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/"),