From 7a42679bdf09be10b34d3ec5e87efc7fc45db3d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C5=ABdolfs=20O=C5=A1i=C5=86=C5=A1?= Date: Wed, 30 Sep 2026 16:39:01 +0200 Subject: [PATCH] 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. --- .../PeerSelector/PeerSelector.svelte | 17 ++----- src/lib/cobSearch.ts | 32 +++++++++++++ src/lib/debounced.svelte.ts | 18 +++++++ src/views/repo/Issues.svelte | 44 ++++------------- src/views/repo/Patches.svelte | 44 ++++------------- src/views/repo/RepoCommits.svelte | 13 ++--- tests/unit/cobSearch.test.ts | 47 +++++++++++++++++++ tests/unit/debounced.svelte.test.ts | 35 ++++++++++++++ vite.config.ts | 2 + 9 files changed, 158 insertions(+), 94 deletions(-) create mode 100644 src/lib/cobSearch.ts create mode 100644 src/lib/debounced.svelte.ts create mode 100644 tests/unit/cobSearch.test.ts create mode 100644 tests/unit/debounced.svelte.test.ts diff --git a/src/components/PeerSelector/PeerSelector.svelte b/src/components/PeerSelector/PeerSelector.svelte index be203ae2c..c07ca6f89 100644 --- a/src/components/PeerSelector/PeerSelector.svelte +++ b/src/components/PeerSelector/PeerSelector.svelte @@ -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 @@ - {#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}
diff --git a/src/lib/cobSearch.ts b/src/lib/cobSearch.ts new file mode 100644 index 000000000..57f968eaa --- /dev/null +++ b/src/lib/cobSearch.ts @@ -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( + 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); +} diff --git a/src/lib/debounced.svelte.ts b/src/lib/debounced.svelte.ts new file mode 100644 index 000000000..949b9915d --- /dev/null +++ b/src/lib/debounced.svelte.ts @@ -0,0 +1,18 @@ +export function debounced( + 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; + }, + }; +} diff --git a/src/views/repo/Issues.svelte b/src/views/repo/Issues.svelte index 2a1a33a7b..81c42aef2 100644 --- a/src/views/repo/Issues.svelte +++ b/src/views/repo/Issues.svelte @@ -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 => 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), ); @@ -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)}
diff --git a/src/views/repo/Patches.svelte b/src/views/repo/Patches.svelte index 91a9ce632..8864e311f 100644 --- a/src/views/repo/Patches.svelte +++ b/src/views/repo/Patches.svelte @@ -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 => 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), ); @@ -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)}
diff --git a/src/views/repo/RepoCommits.svelte b/src/views/repo/RepoCommits.svelte index c9b0ac305..3932d13f5 100644 --- a/src/views/repo/RepoCommits.svelte +++ b/src/views/repo/RepoCommits.svelte @@ -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({ @@ -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, diff --git a/tests/unit/cobSearch.test.ts b/tests/unit/cobSearch.test.ts new file mode 100644 index 000000000..00a501f71 --- /dev/null +++ b/tests/unit/cobSearch.test.ts @@ -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[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]); +}); diff --git a/tests/unit/debounced.svelte.test.ts b/tests/unit/debounced.svelte.test.ts new file mode 100644 index 000000000..9462568fe --- /dev/null +++ b/tests/unit/debounced.svelte.test.ts @@ -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(); +}); diff --git a/vite.config.ts b/vite.config.ts index 147165fc3..f79bb23fd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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/"),