Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Implement fuzzy filter in single patch/issue views
Merged rudolfs opened 1 year ago
8 files changed +464 -285 12afb43e b770fac3
modified src/components/IssueSecondColumn.svelte
@@ -4,9 +4,10 @@
  import type { IssueStatus } from "@app/views/repo/router";

  import capitalize from "lodash/capitalize";
+
  import fuzzysort from "fuzzysort";

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

  import Border from "./Border.svelte";
  import DropdownList from "./DropdownList.svelte";
@@ -14,8 +15,10 @@
  import Icon from "./Icon.svelte";
  import IssueTeaser from "@app/components/IssueTeaser.svelte";
  import Link from "./Link.svelte";
+
  import NakedButton from "./NakedButton.svelte";
  import OutlineButton from "./OutlineButton.svelte";
  import Popover, { closeFocused } from "./Popover.svelte";
+
  import TextInput from "./TextInput.svelte";

  const activeRouteStore = router.activeRouteStore;

@@ -34,12 +37,38 @@
  /* eslint-enable prefer-const */

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

+
  let showFilters: boolean = $state(false);
+
  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>
  .container {
    display: flex;
-
    justify-content: space-between;
    align-items: center;
    min-height: 40px;
  }
@@ -88,7 +117,40 @@
    </Link>
  </div>

-
  <div class="global-flex" style:margin-left="1rem">
+
  <div class="global-flex" style:margin-left="auto">
+
    <NakedButton
+
      keyShortcuts="ctrl+f"
+
      variant="ghost"
+
      stylePadding="0 4px"
+
      active={showFilters}
+
      onclick={() => {
+
        if (showFilters) {
+
          showFilters = false;
+
          searchInput = "";
+
        } else {
+
          showFilters = true;
+
        }
+
      }}>
+
      <Icon name="filter" />
+
    </NakedButton>
+

+
    <OutlineButton
+
      variant="ghost"
+
      disabled={$activeRouteStore.resource === "repo.createIssue"}
+
      onclick={() => {
+
        void router.push({
+
          resource: "repo.createIssue",
+
          rid: repo.rid,
+
          status,
+
        });
+
      }}>
+
      <Icon name="plus" />New
+
    </OutlineButton>
+
  </div>
+
</div>
+

+
{#if showFilters}
+
  <div class="global-flex" style:margin="1rem 0">
    <Popover popoverPositionRight="0" popoverPositionTop="2.5rem">
      {#snippet toggle(onclick)}
        <OutlineButton variant="ghost" {onclick}>
@@ -119,32 +181,48 @@
        </Border>
      {/snippet}
    </Popover>
-

-
    <OutlineButton
-
      variant="ghost"
-
      disabled={$activeRouteStore.resource === "repo.createIssue"}
-
      onclick={() => {
-
        void router.push({
-
          resource: "repo.createIssue",
-
          rid: repo.rid,
-
          status,
-
        });
-
      }}>
-
      <Icon name="plus" />New
-
    </OutlineButton>
+
    <TextInput
+
      onSubmit={async () => {
+
        if (searchResults.length === 1) {
+
          await router.push({
+
            resource: "repo.issue",
+
            rid: repo.rid,
+
            issue: searchResults[0].obj.issue.id,
+
            status,
+
          });
+
        }
+
      }}
+
      onDismiss={() => {
+
        showFilters = false;
+
        searchInput = "";
+
      }}
+
      placeholder={`Fuzzy filter issues ${modifierKey()} + f`}
+
      autofocus
+
      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>
-
</div>
+
{/if}
+

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

-
  {#if issues.length === 0}
+
  {#if searchResults.length === 0}
    <Border
      styleMinWidth="25rem"
      variant="ghost"
@@ -156,10 +234,10 @@
        style:justify-content="center">
        <div class="txt-missing txt-small global-flex" style:gap="0.25rem">
          <Icon name="none" />
-
          {#if status === "all"}
-
            No issues.
+
          {#if issues.length > 0 && searchResults.length === 0}
+
            No matching issues.
          {:else}
-
            No {status} issues.
+
            No {status === "all" ? "" : status} issues.
          {/if}
        </div>
      </div>
modified src/components/IssueTeaser.svelte
@@ -47,6 +47,7 @@
    cursor: pointer;
    font-size: var(--font-size-regular);
    word-break: break-word;
+
    width: 100%;
  }
  .selected {
    background-color: var(--color-fill-float-hover);
@@ -70,68 +71,65 @@
</style>

{#snippet issueSnippet()}
-
  <div class="global-flex" style:align-items="flex-start">
-
    <div
-
      class="global-counter status"
-
      style:color={issueStatusColor[issue.state.status]}
-
      style:background-color={issueStatusBackgroundColor[issue.state.status]}>
-
      {#if issue.state.status === "open"}
-
        <Icon name="issue" />
-
      {:else}
-
        <Icon name="issue-closed" />
-
      {/if}
-
    </div>
-
    <div
-
      class="global-flex"
-
      style:flex-direction="column"
-
      style:align-items="flex-start">
-
      <InlineTitle content={issue.title} />
-
      <div class="global-flex txt-small" style:flex-wrap="wrap">
-
        <NodeId {...authorForNodeId(issue.author)} />
-
        opened
-
        <Id id={issue.id} variant="oid" />
-
        {formatTimestamp(issue.timestamp)}
+
  <!-- svelte-ignore a11y_click_events_have_key_events -->
+
  <div
+
    tabindex="0"
+
    role="button"
+
    class="issue-teaser"
+
    class:selected
+
    style:align-items="flex-start"
+
    style:clip-path={focussed ? "none" : undefined}
+
    onclick={() => {
+
      void push({ resource: "repo.issue", rid, issue: issue.id, status });
+
    }}>
+
    <div class="global-flex" style:align-items="flex-start">
+
      <div
+
        class="global-counter status"
+
        style:color={issueStatusColor[issue.state.status]}
+
        style:background-color={issueStatusBackgroundColor[issue.state.status]}>
+
        {#if issue.state.status === "open"}
+
          <Icon name="issue" />
+
        {:else}
+
          <Icon name="issue-closed" />
+
        {/if}
+
      </div>
+
      <div
+
        class="global-flex"
+
        style:flex-direction="column"
+
        style:align-items="flex-start">
+
        <InlineTitle content={issue.title} />
+
        <div class="global-flex txt-small" style:flex-wrap="wrap">
+
          <NodeId {...authorForNodeId(issue.author)} />
+
          opened
+
          <Id id={issue.id} variant="oid" />
+
          {formatTimestamp(issue.timestamp)}
+
        </div>
      </div>
    </div>
-
  </div>

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

-
    {#if issue.commentCount > 0}
-
      <div class="txt-small global-flex" style:gap="0.25rem">
-
        <Icon name="comment" />
-
        {issue.commentCount}
-
      </div>
-
    {/if}
+
      {#if issue.commentCount > 0}
+
        <div class="txt-small global-flex" style:gap="0.25rem">
+
          <Icon name="comment" />
+
          {issue.commentCount}
+
        </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>
+
  {@render issueSnippet()}
{/if}
modified src/components/NakedButton.svelte
@@ -10,6 +10,7 @@
    styleHeight?: string;
    stylePadding?: string;
    active?: boolean;
+
    keyShortcuts?: string;
  }

  const {
@@ -21,6 +22,7 @@
    styleHeight = "2rem",
    stylePadding = "0 8px",
    active = false,
+
    keyShortcuts,
  }: Props = $props();

  const style = $derived(
@@ -239,6 +241,7 @@
  class:active
  onclick={!disabled ? onclick : undefined}
  {title}
+
  aria-keyshortcuts={keyShortcuts}
  role="button"
  tabindex="0"
  {style}
modified src/components/PatchTeaser.svelte
@@ -52,6 +52,7 @@
    cursor: pointer;
    font-size: var(--font-size-regular);
    word-break: break-word;
+
    width: 100%;
  }
  .selected {
    background-color: var(--color-fill-float-hover);
@@ -75,61 +76,6 @@
</style>

{#snippet patchSnippet()}
-
  <div class="global-flex" style:align-items="flex-start">
-
    <div
-
      class="global-counter status"
-
      style:color={patchStatusColor[patch.state.status]}
-
      style:background-color={patchStatusBackgroundColor[patch.state.status]}>
-
      <Icon
-
        name={patch.state.status === "open"
-
          ? "patch"
-
          : `patch-${patch.state.status}`} />
-
    </div>
-
    <div
-
      class="global-flex"
-
      style:flex-direction="column"
-
      style:align-items="flex-start">
-
      <InlineTitle content={patch.title} />
-
      <div class="global-flex txt-small" style:flex-wrap="wrap">
-
        <NodeId {...authorForNodeId(patch.author)} />
-
        opened
-
        <Id id={patch.id} variant="oid" />
-
        {formatTimestamp(patch.timestamp)}
-
      </div>
-
    </div>
-
  </div>
-

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

-
      {#each patch.labels as label}
-
        <Label {label} />
-
      {/each}
-
    {/if}
-
    <div
-
      class="txt-small global-flex"
-
      style:gap="0.25rem"
-
      style:white-space="nowrap">
-
      <Icon name="revision" />
-
      {patch.revisionCount}
-
    </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"
@@ -137,6 +83,7 @@
    class:selected
    class="patch-teaser"
    style:align-items="flex-start"
+
    style:clip-path={focussed ? "none" : undefined}
    onclick={async () => {
      if (loadPatch) {
        await loadPatch(patch.id);
@@ -150,6 +97,57 @@
        });
      }
    }}>
-
    {@render patchSnippet()}
+
    <div class="global-flex" style:align-items="flex-start">
+
      <div
+
        class="global-counter status"
+
        style:color={patchStatusColor[patch.state.status]}
+
        style:background-color={patchStatusBackgroundColor[patch.state.status]}>
+
        <Icon
+
          name={patch.state.status === "open"
+
            ? "patch"
+
            : `patch-${patch.state.status}`} />
+
      </div>
+
      <div
+
        class="global-flex"
+
        style:flex-direction="column"
+
        style:align-items="flex-start">
+
        <InlineTitle content={patch.title} />
+
        <div class="global-flex txt-small" style:flex-wrap="wrap">
+
          <NodeId {...authorForNodeId(patch.author)} />
+
          opened
+
          <Id id={patch.id} variant="oid" />
+
          {formatTimestamp(patch.timestamp)}
+
        </div>
+
      </div>
+
    </div>
+

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

+
        {#each patch.labels as label}
+
          <Label {label} />
+
        {/each}
+
      {/if}
+
      <div
+
        class="txt-small global-flex"
+
        style:gap="0.25rem"
+
        style:white-space="nowrap">
+
        <Icon name="revision" />
+
        {patch.revisionCount}
+
      </div>
+
    </div>
  </div>
+
{/snippet}
+

+
{#if focussed}
+
  <Border
+
    styleBackgroundColor="var(--color-background-float)"
+
    variant="secondary">
+
    {@render patchSnippet()}
+
  </Border>
+
{:else}
+
  {@render patchSnippet()}
{/if}
modified src/views/home/Repos.svelte
@@ -122,32 +122,34 @@
  <div class="container">
    <div class="global-flex" style:margin-bottom="0.5rem">
      <div class="header">Repositories</div>
-
      <div class="global-flex" style:margin-left="auto">
-
        <TextInput
-
          onSubmit={async () => {
-
            if (searchResults.length === 1) {
-
              await router.push({
-
                resource: "repo.issues",
-
                rid: searchResults[0].obj.repo.rid,
-
                status: "open",
-
              });
-
            }
-
          }}
-
          onDismiss={() => {
-
            searchInput = "";
-
          }}
-
          placeholder={`Fuzzy filter repositories ${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>
+
      {#if repos.length > 0}
+
        <div class="global-flex" style:margin-left="auto">
+
          <TextInput
+
            onSubmit={async () => {
+
              if (searchResults.length === 1) {
+
                await router.push({
+
                  resource: "repo.issues",
+
                  rid: searchResults[0].obj.repo.rid,
+
                  status: "open",
+
                });
+
              }
+
            }}
+
            onDismiss={() => {
+
              searchInput = "";
+
            }}
+
            placeholder={`Fuzzy filter repositories ${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>
+
      {/if}
    </div>
    {#if repos.length > 0}
      {#if searchResults.length > 0}
@@ -173,7 +175,7 @@
          styleJustifyContent="center">
          <div
            class="global-flex"
-
            style:height="74px"
+
            style:height="126px"
            style:justify-content="center">
            <div class="txt-missing txt-small global-flex" style:gap="0.25rem">
              <Icon name="none" />
modified src/views/repo/Issues.svelte
@@ -97,31 +97,33 @@
    <div class="header">
      <div>Issues</div>
      <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>
+
        {#if issues.length > 0}
+
          <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>
+
        {/if}
        <div class="txt-regular txt-semibold">
          <Button
            variant="secondary"
@@ -147,7 +149,7 @@
          {status} />
      {/each}

-
      {#if issues.length === 0}
+
      {#if searchResults.length === 0}
        <Border
          variant="ghost"
          styleAlignItems="center"
@@ -158,10 +160,10 @@
            style:justify-content="center">
            <div class="txt-missing txt-small global-flex" style:gap="0.25rem">
              <Icon name="none" />
-
              {#if status === "all"}
-
                No issues.
+
              {#if issues.length > 0 && searchResults.length === 0}
+
                No matching issues.
              {:else}
-
                No {status} issues.
+
                No {status === "all" ? "" : status} issues.
              {/if}
            </div>
          </div>
modified src/views/repo/Patch.svelte
@@ -11,8 +11,11 @@
  import type { Revision } from "@bindings/cob/patch/Revision";

  import capitalize from "lodash/capitalize";
+
  import fuzzysort from "fuzzysort";

  import * as roles from "@app/lib/roles";
+
  import * as router from "@app/lib/router";
+
  import { DEFAULT_TAKE } from "./router";
  import { announce } from "@app/components/AnnounceSwitch.svelte";
  import {
    formatOid,
@@ -20,6 +23,7 @@
    patchStatusColor,
  } from "@app/lib/utils";
  import { invoke } from "@app/lib/invoke";
+
  import { modifierKey } from "@app/lib/utils";
  import { nodeRunning } from "@app/lib/events";

  import AssigneeInput from "@app/components/AssigneeInput.svelte";
@@ -32,6 +36,7 @@
  import LabelInput from "@app/components/LabelInput.svelte";
  import Layout from "./Layout.svelte";
  import Link from "@app/components/Link.svelte";
+
  import NakedButton from "@app/components/NakedButton.svelte";
  import OutlineButton from "@app/components/OutlineButton.svelte";
  import PatchStateBadge from "@app/components/PatchStateBadge.svelte";
  import PatchStateButton from "@app/components/PatchStateButton.svelte";
@@ -190,18 +195,22 @@
    }
  }

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

      cursor = p.cursor;
      more = p.more;
-
      patchTeasers = [...patchTeasers, ...p.content];
+
      if (all) {
+
        patchTeasers = p.content;
+
      } else {
+
        patchTeasers = [...patchTeasers, ...p.content];
+
      }
    }
  }

@@ -222,6 +231,7 @@
      invoke<PaginatedQuery<Patch[]>>("list_patches", {
        rid: repo.rid,
        status,
+
        take: DEFAULT_TAKE,
      }),
    ]);
  }
@@ -244,6 +254,7 @@
      patches = await invoke<PaginatedQuery<Patch[]>>("list_patches", {
        rid: repo.rid,
        status: filter,
+
        take: DEFAULT_TAKE,
      });
      status = filter;
    } catch (error) {
@@ -260,6 +271,34 @@
      });
    }) as Revision;
  }
+

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

+
  const searchablePatches = $derived(
+
    patches.content
+
      .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>
@@ -350,10 +389,7 @@
  {/snippet}

  {#snippet secondColumn()}
-
    <div
-
      class="txt-regular txt-semibold global-flex"
-
      style:min-height="40px"
-
      style:justify-content="space-between">
+
    <div class="txt-regular txt-semibold global-flex" style:min-height="40px">
      <div class="global-flex" style:gap="4px">
        {project.data.name}
        <Icon name="chevron-right" />
@@ -367,60 +403,120 @@
          Patches
        </Link>
      </div>
-

-
      <Popover popoverPositionRight="0" popoverPositionTop="2.5rem">
-
        {#snippet toggle(onclick)}
-
          <OutlineButton variant="ghost" {onclick}>
-
            {@render icons(status)}
-
            {status ? capitalize(status) : "All"}
-
            {@render counters(status)}
-
            <Icon name="chevron-down" />
-
          </OutlineButton>
-
        {/snippet}
-

-
        {#snippet popover()}
-
          <Border variant="ghost">
-
            <DropdownList
-
              items={[
-
                undefined,
-
                "draft",
-
                "open",
-
                "archived",
-
                "merged",
-
              ] as const}>
-
              {#snippet item(state)}
-
                <DropdownListItem
-
                  style="gap: 0.5rem"
-
                  selected={status === state}
-
                  onclick={async () => {
-
                    await loadPatches(state);
-
                    closeFocused();
-
                  }}>
-
                  {@render icons(state)}
-
                  {state ? capitalize(state) : "All"}
-
                  {@render counters(state)}
-
                </DropdownListItem>
-
              {/snippet}
-
            </DropdownList>
-
          </Border>
-
        {/snippet}
-
      </Popover>
+
      <div style:margin-left="auto">
+
        <NakedButton
+
          keyShortcuts="ctrl+f"
+
          variant="ghost"
+
          stylePadding="0 4px"
+
          active={showFilters}
+
          onclick={() => {
+
            if (showFilters) {
+
              showFilters = false;
+
              searchInput = "";
+
            } else {
+
              showFilters = true;
+
            }
+
          }}>
+
          <Icon name="filter" />
+
        </NakedButton>
+
      </div>
    </div>
+
    {#if showFilters}
+
      <div class="global-flex" style:margin="1rem 0">
+
        <Popover popoverPositionLeft="0" popoverPositionTop="2.5rem">
+
          {#snippet toggle(onclick)}
+
            <OutlineButton variant="ghost" {onclick}>
+
              {@render icons(status)}
+
              {status ? capitalize(status) : "All"}
+
              {@render counters(status)}
+
              <Icon name="chevron-down" />
+
            </OutlineButton>
+
          {/snippet}
+

+
          {#snippet popover()}
+
            <Border variant="ghost">
+
              <DropdownList
+
                items={[
+
                  undefined,
+
                  "draft",
+
                  "open",
+
                  "archived",
+
                  "merged",
+
                ] as const}>
+
                {#snippet item(state)}
+
                  <DropdownListItem
+
                    style="gap: 0.5rem"
+
                    selected={status === state}
+
                    onclick={async () => {
+
                      await loadPatches(state);
+
                      closeFocused();
+
                    }}>
+
                    {@render icons(state)}
+
                    {state ? capitalize(state) : "All"}
+
                    {@render counters(state)}
+
                  </DropdownListItem>
+
                {/snippet}
+
              </DropdownList>
+
            </Border>
+
          {/snippet}
+
        </Popover>
+
        {#if patchTeasers.length > 0}
+
          <TextInput
+
            onFocus={async () => {
+
              try {
+
                loading = true;
+
                await loadMoreTeasers(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={() => {
+
              showFilters = false;
+
              searchInput = "";
+
            }}
+
            placeholder={`Fuzzy filter patches ${modifierKey()} + f`}
+
            autofocus
+
            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>
+
        {/if}
+
      </div>
+
    {/if}
    <div class="patch-list">
-
      {#each patchTeasers as teaser}
+
      {#each searchResults as teaser}
        <PatchTeaser
+
          focussed={searchResults.length === 1 && searchInput !== ""}
          compact
          loadPatch={async (id: string) => {
            review = undefined;
            await loadPatch(id);
          }}
-
          patch={teaser}
+
          patch={teaser.obj.patch}
          rid={repo.rid}
          {status}
-
          selected={patch && teaser.id === patch.id} />
+
          selected={teaser.obj.patch.id === patch.id} />
      {/each}

-
      {#if patches.content.length === 0}
+
      {#if searchResults.length === 0}
        <Border
          styleMinWidth="25rem"
          variant="ghost"
@@ -432,10 +528,10 @@
            style:justify-content="center">
            <div class="txt-missing txt-small global-flex" style:gap="0.25rem">
              <Icon name="none" />
-
              {#if status === undefined}
-
                No patches.
+
              {#if patchTeasers.length > 0 && searchResults.length === 0}
+
                No matching patches.
              {:else}
-
                No {status} patches.
+
                No {status === undefined ? "" : status} patches.
              {/if}
            </div>
          </div>
modified src/views/repo/Patches.svelte
@@ -2,12 +2,13 @@
  import type { Config } from "@bindings/config/Config";
  import type { PaginatedQuery } from "@bindings/cob/PaginatedQuery";
  import type { Patch } from "@bindings/cob/patch/Patch";
-
  import { DEFAULT_TAKE, type PatchStatus } from "./router";
+
  import type { PatchStatus } from "./router";
  import type { RepoInfo } from "@bindings/repo/RepoInfo";

  import fuzzysort from "fuzzysort";

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

@@ -29,8 +30,6 @@

  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;
@@ -63,6 +62,7 @@

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

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

  const searchablePatches = $derived(
@@ -130,45 +130,47 @@
    <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>
+
      {#if items.length > 0}
+
        <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 patches ${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>
+
      {/if}
    </div>

    <div class="list">
@@ -180,7 +182,7 @@
          {status} />
      {/each}

-
      {#if patches.content.length === 0}
+
      {#if searchResults.length === 0}
        <Border
          variant="ghost"
          styleAlignItems="center"
@@ -191,10 +193,10 @@
            style:justify-content="center">
            <div class="txt-missing txt-small global-flex" style:gap="0.25rem">
              <Icon name="none" />
-
              {#if status === undefined}
-
                No patches.
+
              {#if items.length > 0 && searchResults.length === 0}
+
                No matching patches.
              {:else}
-
                No {status} patches.
+
                No {status === undefined ? "" : status} patches.
              {/if}
            </div>
          </div>