Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Implement issue and patch list fuzzy finder
Merged rudolfs opened 1 year ago
24 files changed +498 -163 8a3e480b 12afb43e
modified crates/radicle-tauri/src/commands/cob/patch.rs
@@ -22,32 +22,50 @@ pub async fn list_patches(
    rid: identity::RepoId,
    status: Option<types::cobs::query::PatchStatus>,
    skip: Option<usize>,
+
    // None: return all patches, `skip` is ignored.
    take: Option<usize>,
) -> Result<types::cobs::PaginatedQuery<Vec<models::patch::Patch>>, Error> {
    let profile = ctx.profile();
    let cursor = skip.unwrap_or(0);
-
    let take = take.unwrap_or(20);
    let aliases = profile.aliases();
+

    let patches = match status {
        None => sqlite_service.list(rid)?.collect::<Vec<_>>(),
        Some(s) => sqlite_service
            .list_by_status(rid, s.into())?
            .collect::<Vec<_>>(),
    };
-
    let more = cursor + take < patches.len();

-
    let patches = patches
-
        .into_iter()
-
        .map(|(id, patch)| models::patch::Patch::new(id, &patch, &aliases))
-
        .skip(cursor)
-
        .take(take)
-
        .collect::<Vec<_>>();
+
    match take {
+
        None => {
+
            let content = patches
+
                .into_iter()
+
                .map(|(id, patch)| models::patch::Patch::new(id, &patch, &aliases))
+
                .collect::<Vec<_>>();
+

+
            Ok::<_, Error>(cobs::PaginatedQuery {
+
                cursor: 0,
+
                more: false,
+
                content,
+
            })
+
        }
+
        Some(take) => {
+
            let more = cursor + take < patches.len();
+

+
            let content = patches
+
                .into_iter()
+
                .map(|(id, patch)| models::patch::Patch::new(id, &patch, &aliases))
+
                .skip(cursor)
+
                .take(take)
+
                .collect::<Vec<_>>();

-
    Ok::<_, Error>(cobs::PaginatedQuery {
-
        cursor,
-
        more,
-
        content: patches,
-
    })
+
            Ok::<_, Error>(cobs::PaginatedQuery {
+
                cursor,
+
                more,
+
                content,
+
            })
+
        }
+
    }
}

#[tauri::command]
modified crates/radicle-tauri/src/commands/diff.rs
@@ -9,7 +9,7 @@ use crate::AppState;
pub async fn get_diff(
    ctx: tauri::State<'_, AppState>,
    rid: identity::RepoId,
-
    options: radicle_types::cobs::diff::Options,
+
    options: radicle_types::cobs::diff::DiffOptions,
) -> Result<types::diff::Diff, Error> {
    ctx.get_diff(rid, options)
}
modified crates/radicle-tauri/src/commands/repo.rs
@@ -41,7 +41,7 @@ pub async fn diff_stats(
    rid: RepoId,
    base: git::Oid,
    head: git::Oid,
-
) -> Result<types::cobs::Stats, Error> {
+
) -> Result<types::diff::Stats, Error> {
    ctx.diff_stats(rid, base, head)
}

added crates/radicle-types/bindings/cob/DiffOptions.ts
@@ -0,0 +1,8 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+

+
export type DiffOptions = {
+
  base: string;
+
  head: string;
+
  unified: number | null;
+
  highlight: boolean | null;
+
};
deleted crates/radicle-types/bindings/cob/Stats.ts
@@ -1,7 +0,0 @@
-
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
-

-
export type Stats = {
-
  files_changed: number;
-
  insertions: number;
-
  deletions: number;
-
};
modified crates/radicle-types/src/cobs.rs
@@ -96,25 +96,6 @@ pub struct PaginatedQuery<T> {
    pub content: T,
}

-
#[derive(TS, Serialize)]
-
#[ts(export)]
-
#[ts(export_to = "cob/")]
-
pub struct Stats {
-
    pub files_changed: usize,
-
    pub insertions: usize,
-
    pub deletions: usize,
-
}
-

-
impl Stats {
-
    pub fn new(stats: &radicle_surf::diff::Stats) -> Self {
-
        Self {
-
            files_changed: stats.files_changed,
-
            insertions: stats.insertions,
-
            deletions: stats.deletions,
-
        }
-
    }
-
}
-

pub mod query {
    use serde::{Deserialize, Serialize};

modified crates/radicle-types/src/cobs/diff.rs
@@ -1,9 +1,14 @@
use radicle::git;
use serde::{Deserialize, Serialize};
+
use ts_rs::TS;

-
#[derive(Serialize, Deserialize)]
-
pub struct Options {
+
#[derive(TS, Serialize, Deserialize)]
+
#[ts(export)]
+
#[ts(export_to = "cob/")]
+
pub struct DiffOptions {
+
    #[ts(as = "String")]
    pub base: git::Oid,
+
    #[ts(as = "String")]
    pub head: git::Oid,
    pub unified: Option<u32>,
    pub highlight: Option<bool>,
modified crates/radicle-types/src/diff.rs
@@ -14,6 +14,16 @@ pub struct Diff {
    pub stats: Stats,
}

+
impl Stats {
+
    pub fn new(stats: &radicle_surf::diff::Stats) -> Self {
+
        Self {
+
            files_changed: stats.files_changed,
+
            insertions: stats.insertions,
+
            deletions: stats.deletions,
+
        }
+
    }
+
}
+

impl From<surf::diff::Diff> for Diff {
    fn from(value: surf::diff::Diff) -> Self {
        Self {
modified crates/radicle-types/src/traits/repo.rs
@@ -10,6 +10,7 @@ use radicle::storage::{ReadRepository, ReadStorage, RepositoryInfo};
use radicle::{git, identity};

use crate::cobs;
+
use crate::diff;
use crate::diff::Diff;
use crate::error::Error;
use crate::repo::{self, RepoCount};
@@ -122,7 +123,7 @@ pub trait Repo: Profile {
        rid: identity::RepoId,
        base: git::Oid,
        head: git::Oid,
-
    ) -> Result<cobs::Stats, Error> {
+
    ) -> Result<diff::Stats, Error> {
        let profile = self.profile();
        let repo = radicle_surf::Repository::open(storage::git::paths::repository(
            &profile.storage,
@@ -133,7 +134,7 @@ pub trait Repo: Profile {
        let diff = repo.diff(base.id, commit.id)?;
        let stats = diff.stats();

-
        Ok::<_, Error>(cobs::Stats::new(stats))
+
        Ok::<_, Error>(diff::Stats::new(stats))
    }

    fn repo_info(
@@ -182,7 +183,11 @@ pub trait Repo: Profile {
        })
    }

-
    fn get_diff(&self, rid: identity::RepoId, options: cobs::diff::Options) -> Result<Diff, Error> {
+
    fn get_diff(
+
        &self,
+
        rid: identity::RepoId,
+
        options: cobs::diff::DiffOptions,
+
    ) -> Result<Diff, Error> {
        let unified = options.unified.unwrap_or(5);
        let highlight = options.highlight.unwrap_or(true);
        let profile = self.profile();
modified crates/test-http-api/src/api.rs
@@ -165,7 +165,7 @@ async fn diff_stats_handler(
#[derive(Serialize, Deserialize)]
struct DiffBody {
    pub rid: identity::RepoId,
-
    pub options: types::cobs::diff::Options,
+
    pub options: types::cobs::diff::DiffOptions,
}

async fn diff_handler(
@@ -339,7 +339,7 @@ async fn issue_threads_handler(
struct PatchesBody {
    pub rid: identity::RepoId,
    pub skip: Option<usize>,
-
    pub take: Option<usize>,
+
    pub take: Option<isize>,
    pub status: Option<types::cobs::query::PatchStatus>,
}

@@ -354,8 +354,8 @@ async fn patches_handler(
) -> impl IntoResponse {
    let profile = ctx.profile;
    let cursor = skip.unwrap_or(0);
-
    let take = take.unwrap_or(20);
    let aliases = profile.aliases();
+

    let patches = match status {
        None => ctx.patches.list(rid)?.collect::<Vec<_>>(),
        Some(s) => ctx
@@ -363,6 +363,25 @@ async fn patches_handler(
            .list_by_status(rid, s.into())?
            .collect::<Vec<_>>(),
    };
+

+
    if let Some(t) = take {
+
        if t < 0 {
+
            // Return all patches
+
            let content = patches
+
                .into_iter()
+
                .map(|(id, patch)| models::patch::Patch::new(id, &patch, &aliases))
+
                .collect::<Vec<_>>();
+

+
            return Ok::<_, Error>(Json(cobs::PaginatedQuery {
+
                cursor: 0,
+
                more: false,
+
                content,
+
            }));
+
        }
+
    }
+

+
    let take = take.unwrap_or(20) as usize;
+

    let more = cursor + take < patches.len();

    let patches = patches
modified package-lock.json
@@ -44,6 +44,7 @@
        "hast-util-to-dom": "^4.0.1",
        "keyux": "^0.11.1",
        "lodash": "^4.17.21",
+
        "lru-cache": "^11.1.0",
        "marked": "^15.0.7",
        "marked-emoji": "^2.0.0",
        "marked-footnote": "^1.2.4",
@@ -3388,6 +3389,16 @@
      "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==",
      "dev": true
    },
+
    "node_modules/lru-cache": {
+
      "version": "11.1.0",
+
      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz",
+
      "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==",
+
      "dev": true,
+
      "license": "ISC",
+
      "engines": {
+
        "node": "20 || >=22"
+
      }
+
    },
    "node_modules/magic-string": {
      "version": "0.30.17",
      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
modified package.json
@@ -59,6 +59,7 @@
    "hast-util-to-dom": "^4.0.1",
    "keyux": "^0.11.1",
    "lodash": "^4.17.21",
+
    "lru-cache": "^11.1.0",
    "marked": "^15.0.7",
    "marked-emoji": "^2.0.0",
    "marked-footnote": "^1.2.4",
modified src/components/Changes.svelte
@@ -1,10 +1,8 @@
<script lang="ts">
-
  import type { Commit } from "@bindings/repo/Commit";
-
  import type { Diff } from "@bindings/diff/Diff";
  import type { CodeComments } from "./Diff.svelte";
  import type { Revision } from "@bindings/cob/patch/Revision";

-
  import { invoke } from "@app/lib/invoke";
+
  import { cachedGetDiff, cachedListCommits } from "@app/lib/invoke";
  import { pluralize } from "@app/lib/utils";

  import Changeset from "@app/components/Changeset.svelte";
@@ -57,26 +55,6 @@
  const isActiveCommit = (commitId: string) => selectedCommit === commitId;
  const isTeaserDisabled = (commitId: string) =>
    selectedCommit ? selectedCommit !== commitId : false;
-

-
  async function loadHighlightedDiff(rid: string, base: string, head: string) {
-
    return invoke<Diff>("get_diff", {
-
      rid,
-
      options: {
-
        base,
-
        head,
-
        unified: 3,
-
        highlight: true,
-
      },
-
    });
-
  }
-

-
  async function loadCommits(rid: string, base: string, head: string) {
-
    return invoke<Commit[]>("list_commits", {
-
      rid,
-
      base,
-
      head,
-
    });
-
  }
</script>

<style>
@@ -158,7 +136,7 @@
</div>

<div class:hide={hideChanges}>
-
  {#await loadCommits(rid, revision.base, revision.head) then commits}
+
  {#await cachedListCommits(rid, revision.base, revision.head) then commits}
    <div style:margin-bottom="1rem">
      <CommitsContainer>
        {#snippet leftHeader()}
@@ -209,7 +187,7 @@
    </div>
  {/await}

-
  {#await loadHighlightedDiff(rid, base, head)}
+
  {#await cachedGetDiff(rid, { base, head, unified: 3, highlight: true })}
    <span class="txt-small">Loading…</span>
  {:then diff}
    <Changeset expanded={filesExpanded} {head} {diff} {codeComments} />
modified src/components/DiffStatBadge.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { Stats } from "@bindings/cob/Stats";
+
  import type { Stats } from "@bindings/diff/Stats";

  interface Props {
    stats: Stats;
modified src/components/Icon.svelte
@@ -36,6 +36,7 @@
      | "eye"
      | "face"
      | "file"
+
      | "filter"
      | "home"
      | "info"
      | "inbox"
@@ -545,6 +546,21 @@
    <path d="M4 2H9V3L4 3V2Z" />
    <path d="M13 6V13H12V6L13 6Z" />
    <path d="M4 3V13H3L3 3L4 3Z" />
+
  {:else if name === "filter"}
+
    <path d="M3 2L13 2V3L3 3V2Z" />
+
    <path d="M13 3L14 3V4L13 4V3Z" />
+
    <path d="M12 4L13 4V5H12V4Z" />
+
    <path d="M11 5L12 5V7H11V5Z" />
+
    <path d="M5 5H4V7H5V5Z" />
+
    <path d="M10 7L11 7V9H10V7Z" />
+
    <path d="M6 7L5 7L5 9H6V7Z" />
+
    <path d="M9 9L10 9L10 13L9 13L9 9Z" />
+
    <path d="M7 9H6V12H7V9Z" />
+
    <path d="M2 3L3 3L3 4H2L2 3Z" />
+
    <path d="M3 4H4L4 5L3 5L3 4Z" />
+
    <path d="M8 13H9L9 14H8V13Z" />
+
    <path d="M6 4L12 4V5L6 5V4Z" />
+
    <path d="M7 12H8L8 13H7L7 12Z" />
  {:else if name === "home"}
    <path d="M7 1.50003H9V2.50003H7V1.50003Z" />
    <path d="M6 2.50003L7 2.50003V3.50003H6V2.50003Z" />
modified src/components/IssueTeaser.svelte
@@ -10,6 +10,7 @@
  } from "@app/lib/utils";
  import { push } from "@app/lib/router";

+
  import Border from "./Border.svelte";
  import Icon from "./Icon.svelte";
  import Id from "./Id.svelte";
  import InlineTitle from "./InlineTitle.svelte";
@@ -17,19 +18,21 @@
  import NodeId from "./NodeId.svelte";

  interface Props {
+
    compact?: boolean;
+
    focussed?: boolean;
    issue: Issue;
    rid: string;
-
    status: IssueStatus;
    selected?: boolean;
-
    compact?: boolean;
+
    status: IssueStatus;
  }

  const {
+
    compact = false,
+
    focussed,
    issue,
    rid,
-
    status,
    selected = false,
-
    compact = false,
+
    status,
  }: Props = $props();
</script>

@@ -37,7 +40,6 @@
  .issue-teaser {
    display: flex;
    align-items: center;
-
    justify-content: space-between;
    gap: 0.25rem;
    min-height: 5rem;
    background-color: var(--color-background-float);
@@ -67,16 +69,7 @@
  }
</style>

-
<!-- svelte-ignore a11y_click_events_have_key_events -->
-
<div
-
  tabindex="0"
-
  role="button"
-
  class="issue-teaser"
-
  class:selected
-
  style:align-items="flex-start"
-
  onclick={() => {
-
    void push({ resource: "repo.issue", rid, issue: issue.id, status });
-
  }}>
+
{#snippet issueSnippet()}
  <div class="global-flex" style:align-items="flex-start">
    <div
      class="global-counter status"
@@ -102,7 +95,7 @@
    </div>
  </div>

-
  <div class="global-flex">
+
  <div class="global-flex" style:margin-left="auto">
    {#if !compact}
      {#each issue.labels as label}
        <Label {label} />
@@ -116,4 +109,29 @@
      </div>
    {/if}
  </div>
-
</div>
+
{/snippet}
+

+
{#if focussed}
+
  <Border
+
    styleBackgroundColor="var(--color-background-float)"
+
    styleDisplay="flex"
+
    styleAlignItems="flex-start"
+
    styleMinHeight="5rem"
+
    stylePadding="1rem"
+
    variant="secondary">
+
    {@render issueSnippet()}
+
  </Border>
+
{:else}
+
  <!-- svelte-ignore a11y_click_events_have_key_events -->
+
  <div
+
    tabindex="0"
+
    role="button"
+
    class="issue-teaser"
+
    class:selected
+
    style:align-items="flex-start"
+
    onclick={() => {
+
      void push({ resource: "repo.issue", rid, issue: issue.id, status });
+
    }}>
+
    {@render issueSnippet()}
+
  </div>
+
{/if}
modified src/components/PatchTeaser.svelte
@@ -1,7 +1,6 @@
<script lang="ts">
  import type { Patch } from "@bindings/cob/patch/Patch";
  import type { PatchStatus } from "@app/views/repo/router";
-
  import type { Stats } from "@bindings/cob/Stats";

  import {
    authorForNodeId,
@@ -9,7 +8,7 @@
    patchStatusBackgroundColor,
    patchStatusColor,
  } from "@app/lib/utils";
-
  import { invoke } from "@app/lib/invoke";
+
  import { cachedDiffStats } from "@app/lib/invoke";
  import { push } from "@app/lib/router";

  import DiffStatBadge from "@app/components/DiffStatBadge.svelte";
@@ -18,9 +17,11 @@
  import InlineTitle from "@app/components/InlineTitle.svelte";
  import Label from "@app/components/Label.svelte";
  import NodeId from "@app/components/NodeId.svelte";
+
  import Border from "./Border.svelte";

  interface Props {
    compact?: boolean;
+
    focussed?: boolean;
    loadPatch?: (patchId: string) => Promise<void>;
    patch: Patch;
    rid: string;
@@ -30,6 +31,7 @@

  const {
    compact = false,
+
    focussed,
    loadPatch,
    patch,
    rid,
@@ -72,26 +74,7 @@
  }
</style>

-
<!-- svelte-ignore a11y_click_events_have_key_events -->
-
<div
-
  tabindex="0"
-
  role="button"
-
  class:selected
-
  class="patch-teaser"
-
  style:align-items="flex-start"
-
  onclick={async () => {
-
    if (loadPatch) {
-
      await loadPatch(patch.id);
-
    } else {
-
      void push({
-
        resource: "repo.patch",
-
        rid,
-
        patch: patch.id,
-
        status,
-
        reviewId: undefined,
-
      });
-
    }
-
  }}>
+
{#snippet patchSnippet()}
  <div class="global-flex" style:align-items="flex-start">
    <div
      class="global-counter status"
@@ -116,9 +99,9 @@
    </div>
  </div>

-
  <div class="global-flex">
+
  <div class="global-flex" style:margin-left="auto">
    {#if !compact}
-
      {#await invoke<Stats>( "diff_stats", { rid, base: patch.base, head: patch.head }, ) then stats}
+
      {#await cachedDiffStats(rid, patch.base, patch.head) then stats}
        <DiffStatBadge {stats} />
      {/await}

@@ -134,4 +117,39 @@
      {patch.revisionCount}
    </div>
  </div>
-
</div>
+
{/snippet}
+

+
{#if focussed}
+
  <Border
+
    styleBackgroundColor="var(--color-background-float)"
+
    styleDisplay="flex"
+
    styleAlignItems="flex-start"
+
    styleMinHeight="5rem"
+
    stylePadding="1rem"
+
    variant="secondary">
+
    {@render patchSnippet()}
+
  </Border>
+
{:else}
+
  <!-- svelte-ignore a11y_click_events_have_key_events -->
+
  <div
+
    tabindex="0"
+
    role="button"
+
    class:selected
+
    class="patch-teaser"
+
    style:align-items="flex-start"
+
    onclick={async () => {
+
      if (loadPatch) {
+
        await loadPatch(patch.id);
+
      } else {
+
        void push({
+
          resource: "repo.patch",
+
          rid,
+
          patch: patch.id,
+
          status,
+
          reviewId: undefined,
+
        });
+
      }
+
    }}>
+
    {@render patchSnippet()}
+
  </div>
+
{/if}
modified src/components/TextInput.svelte
@@ -1,38 +1,44 @@
<script lang="ts">
+
  import type { FormEventHandler } from "svelte/elements";
+
  import type { Snippet } from "svelte";
+

  import { onMount } from "svelte";

  import Border from "./Border.svelte";
-
  import type { FormEventHandler } from "svelte/elements";

  interface Props {
-
    name?: string;
-
    placeholder?: string;
-
    value?: string;
-
    type?: string;
    autofocus?: boolean;
    autoselect?: boolean;
    disabled?: boolean;
-
    onSubmit?: () => void;
+
    keyShortcuts?: string;
+
    left?: Snippet;
+
    name?: string;
    onDismiss?: () => void;
-
    valid?: boolean;
+
    onFocus?: () => void;
+
    onSubmit?: () => void;
    oninput?: FormEventHandler<HTMLInputElement>;
-
    keyShortcuts?: string;
+
    placeholder?: string;
+
    type?: string;
+
    valid?: boolean;
+
    value?: string;
  }

  /* eslint-disable prefer-const */
  let {
-
    name,
-
    placeholder,
-
    value = $bindable(undefined),
-
    type = "text",
    autofocus = false,
    autoselect = false,
    disabled = false,
-
    onSubmit,
+
    keyShortcuts,
+
    left,
+
    name,
    onDismiss,
-
    valid = true,
+
    onFocus,
+
    onSubmit,
    oninput,
-
    keyShortcuts,
+
    placeholder,
+
    type = "text",
+
    valid = true,
+
    value = $bindable(undefined),
  }: Props = $props();
  /* eslint-enable prefer-const */

@@ -78,7 +84,6 @@
    height: 100%;
    margin: 0;
    height: 32px;
-
    padding: 0.25rem 0.75rem;
    border: 0;
  }
  input::placeholder {
@@ -94,9 +99,14 @@
<Border
  variant={valid ? (focussed ? "secondary" : "ghost") : "danger"}
  styleWidth="100%">
+
  {@render left?.()}
  <input
+
    style:padding={left ? "0.25rem 0.75rem 0.25rem 0" : "0.25rem 0.75rem"}
    aria-keyshortcuts={keyShortcuts}
    onfocus={() => {
+
      if (onFocus) {
+
        onFocus();
+
      }
      focussed = true;
    }}
    onblur={() => {
added src/lib/cached.ts
@@ -0,0 +1,21 @@
+
import { LRUCache } from "lru-cache";
+

+
export function cached<Args extends unknown[], V>(
+
  f: (...args: Args) => Promise<V>,
+
  makeKey: (...args: Args) => string,
+
  options?: LRUCache.Options<string, { value: V }, unknown>,
+
): (...args: Args) => Promise<V> {
+
  const cache = new LRUCache(options || { max: 500 });
+
  return async function (...args: Args): Promise<V> {
+
    const key = makeKey(...args);
+
    const cached = cache.get(key);
+

+
    if (cached === undefined) {
+
      const value = await f(...args);
+
      cache.set(key, { value });
+
      return value;
+
    } else {
+
      return cached.value;
+
    }
+
  };
+
}
modified src/lib/invoke.ts
@@ -1,12 +1,31 @@
+
import type { Commit } from "@bindings/repo/Commit";
+
import type { Diff } from "@bindings/diff/Diff";
+
import type { DiffOptions } from "@bindings/cob/DiffOptions";
+
import type { Stats } from "@bindings/diff/Stats";
+

import * as tauri from "@tauri-apps/api/core";
+
import { cached } from "@app/lib/cached";

export async function invoke<T = null>(
  cmd: string,
  args?: tauri.InvokeArgs,
  options?: tauri.InvokeOptions,
): Promise<T> {
+
  return withTestBackend<T>(tauri.invoke, cmd, args, options);
+
}
+

+
async function withTestBackend<T>(
+
  fn: (
+
    cmd: string,
+
    args?: tauri.InvokeArgs,
+
    options?: tauri.InvokeOptions,
+
  ) => Promise<T>,
+
  cmd: string,
+
  args?: tauri.InvokeArgs,
+
  options?: tauri.InvokeOptions,
+
) {
  if (window.__TAURI_INTERNALS__) {
-
    return tauri.invoke(cmd, args, options);
+
    return fn(cmd, args, options);
  } else {
    return fetch(`http://127.0.0.1:8081/${cmd}`, {
      method: "POST",
@@ -27,6 +46,52 @@ export async function invoke<T = null>(
  }
}

+
async function getDiff(rid: string, options: DiffOptions): Promise<Diff> {
+
  return withTestBackend(tauri.invoke, "get_diff", {
+
    rid,
+
    options,
+
  });
+
}
+

+
export const cachedGetDiff = cached(
+
  getDiff,
+
  (...[rid, options]) =>
+
    `get_diff:${rid}:${JSON.stringify(options, Object.keys(options).sort())}`,
+
  { max: 10_000 },
+
);
+

+
async function listCommits(
+
  rid: string,
+
  base: string,
+
  head: string,
+
): Promise<Commit[]> {
+
  return withTestBackend(tauri.invoke, "list_commits", { rid, base, head });
+
}
+

+
export const cachedListCommits = cached(
+
  listCommits,
+
  (...[rid, base, head]) => `list_commits:${rid}:${base}:${head}`,
+
  { max: 5_000 },
+
);
+

+
async function diffStats(
+
  rid: string,
+
  base: string,
+
  head: string,
+
): Promise<Stats> {
+
  return withTestBackend(tauri.invoke, "diff_stats", {
+
    rid,
+
    base,
+
    head,
+
  });
+
}
+

+
export const cachedDiffStats = cached(
+
  diffStats,
+
  (...[rid, base, head]) => `diff_stats:${rid}:${base}:${head}`,
+
  { max: 10_000 },
+
);
+

export async function writeToClipboard(
  text: string,
  opts?: {
modified src/views/home/Repos.svelte
@@ -122,7 +122,7 @@
  <div class="container">
    <div class="global-flex" style:margin-bottom="0.5rem">
      <div class="header">Repositories</div>
-
      <div style:margin-left="auto">
+
      <div class="global-flex" style:margin-left="auto">
        <TextInput
          onSubmit={async () => {
            if (searchResults.length === 1) {
@@ -136,9 +136,17 @@
          onDismiss={() => {
            searchInput = "";
          }}
-
          placeholder={`Filter repositories ${modifierKey()} + f`}
+
          placeholder={`Fuzzy filter repositories ${modifierKey()} + f`}
          keyShortcuts="ctrl+f"
-
          bind:value={searchInput} />
+
          bind:value={searchInput}>
+
          {#snippet left()}
+
            <div
+
              style:color="var(--color-foreground-dim)"
+
              style:padding-left="0.5rem">
+
              <Icon name="filter" />
+
            </div>
+
          {/snippet}
+
        </TextInput>
      </div>
    </div>
    {#if repos.length > 0}
@@ -146,7 +154,7 @@
        <div class="repo-grid">
          {#each searchResults as result}
            <RepoCard
-
              focussed={searchResults.length === 1}
+
              focussed={searchResults.length === 1 && searchInput !== ""}
              repo={result.obj.repo}
              selfDid={didFromPublicKey(config.publicKey)}
              onclick={() => {
modified src/views/repo/Issues.svelte
@@ -4,7 +4,10 @@
  import type { IssueStatus } from "./router";
  import type { RepoInfo } from "@bindings/repo/RepoInfo";

+
  import fuzzysort from "fuzzysort";
+

  import * as router from "@app/lib/router";
+
  import { modifierKey } from "@app/lib/utils";

  import Layout from "./Layout.svelte";

@@ -15,6 +18,7 @@
  import IssueTeaser from "@app/components/IssueTeaser.svelte";
  import IssuesSecondColumn from "@app/components/IssuesSecondColumn.svelte";
  import Sidebar from "@app/components/Sidebar.svelte";
+
  import TextInput from "@app/components/TextInput.svelte";

  interface Props {
    repo: RepoInfo;
@@ -23,7 +27,35 @@
    status: IssueStatus;
  }

-
  const { repo, issues, config, status }: Props = $props();
+
  /* eslint-disable prefer-const */
+
  let { repo, issues, config, status }: Props = $props();
+
  /* eslint-enable prefer-const */
+

+
  let searchInput = $state("");
+

+
  const searchableIssues = $derived(
+
    issues
+
      .flatMap(i => {
+
        return {
+
          issue: i,
+
          labels: i.labels.join(" "),
+
          assignees: i.assignees
+
            .map(a => {
+
              return a.alias ?? "";
+
            })
+
            .join(" "),
+
          author: i.author.alias ?? "",
+
        };
+
      })
+
      .filter((item): item is NonNullable<typeof item> => item !== undefined),
+
  );
+

+
  const searchResults = $derived(
+
    fuzzysort.go(searchInput, searchableIssues, {
+
      keys: ["issue.title", "labels", "assignees", "author"],
+
      all: true,
+
    }),
+
  );
</script>

<style>
@@ -40,7 +72,6 @@
    font-size: var(--font-size-medium);
    display: flex;
    align-items: center;
-
    justify-content: space-between;
    min-height: 40px;
    margin-bottom: 0.5rem;
  }
@@ -65,24 +96,55 @@
  <div class="container">
    <div class="header">
      <div>Issues</div>
-
      <div class="txt-regular txt-semibold">
-
        <Button
-
          variant="secondary"
-
          onclick={() => {
-
            void router.push({
-
              resource: "repo.createIssue",
-
              status,
-
              rid: repo.rid,
-
            });
-
          }}>
-
          <Icon name="plus" />New
-
        </Button>
+
      <div class="global-flex" style:margin-left="auto">
+
        <TextInput
+
          onSubmit={async () => {
+
            if (searchResults.length === 1) {
+
              await router.push({
+
                resource: "repo.issue",
+
                rid: repo.rid,
+
                issue: searchResults[0].obj.issue.id,
+
                status,
+
              });
+
            }
+
          }}
+
          onDismiss={() => {
+
            searchInput = "";
+
          }}
+
          placeholder={`Fuzzy filter issues ${modifierKey()} + f`}
+
          keyShortcuts="ctrl+f"
+
          bind:value={searchInput}>
+
          {#snippet left()}
+
            <div
+
              style:color="var(--color-foreground-dim)"
+
              style:padding-left="0.5rem">
+
              <Icon name="filter" />
+
            </div>
+
          {/snippet}
+
        </TextInput>
+
        <div class="txt-regular txt-semibold">
+
          <Button
+
            variant="secondary"
+
            onclick={() => {
+
              void router.push({
+
                resource: "repo.createIssue",
+
                status,
+
                rid: repo.rid,
+
              });
+
            }}>
+
            <Icon name="plus" />New
+
          </Button>
+
        </div>
      </div>
    </div>

    <div class="list">
-
      {#each issues as issue}
-
        <IssueTeaser {issue} rid={repo.rid} {status} />
+
      {#each searchResults as result}
+
        <IssueTeaser
+
          focussed={searchResults.length === 1 && searchInput !== ""}
+
          issue={result.obj.issue}
+
          rid={repo.rid}
+
          {status} />
      {/each}

      {#if issues.length === 0}
modified src/views/repo/Patches.svelte
@@ -2,18 +2,23 @@
  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 { DEFAULT_TAKE, type PatchStatus } from "./router";
  import type { RepoInfo } from "@bindings/repo/RepoInfo";

+
  import fuzzysort from "fuzzysort";
+

+
  import * as router from "@app/lib/router";
  import { invoke } from "@app/lib/invoke";
+
  import { modifierKey } from "@app/lib/utils";

+
  import Border from "@app/components/Border.svelte";
  import CopyableId from "@app/components/CopyableId.svelte";
  import Icon from "@app/components/Icon.svelte";
  import Layout from "./Layout.svelte";
  import PatchTeaser from "@app/components/PatchTeaser.svelte";
  import PatchesSecondColumn from "@app/components/PatchesSecondColumn.svelte";
  import Sidebar from "@app/components/Sidebar.svelte";
-
  import Border from "@app/components/Border.svelte";
+
  import TextInput from "@app/components/TextInput.svelte";

  interface Props {
    repo: RepoInfo;
@@ -24,6 +29,8 @@

  const { repo, patches, config, status }: Props = $props();

+
  let loading: boolean = $state(false);
+

  let items = $state(patches.content);
  let cursor = patches.cursor;
  let more = patches.more;
@@ -34,22 +41,53 @@
    more = patches.more;
  });

-
  async function loadMoreContent() {
+
  async function loadMoreContent(all: boolean = false) {
    if (more) {
      const p = await invoke<PaginatedQuery<Patch[]>>("list_patches", {
        rid: repo.rid,
-
        skip: cursor + 20,
+
        skip: cursor + DEFAULT_TAKE,
        status,
-
        take: 20,
+
        take: all ? undefined : DEFAULT_TAKE,
      });

      cursor = p.cursor;
      more = p.more;
-
      items = [...items, ...p.content];
+

+
      if (all) {
+
        items = p.content;
+
      } else {
+
        items = [...items, ...p.content];
+
      }
    }
  }

  const project = $derived(repo.payloads["xyz.radicle.project"]!);
+

+
  let searchInput = $state("");
+

+
  const searchablePatches = $derived(
+
    items
+
      .flatMap(i => {
+
        return {
+
          patch: i,
+
          labels: i.labels.join(" "),
+
          assignees: i.assignees
+
            .map(a => {
+
              return a.alias ?? "";
+
            })
+
            .join(" "),
+
          author: i.author.alias ?? "",
+
        };
+
      })
+
      .filter((item): item is NonNullable<typeof item> => item !== undefined),
+
  );
+

+
  const searchResults = $derived(
+
    fuzzysort.go(searchInput, searchablePatches, {
+
      keys: ["patch.title", "labels", "assignees", "author"],
+
      all: true,
+
    }),
+
  );
</script>

<style>
@@ -89,11 +127,57 @@
  {/snippet}

  <div class="container">
-
    <div class="header">Patches</div>
+
    <div class="header">
+
      Patches
+

+
      <div class="global-flex" style:margin-left="auto">
+
        <TextInput
+
          onFocus={async () => {
+
            try {
+
              loading = true;
+
              // Load all patches.
+
              await loadMoreContent(true);
+
            } catch (e) {
+
              console.error("Loading all patches failed: ", e);
+
            } finally {
+
              loading = false;
+
            }
+
          }}
+
          onSubmit={async () => {
+
            if (searchResults.length === 1) {
+
              await router.push({
+
                patch: searchResults[0].obj.patch.id,
+
                resource: "repo.patch",
+
                reviewId: undefined,
+
                rid: repo.rid,
+
                status,
+
              });
+
            }
+
          }}
+
          onDismiss={() => {
+
            searchInput = "";
+
          }}
+
          placeholder={`Fuzzy filter issues ${modifierKey()} + f`}
+
          keyShortcuts="ctrl+f"
+
          bind:value={searchInput}>
+
          {#snippet left()}
+
            <div
+
              style:color="var(--color-foreground-dim)"
+
              style:padding-left="0.5rem">
+
              <Icon name={loading ? "clock" : "filter"} />
+
            </div>
+
          {/snippet}
+
        </TextInput>
+
      </div>
+
    </div>

    <div class="list">
-
      {#each items as patch}
-
        <PatchTeaser rid={repo.rid} {patch} {status} />
+
      {#each searchResults as result}
+
        <PatchTeaser
+
          focussed={searchResults.length === 1 && searchInput !== ""}
+
          patch={result.obj.patch}
+
          rid={repo.rid}
+
          {status} />
      {/each}

      {#if patches.content.length === 0}
modified src/views/repo/router.ts
@@ -15,6 +15,8 @@ import { unreachable } from "@app/lib/utils";

export type IssueStatus = "all" | Issue["state"]["status"];

+
export const DEFAULT_TAKE = 20;
+

export interface RepoIssueRoute {
  resource: "repo.issue";
  rid: string;
@@ -132,6 +134,7 @@ export async function loadPatch(
      invoke<PaginatedQuery<Patch[]>>("list_patches", {
        rid: route.rid,
        status: route.status,
+
        take: DEFAULT_TAKE,
      }),
      invoke<Patch>("patch_by_id", {
        rid: route.rid,
@@ -178,6 +181,7 @@ export async function loadPatches(
    invoke<PaginatedQuery<Patch[]>>("list_patches", {
      rid: route.rid,
      status: route.status,
+
      take: DEFAULT_TAKE,
    }),
  ]);