mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-02 02:00:31 +08:00
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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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/"),
|
||||
|
||||
Reference in New Issue
Block a user