Implement issue and patch list fuzzy finder

This commit is contained in:
Rūdolfs Ošiņš
2025-04-01 17:16:03 +02:00
parent d01cf9b433
commit 83390aed59
8 changed files with 325 additions and 96 deletions
+31 -13
View File
@@ -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,
more,
content: patches,
})
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,
})
}
}
}
#[tauri::command]
+21 -2
View File
@@ -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
+35 -17
View File
@@ -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}
+41 -22
View File
@@ -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}
+21 -16
View File
@@ -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={() => {
+78 -16
View File
@@ -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}
+94 -10
View File
@@ -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 { invoke } from "@app/lib/invoke";
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}
+4
View File
@@ -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,
}),
]);