Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Add rebuild cache functionality to issue and patch listing
Sebastian Martinez committed 10 months ago
commit 499a0983ea87efffbdf9ac9509b30d3ce23e9636
parent 1e1810b
13 files changed +365 -10
modified Cargo.lock
@@ -4115,6 +4115,7 @@ dependencies = [
 "serde_json",
 "sqlite",
 "ssh-key",
+
 "tauri",
 "tauri-plugin-clipboard-manager",
 "tauri-plugin-fs",
 "tempfile",
modified crates/radicle-tauri/src/commands/cob/issue.rs
@@ -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(())
+
}
modified crates/radicle-tauri/src/commands/cob/patch.rs
@@ -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(())
+
}
modified crates/radicle-tauri/src/lib.rs
@@ -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,
modified crates/radicle-types/Cargo.toml
@@ -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"] }

\ No newline at end of file
+
radicle = { version = "0.15.0", features = ["test"] }
added crates/radicle-types/bindings/cob/CacheEvent.ts
@@ -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 } };
modified crates/radicle-types/src/cobs.rs
@@ -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)]
modified crates/radicle-types/src/error.rs
@@ -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),
modified src/components/Border.svelte
@@ -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;
added src/lib/issueCounts.svelte.ts
@@ -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];
+
}
added src/lib/patchCounts.svelte.ts
@@ -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));
+
  }
+
}
modified src/views/repo/Issues.svelte
@@ -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">
modified src/views/repo/Patches.svelte
@@ -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