Radish alpha
r
Radicle desktop app
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add rebuild cache functionality to issue and patch listing
Sebastian Martinez committed 11 months ago
commit 499a0983ea87efffbdf9ac9509b30d3ce23e9636
parent 1e1810b78a4e6eac051ef1afa2932dd0aacbdd70
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