Implement fuzzy filter in single patch/issue views

This commit is contained in:
Rūdolfs Ošiņš
2025-04-02 10:32:18 +02:00
parent 054174f606
commit b770fac336
7 changed files with 369 additions and 195 deletions
+102 -24
View File
@@ -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>
+51 -53
View File
@@ -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,58 +71,6 @@
</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)}
</div>
</div>
</div>
<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}
</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"
@@ -129,9 +78,58 @@
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 });
}}>
{@render 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)}
</div>
</div>
</div>
<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}
</div>
</div>
{/snippet}
{#if focussed}
<Border
styleBackgroundColor="var(--color-background-float)"
variant="secondary">
{@render issueSnippet()}
</Border>
{:else}
{@render issueSnippet()}
{/if}
+3
View File
@@ -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}
+54 -56
View File
@@ -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}
+4 -4
View File
@@ -149,7 +149,7 @@
{status} />
{/each}
{#if issues.length === 0}
{#if searchResults.length === 0}
<Border
variant="ghost"
styleAlignItems="center"
@@ -160,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>
+149 -53
View File
@@ -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>
+6 -5
View File
@@ -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";
@@ -181,7 +182,7 @@
{status} />
{/each}
{#if patches.content.length === 0}
{#if searchResults.length === 0}
<Border
variant="ghost"
styleAlignItems="center"
@@ -192,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>