Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Implement issue and patch list fuzzy finder
Rūdolfs Ošiņš committed 1 year ago
commit 83390aed59fc4b3d8090bb29773a0d79ec76b2b8
parent d01cf9b
8 files changed +324 -95
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/test-http-api/src/api.rs
@@ -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 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
@@ -18,9 +18,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 +32,7 @@

  const {
    compact = false,
+
    focussed,
    loadPatch,
    patch,
    rid,
@@ -72,26 +75,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,7 +100,7 @@
    </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}
        <DiffStatBadge {stats} />
@@ -134,4 +118,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
@@ -7,36 +7,38 @@
  import Border from "./Border.svelte";

  interface Props {
-
    name?: string;
-
    placeholder?: string;
-
    value?: string;
-
    type?: string;
    autofocus?: boolean;
    autoselect?: boolean;
    disabled?: boolean;
-
    onSubmit?: () => void;
-
    onDismiss?: () => void;
-
    valid?: boolean;
-
    oninput?: FormEventHandler<HTMLInputElement>;
    keyShortcuts?: string;
    left?: Snippet;
+
    name?: string;
+
    onDismiss?: () => void;
+
    onFocus?: () => void;
+
    onSubmit?: () => void;
+
    oninput?: FormEventHandler<HTMLInputElement>;
+
    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,
-
    onDismiss,
-
    valid = true,
-
    oninput,
    keyShortcuts,
    left,
+
    name,
+
    onDismiss,
+
    onFocus,
+
    onSubmit,
+
    oninput,
+
    placeholder,
+
    type = "text",
+
    valid = true,
+
    value = $bindable(undefined),
  }: Props = $props();
  /* eslint-enable prefer-const */

@@ -102,6 +104,9 @@
    style:padding={left ? "0.25rem 0.75rem 0.25rem 0" : "0.25rem 0.75rem"}
    aria-keyshortcuts={keyShortcuts}
    onfocus={() => {
+
      if (onFocus) {
+
        onFocus();
+
      }
      focussed = true;
    }}
    onblur={() => {
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,
    }),
  ]);