Add rebuild cache functionality to issue and patch listing

In case of an outdated cobs cache we display a banner on top of the
patch or issue listing, to allow users to rebuild the cobs cache of a
repo similar to running `rad patch cache`
After rebuilding we fetch the issues or patches from the start.

Small caveat with patches:
The banner can only be shown after a user fetched all patches from the
backend, so we are sure we tried to fetch all of them.
This commit is contained in:
Sebastian Martinez
2025-06-11 18:24:27 +02:00
committed by Rūdolfs Ošiņš
parent 1e1810b78a
commit 499a0983ea
13 changed files with 365 additions and 10 deletions
Generated
+1
View File
@@ -4115,6 +4115,7 @@ dependencies = [
"serde_json",
"sqlite",
"ssh-key",
"tauri",
"tauri-plugin-clipboard-manager",
"tauri-plugin-fs",
"tempfile",
@@ -1,5 +1,8 @@
use std::ops::ControlFlow;
use radicle::git;
use radicle::identity;
use radicle::storage::ReadStorage;
use radicle::issue::TYPENAME;
use radicle_types as types;
@@ -66,3 +69,36 @@ pub fn activity_by_issue(
) -> Result<Vec<types::cobs::Operation<types::cobs::issue::Action>>, Error> {
ctx.activity_by_id(rid, &TYPENAME, id)
}
#[tauri::command]
pub async fn rebuild_issue_cache(
ctx: tauri::State<'_, AppState>,
rid: identity::RepoId,
on_event: tauri::ipc::Channel<types::cobs::CacheEvent>,
) -> Result<(), Error> {
let repo = ctx.profile.storage.repository(rid)?;
let mut issues = ctx.profile.issues_mut(&repo)?;
on_event.send(types::cobs::CacheEvent::Started { rid })?;
issues.write_all(|result, progress| {
match result {
Ok((id, _)) => {
if on_event
.send(types::cobs::CacheEvent::Progress {
rid,
oid: **id,
current: progress.current(),
total: progress.total(),
})
.is_err()
{
log::error!("Failed to send progress");
}
}
Err(err) => log::warn!("Failed to retrieve issue: {err}"),
};
ControlFlow::Continue(())
})?;
on_event.send(types::cobs::CacheEvent::Finished { rid })?;
Ok(())
}
@@ -1,4 +1,7 @@
use std::ops::ControlFlow;
use radicle::patch::TYPENAME;
use radicle::storage::ReadStorage;
use radicle::{cob, git, identity};
use radicle_types as types;
@@ -125,3 +128,36 @@ pub fn activity_by_patch(
) -> Result<Vec<types::cobs::Operation<models::patch::Action>>, Error> {
ctx.activity_by_id(rid, &TYPENAME, id)
}
#[tauri::command]
pub async fn rebuild_patch_cache(
ctx: tauri::State<'_, AppState>,
rid: identity::RepoId,
on_event: tauri::ipc::Channel<cobs::CacheEvent>,
) -> Result<(), Error> {
let repo = ctx.profile.storage.repository(rid)?;
let mut patches = ctx.profile.patches_mut(&repo)?;
on_event.send(types::cobs::CacheEvent::Started { rid })?;
patches.write_all(|result, progress| {
match result {
Ok((id, _)) => {
if on_event
.send(cobs::CacheEvent::Progress {
rid,
oid: **id,
current: progress.current(),
total: progress.total(),
})
.is_err()
{
log::error!("Failed to send progress");
}
}
Err(err) => log::warn!("Failed to retrieve patch: {err}"),
};
ControlFlow::Continue(())
})?;
on_event.send(types::cobs::CacheEvent::Finished { rid })?;
Ok(())
}
+2
View File
@@ -31,11 +31,13 @@ pub fn run() {
cob::issue::edit_issue,
cob::issue::issue_by_id,
cob::issue::list_issues,
cob::issue::rebuild_issue_cache,
cob::patch::activity_by_patch,
cob::patch::edit_patch,
cob::patch::list_patches,
cob::patch::patch_by_id,
cob::patch::edit_patch,
cob::patch::rebuild_patch_cache,
cob::patch::review_by_patch_and_revision_and_id,
cob::patch::revisions_by_patch,
cob::patch::revision_by_patch_and_id,
+7 -2
View File
@@ -17,6 +17,7 @@ serde = { version = "1.0.0", features = ["derive"] }
serde_json = { version = "1.0.0" }
sqlite = { version = "0.32.0", features = ["bundled"] }
ssh-key = { version = "0.6.3" }
tauri = { version = "2.5.0", features = ["isolation"] }
tauri-plugin-clipboard-manager = { version = "2.2.2" }
tauri-plugin-fs = { version = "2.2.0" }
tempfile = { version = "3.19.0" }
@@ -39,7 +40,11 @@ tree-sitter-rust = { version = "0.23.2" }
tree-sitter-svelte-ng = { version = "1.0.2" }
tree-sitter-toml-ng = { version = "0.7.0" }
tree-sitter-typescript = { version = "0.23.2" }
ts-rs = { version = "10.1.0", features = ["serde-json-impl", "no-serde-warnings", "format"] }
ts-rs = { version = "10.1.0", features = [
"serde-json-impl",
"no-serde-warnings",
"format",
] }
[dev-dependencies]
radicle = { version = "0.15.0", features = ["test"] }
radicle = { version = "0.15.0", features = ["test"] }
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type CacheEvent = { "event": "started"; "data": { rid: string } } | {
"event": "progress";
"data": { rid: string; oid: string; current: number; total: number };
} | { "event": "finished"; "data": { rid: string } };
+25
View File
@@ -1,3 +1,5 @@
use radicle::git::Oid;
use radicle::prelude::RepoId;
use radicle::profile::Aliases;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
@@ -12,6 +14,29 @@ pub mod repo;
pub mod stream;
pub mod thread;
#[derive(ts_rs::TS, Clone, Serialize)]
#[serde(rename_all = "camelCase", tag = "event", content = "data")]
#[ts(export)]
#[ts(export_to = "cob/")]
pub enum CacheEvent {
Started {
#[ts(as = "String")]
rid: RepoId,
},
Progress {
#[ts(as = "String")]
rid: RepoId,
#[ts(as = "String")]
oid: Oid,
current: usize,
total: usize,
},
Finished {
#[ts(as = "String")]
rid: RepoId,
},
}
#[derive(Debug, Clone, Serialize, TS, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
+4
View File
@@ -43,6 +43,10 @@ pub enum Error {
#[error(transparent)]
TauriPluginFs(#[from] tauri_plugin_fs::Error),
/// Tauri error.
#[error(transparent)]
Tauri(#[from] tauri::Error),
/// Project error.
#[error(transparent)]
ProjectError(#[from] radicle::identity::project::ProjectError),
+8 -1
View File
@@ -3,7 +3,14 @@
interface Props {
children: Snippet;
variant: "primary" | "secondary" | "ghost" | "float" | "danger" | "success";
variant:
| "primary"
| "secondary"
| "ghost"
| "float"
| "danger"
| "success"
| "outline";
hoverable?: boolean;
onclick?: (e: MouseEvent) => void;
stylePosition?: string;
+30
View File
@@ -0,0 +1,30 @@
import type { IssueStatus } from "@app/views/repo/router";
import type { ProjectPayload } from "@bindings/repo/ProjectPayload";
export const issueCounts = $state<
Record<IssueStatus, { sidebar: number | null; total: number | null }>
>({
all: { sidebar: null, total: null },
open: { sidebar: null, total: null },
closed: { sidebar: null, total: null },
});
export function issueCountMismatch(status: IssueStatus) {
return issueCounts[status].total !== issueCounts[status].sidebar;
}
export function resetIssueCounts() {
Object.values(issueCounts).forEach(count => {
count.sidebar = null;
count.total = null;
});
}
export function updateIssueCounts(
itemCount: number,
sidebarCount: ProjectPayload["meta"]["issues"] & { all: number },
status: IssueStatus,
) {
issueCounts[status].total = itemCount;
issueCounts[status].sidebar = sidebarCount[status];
}
+41
View File
@@ -0,0 +1,41 @@
import type { PatchStatus } from "@app/views/repo/router";
import type { ProjectPayload } from "@bindings/repo/ProjectPayload";
import sum from "lodash/sum";
export const patchCounts = $state<
Record<PatchStatus | "all", { sidebar: number | null; total: number | null }>
>({
draft: { sidebar: null, total: null },
open: { sidebar: null, total: null },
archived: { sidebar: null, total: null },
merged: { sidebar: null, total: null },
all: { sidebar: null, total: null },
});
export function patchCountMismatch(status?: PatchStatus) {
return (
patchCounts[status || "all"].total !== patchCounts[status || "all"].sidebar
);
}
export function resetPatchCounts() {
Object.values(patchCounts).forEach(count => {
count.sidebar = null;
count.total = null;
});
}
export function updatePatchCounts(
itemCount: number,
sidebarCount: ProjectPayload["meta"]["patches"],
status?: PatchStatus,
) {
if (status) {
patchCounts[status].total = itemCount;
patchCounts[status].sidebar = sidebarCount[status];
} else {
patchCounts["all"].total = itemCount;
patchCounts["all"].sidebar = sum(Object.values(sidebarCount));
}
}
+67 -1
View File
@@ -1,13 +1,21 @@
<script lang="ts">
import type { CacheEvent } from "@bindings/cob/CacheEvent";
import type { Config } from "@bindings/config/Config";
import type { Issue } from "@bindings/cob/issue/Issue";
import type { IssueStatus } from "./router";
import type { IssueStatus } from "@app/views/repo/router";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import delay from "lodash/delay";
import fuzzysort from "fuzzysort";
import { Channel } from "@tauri-apps/api/core";
import * as router from "@app/lib/router";
import { explorerUrl, modifierKey } from "@app/lib/utils";
import { invoke } from "@app/lib/invoke";
import {
issueCountMismatch,
resetIssueCounts,
} from "@app/lib/issueCounts.svelte";
import Border from "@app/components/Border.svelte";
import Button from "@app/components/Button.svelte";
@@ -15,6 +23,7 @@
import IssueTeaser from "@app/components/IssueTeaser.svelte";
import IssuesSecondColumn from "@app/components/IssuesSecondColumn.svelte";
import NodeBreadcrumb from "@app/components/NodeBreadcrumb.svelte";
import Spinner from "@app/components/Spinner.svelte";
import TextInput from "@app/components/TextInput.svelte";
import BreadcrumbCopyButton from "./BreadcrumbCopyButton.svelte";
@@ -34,10 +43,33 @@
let { notificationCount, repo, issues, config, status }: Props = $props();
/* eslint-enable prefer-const */
let cacheState: CacheEvent | undefined = $state();
const project = $derived(repo.payloads["xyz.radicle.project"]!);
let searchInput = $state("");
async function rebuildIssueCache() {
try {
await invoke("rebuild_issue_cache", {
rid: repo.rid,
onEvent: new Channel<CacheEvent>(message => {
cacheState = message;
}),
});
} catch (error) {
console.error(error);
} finally {
issues = await invoke<Issue[]>("list_issues", { rid: repo.rid, status });
resetIssueCounts();
delay(() => {
cacheState = undefined;
}, 1500);
}
}
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
status;
@@ -112,6 +144,40 @@
{/snippet}
<div class="container">
{#if issueCountMismatch(status)}
<div style="margin-bottom: 1rem;">
<Border
styleOverflow="hidden"
styleBackgroundColor="var(--color-fill-private)"
stylePadding="0.25rem 0.5rem"
styleGap="1rem"
variant="outline">
<div class="txt-overflow txt-small global-flex">
<Icon name="warning" />
<span class="txt-overflow">
There’s a problem with your COB cache, so some issues may not be
displayed. You can rebuild the cache to resolve this.
</span>
</div>
<div style:margin-left="auto">
<Button
variant="ghost"
onclick={rebuildIssueCache}
disabled={cacheState !== undefined}>
{#if cacheState?.event === "started" || cacheState?.event === "progress"}
Rebuilding
<Spinner />
{:else if cacheState?.event === "finished"}
Done
<Icon name="checkmark" />
{:else}
Rebuild cache
{/if}
</Button>
</div>
</Border>
</div>
{/if}
<div class="header">
<div>Issues</div>
<div class="global-flex" style:margin-left="auto" style:gap="0.75rem">
+102 -6
View File
@@ -1,23 +1,33 @@
<script lang="ts">
import type { CacheEvent } from "@bindings/cob/CacheEvent";
import type { Config } from "@bindings/config/Config";
import type { PaginatedQuery } from "@bindings/cob/PaginatedQuery";
import type { Patch } from "@bindings/cob/patch/Patch";
import type { PatchStatus } from "./router";
import type { PatchStatus } from "@app/views/repo/router";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import delay from "lodash/delay";
import fuzzysort from "fuzzysort";
import { Channel } from "@tauri-apps/api/core";
import * as router from "@app/lib/router";
import { DEFAULT_TAKE } from "./router";
import { invoke } from "@app/lib/invoke";
import { DEFAULT_TAKE } from "@app/views/repo/router";
import { explorerUrl, modifierKey } from "@app/lib/utils";
import { invoke } from "@app/lib/invoke";
import {
patchCountMismatch,
resetPatchCounts,
updatePatchCounts,
} from "@app/lib/patchCounts.svelte";
import Border from "@app/components/Border.svelte";
import Button from "@app/components/Button.svelte";
import Icon from "@app/components/Icon.svelte";
import NewPatchButton from "@app/components/NewPatchButton.svelte";
import NodeBreadcrumb from "@app/components/NodeBreadcrumb.svelte";
import PatchTeaser from "@app/components/PatchTeaser.svelte";
import PatchesSecondColumn from "@app/components/PatchesSecondColumn.svelte";
import Spinner from "@app/components/Spinner.svelte";
import TextInput from "@app/components/TextInput.svelte";
import BreadcrumbCopyButton from "./BreadcrumbCopyButton.svelte";
@@ -39,10 +49,25 @@
let cursor = patches.cursor;
let more = patches.more;
const project = $derived(repo.payloads["xyz.radicle.project"]!);
let cacheState: CacheEvent | undefined = $state();
$effect(() => {
items = patches.content;
cursor = patches.cursor;
more = patches.more;
// If the first page is not full, we know there are no more patches.
if (patches.more === true && patches.content.length < DEFAULT_TAKE) {
more = false;
} else {
more = patches.more;
}
});
$effect(() => {
if (more === false) {
updatePatchCounts(items.length, project.meta.patches, status);
}
});
$effect(() => {
@@ -52,6 +77,36 @@
searchInput = "";
});
async function rebuildPatchCache() {
try {
await invoke("rebuild_patch_cache", {
rid: repo.rid,
onEvent: new Channel<CacheEvent>(message => {
cacheState = message;
}),
});
} catch (error) {
console.error(error);
} finally {
const p = await invoke<PaginatedQuery<Patch[]>>("list_patches", {
rid: repo.rid,
skip: 0,
status,
take: DEFAULT_TAKE,
});
items = p.content;
cursor = p.cursor;
more = p.more;
resetPatchCounts();
delay(() => {
cacheState = undefined;
}, 1500);
}
}
async function loadMoreContent(all: boolean = false) {
if (more) {
const p = await invoke<PaginatedQuery<Patch[]>>("list_patches", {
@@ -69,11 +124,18 @@
} else {
items = [...items, ...p.content];
}
// If the newly fetched patches are empty, there is no more to fetch.
if (p.content.length === 0) {
more = false;
}
if (more === false) {
updatePatchCounts(items.length, project.meta.patches, status);
}
}
}
const project = $derived(repo.payloads["xyz.radicle.project"]!);
let loading: boolean = $state(false);
let searchInput = $state("");
@@ -146,6 +208,40 @@
{/snippet}
<div class="container">
{#if patchCountMismatch(status)}
<div style="margin-bottom: 1rem;">
<Border
styleOverflow="hidden"
styleBackgroundColor="var(--color-fill-private)"
stylePadding="0.25rem 0.5rem"
styleGap="1rem"
variant="outline">
<div class="txt-overflow txt-small global-flex">
<Icon name="warning" />
<span class="txt-overflow">
There’s a problem with your COB cache, so some patches may not be
displayed. You can rebuild the cache to resolve this.
</span>
</div>
<div style:margin-left="auto">
<Button
variant="ghost"
onclick={rebuildPatchCache}
disabled={cacheState !== undefined}>
{#if cacheState?.event === "started" || cacheState?.event === "progress"}
Rebuilding
<Spinner />
{:else if cacheState?.event === "finished"}
Done
<Icon name="checkmark" />
{:else}
Rebuild cache
{/if}
</Button>
</div>
</Border>
</div>
{/if}
<div class="header">
Patches