Share the list search

Issues, patches, commits and the peer selector each had their own
debounce, and issues and patches the same search index. Vitest now
resolves Svelte's browser build, as effects never run in the server one.
This commit is contained in:
Rūdolfs Ošiņš
2026-10-01 18:20:25 +02:00
parent 2c81138871
commit 7a42679bdf
9 changed files with 158 additions and 94 deletions
@@ -5,6 +5,7 @@
import fuzzysort from "fuzzysort";
import { debounced } from "@app/lib/debounced.svelte";
import { selectedRefType, sortedTags, sortRemotes } from "@app/lib/refs";
import * as router from "@app/lib/router";
import { formatOid, truncateId } from "@app/lib/utils";
@@ -92,15 +93,7 @@
);
let searchInput = $state("");
let debouncedSearch = $state("");
$effect(() => {
const value = searchInput;
const timer = setTimeout(() => {
debouncedSearch = value;
}, 100);
return () => clearTimeout(timer);
});
const debouncedSearch = debounced(() => searchInput, 100);
type SearchPeer = { id: string; alias?: string; delegate: boolean };
type SearchElement = {
@@ -147,7 +140,7 @@
);
const searchResults = $derived(
fuzzysort.go(debouncedSearch, searchElements, {
fuzzysort.go(debouncedSearch.current, searchElements, {
keys: ["peer.alias", "revision"],
threshold: 0.5,
}),
@@ -461,7 +454,7 @@
</div>
<ScrollArea style="max-height: calc(60vh - 3rem);">
{#if debouncedSearch !== ""}
{#if debouncedSearch.current !== ""}
{#if searchResults.total > 0}
{#each searchResults as result (result.obj)}
{@const el = result.obj}
@@ -552,7 +545,7 @@
{/each}
{/if}
{#if debouncedSearch === "" && peersForActiveTab.length > 0}
{#if debouncedSearch.current === "" && peersForActiveTab.length > 0}
{#if activeTab === "tags" && canonicalTagsSorted.length === 0}
<div class="section-header">
<span></span>
+32
View File
@@ -0,0 +1,32 @@
import type { Author } from "@bindings/cob/Author";
import fuzzysort from "fuzzysort";
interface Searchable {
id: string;
title: string;
labels: string[];
assignees: Author[];
author: Author;
}
export function searchCobs<T extends Searchable>(
query: string,
cobs: T[],
): T[] {
const index = cobs.map(cob => ({
cob,
id: cob.id,
title: cob.title,
labels: cob.labels.join(" "),
assignees: cob.assignees.map(a => a.alias ?? "").join(" "),
author: cob.author.alias ?? "",
}));
return fuzzysort
.go(query, index, {
keys: ["title", "labels", "assignees", "author", "id"],
threshold: 0.5,
all: true,
})
.map(result => result.obj.cob);
}
+18
View File
@@ -0,0 +1,18 @@
export function debounced<T>(
get: () => T,
ms: number,
): { readonly current: T } {
let current = $state(get());
$effect(() => {
const value = get();
const timer = setTimeout(() => {
current = value;
}, ms);
return () => clearTimeout(timer);
});
return {
get current() {
return current;
},
};
}
+8 -36
View File
@@ -6,9 +6,10 @@
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import { DEFAULT_TAKE } from "@app/views/repo/router";
import fuzzysort from "fuzzysort";
import delay from "lodash/delay";
import { searchCobs } from "@app/lib/cobSearch";
import { debounced } from "@app/lib/debounced.svelte";
import { channel, invoke } from "@app/lib/invoke";
import {
issueCountMismatch,
@@ -46,7 +47,7 @@
let loading = $state(false);
let searchInput = $state("");
let debouncedSearch = $state("");
const debouncedSearch = debounced(() => searchInput, 150);
let showSearch = $state(false);
let cacheState: CacheEvent | undefined = $state();
// Height of the cache-warning banner above the list; fed to the virtualizer
@@ -94,14 +95,6 @@
showSearch = false;
});
$effect(() => {
const value = searchInput;
const timer = setTimeout(() => {
debouncedSearch = value;
}, 150);
return () => clearTimeout(timer);
});
async function rebuildIssueCache() {
try {
await invoke("rebuild_issue_cache", {
@@ -122,29 +115,8 @@
}
}
const searchableIssues = $derived(
list.items
.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(debouncedSearch, searchableIssues, {
keys: ["issue.title", "labels", "assignees", "author", "issue.id"],
threshold: 0.5,
all: true,
}),
searchCobs(debouncedSearch.current, list.items),
);
</script>
@@ -252,7 +224,7 @@
await router.push({
resource: "repo.issue",
rid: repo.rid,
issue: searchResults[0].obj.issue.id,
issue: searchResults[0].id,
status,
});
}
@@ -311,16 +283,16 @@
onLoadMore={() => list.loadMore()}
startMargin={chromeHeight}
estimatedItemSize={80}
getKey={result => result.obj.issue.id}
getKey={issue => issue.id}
initialCache={list.initialCache}
initialScrollOffset={list.initialScrollOffset}
onRestored={list.consumeRestoredScroll}
onState={list.persistScroll}>
{#snippet row(result)}
{#snippet row(issue)}
<div class="row">
<IssueTeaser
focussed={searchResults.length === 1 && searchInput !== ""}
issue={result.obj.issue}
{issue}
rid={repo.rid}
{status} />
</div>
+8 -36
View File
@@ -6,9 +6,10 @@
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import { DEFAULT_TAKE } from "@app/views/repo/router";
import fuzzysort from "fuzzysort";
import delay from "lodash/delay";
import { searchCobs } from "@app/lib/cobSearch";
import { debounced } from "@app/lib/debounced.svelte";
import { channel, invoke } from "@app/lib/invoke";
import { createPaginatedList } from "@app/lib/paginatedList.svelte";
import {
@@ -44,7 +45,7 @@
let loading = $state(false);
let searchInput = $state("");
let debouncedSearch = $state("");
const debouncedSearch = debounced(() => searchInput, 150);
let showSearch = $state(false);
let cacheState: CacheEvent | undefined = $state();
// Height of the cache-warning banner above the list; fed to the virtualizer
@@ -85,14 +86,6 @@
showSearch = false;
});
$effect(() => {
const value = searchInput;
const timer = setTimeout(() => {
debouncedSearch = value;
}, 150);
return () => clearTimeout(timer);
});
async function rebuildPatchCache() {
try {
await invoke("rebuild_patch_cache", {
@@ -113,29 +106,8 @@
}
}
const searchablePatches = $derived(
list.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(debouncedSearch, searchablePatches, {
keys: ["patch.title", "labels", "assignees", "author", "patch.id"],
threshold: 0.5,
all: true,
}),
searchCobs(debouncedSearch.current, list.items),
);
</script>
@@ -286,7 +258,7 @@
onSubmit={async () => {
if (searchResults.length === 1) {
await router.push({
patch: searchResults[0].obj.patch.id,
patch: searchResults[0].id,
resource: "repo.patch",
reviewId: undefined,
rid: repo.rid,
@@ -339,16 +311,16 @@
onLoadMore={() => list.loadMore()}
startMargin={chromeHeight}
estimatedItemSize={80}
getKey={result => result.obj.patch.id}
getKey={patch => patch.id}
initialCache={list.initialCache}
initialScrollOffset={list.initialScrollOffset}
onRestored={list.consumeRestoredScroll}
onState={list.persistScroll}>
{#snippet row(result)}
{#snippet row(patch)}
<div class="row">
<PatchTeaser
focussed={searchResults.length === 1 && searchInput !== ""}
patch={result.obj.patch}
{patch}
rid={repo.rid}
{status} />
</div>
+3 -10
View File
@@ -7,6 +7,7 @@
import fuzzysort from "fuzzysort";
import { commitRowsOf, groupCommitsByDay } from "@app/lib/commitGroups";
import { debounced } from "@app/lib/debounced.svelte";
import { invoke } from "@app/lib/invoke";
import { createPaginatedList } from "@app/lib/paginatedList.svelte";
import * as router from "@app/lib/router";
@@ -48,7 +49,7 @@
let loading = $state(false);
let searchInput = $state("");
let debouncedSearch = $state("");
const debouncedSearch = debounced(() => searchInput, 150);
let showSearch = $state(false);
const list = createPaginatedList<Commit>({
@@ -66,14 +67,6 @@
skipPersist: () => searchInput !== "",
});
$effect(() => {
const value = searchInput;
const timer = setTimeout(() => {
debouncedSearch = value;
}, 150);
return () => clearTimeout(timer);
});
const searchableCommits = $derived(
list.items.map(c => ({
commit: c,
@@ -84,7 +77,7 @@
);
const searchResults = $derived(
fuzzysort.go(debouncedSearch, searchableCommits, {
fuzzysort.go(debouncedSearch.current, searchableCommits, {
keys: ["summary", "id", "author"],
threshold: 0.5,
all: true,
+47
View File
@@ -0,0 +1,47 @@
import { expect, test } from "vitest";
import { searchCobs } from "@app/lib/cobSearch";
import { author } from "./support/cobs";
function cob(
id: string,
props: Partial<Parameters<typeof searchCobs>[1][0]> = {},
) {
return {
id,
title: "Untitled",
labels: [],
assignees: [],
author: author("nobody", null),
...props,
};
}
const cobs = [
cob("1", { title: "Crash on startup" }),
cob("2", { labels: ["regression", "ui"] }),
cob("3", { assignees: [author("a", null), author("b", "bob")] }),
cob("4", { author: author("c", "carol") }),
cob("deadbeef"),
];
test("lists everything for an empty query", () => {
expect(searchCobs("", cobs).map(c => c.id)).toEqual([
"1",
"2",
"3",
"4",
"deadbeef",
]);
});
test.each([
["startup", "1"],
["regression", "2"],
["bob", "3"],
["carol", "4"],
["deadbeef", "deadbeef"],
])("finds %j in cob %j", (query, id) => {
expect(searchCobs(query, cobs).map(c => c.id)).toEqual([id]);
});
+35
View File
@@ -0,0 +1,35 @@
import { flushSync } from "svelte";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { debounced } from "@app/lib/debounced.svelte";
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test("follows the source once it stops changing", () => {
let source = $state("a");
let search: { readonly current: string } | undefined;
const cleanup = $effect.root(() => {
search = debounced(() => source, 150);
});
flushSync();
expect(search?.current).toBe("a");
source = "ab";
flushSync();
vi.advanceTimersByTime(100);
source = "abc";
flushSync();
vi.advanceTimersByTime(149);
expect(search?.current).toBe("a");
vi.advanceTimersByTime(1);
expect(search?.current).toBe("abc");
cleanup();
});
+2
View File
@@ -33,6 +33,8 @@ export default defineConfig({
},
},
resolve: {
// Svelte's server build never runs effects, which rune tests need.
conditions: process.env.VITEST ? ["browser"] : undefined,
alias: {
"@app": path.resolve("./src"),
"@bindings": path.resolve("./crates/radicle-types/bindings/"),