From c2c52fca5f43825dc9dea8f01a048ac93703e2ae Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Tue, 25 Aug 2026 03:14:52 +0900 Subject: [PATCH] web: live code search in repo page Signed-off-by: Seongmin Lee --- web/src/lib/api/search.test.ts | 14 ++ .../components/search/CodeResultCard.svelte | 114 +-------------- .../lib/components/search/FileMatch.svelte | 115 +++++++++++++++ .../components/search/RepoCodeSearch.svelte | 120 ++++++++++++++++ .../components/search/codeSearch.svelte.ts | 134 ++++++++++++++++++ .../search/tabs/CodeSearchTab.svelte | 123 +++------------- .../[handle]/[repo]/search/+page.svelte | 16 +++ .../routes/[handle]/[repo]/search/+page.ts | 6 + 8 files changed, 429 insertions(+), 213 deletions(-) create mode 100644 web/src/lib/components/search/FileMatch.svelte create mode 100644 web/src/lib/components/search/RepoCodeSearch.svelte create mode 100644 web/src/lib/components/search/codeSearch.svelte.ts create mode 100644 web/src/routes/[handle]/[repo]/search/+page.svelte create mode 100644 web/src/routes/[handle]/[repo]/search/+page.ts diff --git a/web/src/lib/api/search.test.ts b/web/src/lib/api/search.test.ts index 18da7b2b3..984ccdcde 100644 --- a/web/src/lib/api/search.test.ts +++ b/web/src/lib/api/search.test.ts @@ -131,6 +131,20 @@ describe("searchCode", () => { expect((await searchCode(ctx, { q: "main" })).cursor).toBeUndefined(); }); + // the repo scope is the only thing keeping a repo-scoped search off other repos' files + it("scopes the search to a repo did when given one", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ results: [] })); + const { ctx } = makeCtx(fetchMock); + + await searchCode(ctx, { q: "main", repo: "did:plc:wshs7t2adsemcrrd4snkeqli" }); + + expect(new URL(String(fetchMock.mock.calls[0][0])).searchParams.get("repo")).toBe( + "did:plc:wshs7t2adsemcrrd4snkeqli" + ); + }); + it("forwards the cursor when asking for the next page", async () => { const fetchMock = vi .fn() diff --git a/web/src/lib/components/search/CodeResultCard.svelte b/web/src/lib/components/search/CodeResultCard.svelte index ec0e91231..47b69f44c 100644 --- a/web/src/lib/components/search/CodeResultCard.svelte +++ b/web/src/lib/components/search/CodeResultCard.svelte @@ -1,76 +1,17 @@ -{#snippet gap()} -
- ··· -
-{/snippet} - - -{#snippet spans(list: Span[])}{#each list as span, i (i)}{#if span.match}{span.text}{:else}{span.text}{/if}{:else}​{/each}{/snippet} - -{#snippet body(chunk: Chunk)} -
- {#each chunkLines(chunk) as line (line.num)} -
- {line.num} - -
{@render spans(line.spans)}
-
- {/each} -
-{/snippet} -
@@ -98,52 +39,5 @@ {/if}
-
-
- - {#if result.language} -
- - {result.language} -
- {/if} -
- - {#each visible as chunk, i (chunk.lineStart)} - {#if i > 0}{@render gap()}{/if} - {@render body(chunk)} - {/each} - - {#if hidden.length > 0} -
- -
-
- -
- {#each hidden as chunk (chunk.lineStart)} - {@render gap()} - {@render body(chunk)} - {/each} -
- {/if} -
+ diff --git a/web/src/lib/components/search/FileMatch.svelte b/web/src/lib/components/search/FileMatch.svelte new file mode 100644 index 000000000..3bc51d836 --- /dev/null +++ b/web/src/lib/components/search/FileMatch.svelte @@ -0,0 +1,115 @@ + + +{#snippet gap()} +
+ ··· +
+{/snippet} + + +{#snippet spans(list: Span[])}{#each list as span, i (i)}{#if span.match}{span.text}{:else}{span.text}{/if}{:else}​{/each}{/snippet} + +{#snippet body(chunk: Chunk)} +
+ {#each chunkLines(chunk) as line (line.num)} +
+ {line.num} + +
{@render spans(line.spans)}
+
+ {/each} +
+{/snippet} + +
+
+ + {#if result.language} +
+ + {result.language} +
+ {/if} +
+ + {#each visible as chunk, i (chunk.lineStart)} + {#if i > 0}{@render gap()}{/if} + {@render body(chunk)} + {/each} + + {#if hidden.length > 0} +
+ +
+
+ +
+ {#each hidden as chunk (chunk.lineStart)} + {@render gap()} + {@render body(chunk)} + {/each} +
+ {/if} +
diff --git a/web/src/lib/components/search/RepoCodeSearch.svelte b/web/src/lib/components/search/RepoCodeSearch.svelte new file mode 100644 index 000000000..361f92dd7 --- /dev/null +++ b/web/src/lib/components/search/RepoCodeSearch.svelte @@ -0,0 +1,120 @@ + + + + Search · {ownerHandle}/{repoName} · Tangled + + + +
+ + {#if draft} + + {/if} +
+ + {#if !signedIn} + + Login + + {:else if !repoDid} + + {:else if !committed} + + + Narrow it with lang:go, + file:readme, or + case:yes. + + + {:else if search.loading} +
+ +
+ {:else if search.error} + + {:else} +
+ {#each search.results as result (`${result.repo.did}/${result.path}`)} + + {:else} + + {/each} +
+ {/if} +
diff --git a/web/src/lib/components/search/codeSearch.svelte.ts b/web/src/lib/components/search/codeSearch.svelte.ts new file mode 100644 index 000000000..1d29f5a85 --- /dev/null +++ b/web/src/lib/components/search/codeSearch.svelte.ts @@ -0,0 +1,134 @@ +import type { Did } from "@atcute/lexicons"; +import { createBobbinClient, type BobbinContext } from "$lib/api/client"; +import type { FileResult } from "$lib/api/lexicons/types/org/tangled/temp/search/searchCode"; +import { errorMessage } from "$lib/api/load"; +import { searchCode } from "$lib/api/search"; +import { getAuth } from "$lib/auth.svelte"; + +const PAGE_LIMIT = 50; + +export interface CodeSearchOptions { + /** the committed query */ + query: () => string; + /** repo did to scope the search to. omit to search everything */ + repo?: () => Did | undefined; +} + +export const createCodeSearch = (opts: CodeSearchOptions) => { + const auth = getAuth(); + + let results = $state([]); + let cursor = $state(undefined); + let requesting = $state(false); + let error = $state(null); + + let loadingMore = $state(false); + let moreError = $state(null); + + let session: { + ctx: BobbinContext; + q: string; + repo: Did | undefined; + signal: AbortSignal; + } | null = null; + + const waitingForAgent = $derived(auth.currentDid !== null && auth.agent === null); + const loading = $derived(requesting || (waitingForAgent && opts.query().trim() !== "")); + + $effect(() => { + const q = opts.query().trim(); + const repo = opts.repo?.(); + const agent = auth.agent; + + if (!q || !agent) { + session = null; + results = []; + cursor = undefined; + requesting = false; + loadingMore = false; + error = null; + moreError = null; + return; + } + + const controller = new AbortController(); + const { signal } = controller; + const scope = { + ctx: createBobbinClient({ serviceUrl: auth.bobbinUrl, agent }), + q, + repo, + signal + }; + session = scope; + + results = []; + cursor = undefined; + requesting = true; + loadingMore = false; + error = null; + moreError = null; + + searchCode(scope.ctx, { q, repo, limit: PAGE_LIMIT }, { signal }) + .then((found) => { + if (signal.aborted) return; + results = found.results; + cursor = found.cursor; + }) + .catch((cause: unknown) => { + if (signal.aborted) return; + results = []; + error = errorMessage(cause, "Could not search code."); + }) + .finally(() => { + if (signal.aborted) return; + requesting = false; + }); + + return () => controller.abort(); + }); + + const loadMore = async () => { + const scope = session; + if (!scope || !cursor || loadingMore) return; + + loadingMore = true; + moreError = null; + try { + const found = await searchCode( + scope.ctx, + { q: scope.q, repo: scope.repo, limit: PAGE_LIMIT, cursor }, + { signal: scope.signal } + ); + if (scope.signal.aborted) return; + results.push(...found.results); + cursor = found.cursor; + } catch (cause: unknown) { + if (scope.signal.aborted) return; + moreError = errorMessage(cause, "Could not load more results."); + } finally { + if (!scope.signal.aborted) loadingMore = false; + } + }; + + return { + get results() { + return results; + }, + get cursor() { + return cursor; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + get loadingMore() { + return loadingMore; + }, + get moreError() { + return moreError; + }, + loadMore + }; +}; diff --git a/web/src/lib/components/search/tabs/CodeSearchTab.svelte b/web/src/lib/components/search/tabs/CodeSearchTab.svelte index f3e3eb148..42510dd1e 100644 --- a/web/src/lib/components/search/tabs/CodeSearchTab.svelte +++ b/web/src/lib/components/search/tabs/CodeSearchTab.svelte @@ -1,9 +1,5 @@ @@ -135,27 +47,32 @@ {:else if !query} - {:else if loading} + {:else if search.loading}
- {:else if error} - + {:else if search.error} + {:else}
- {#each results as result (`${result.repo.did}/${result.path}`)} + {#each search.results as result (`${result.repo.did}/${result.path}`)} {:else} {/each}
- {#if moreError} - + {#if search.moreError} + {/if} - {#if cursor} - {/if} @@ -166,8 +83,8 @@ {#if matches > 0}
Found {matches} - {matches === 1 ? "match" : "matches"} in {results.length} - {results.length === 1 ? "file" : "files"} + {matches === 1 ? "match" : "matches"} in {search.results.length} + {search.results.length === 1 ? "file" : "files"}
{/if} diff --git a/web/src/routes/[handle]/[repo]/search/+page.svelte b/web/src/routes/[handle]/[repo]/search/+page.svelte new file mode 100644 index 000000000..48361b49d --- /dev/null +++ b/web/src/routes/[handle]/[repo]/search/+page.svelte @@ -0,0 +1,16 @@ + + + + {@const repo = await data.repo} + + diff --git a/web/src/routes/[handle]/[repo]/search/+page.ts b/web/src/routes/[handle]/[repo]/search/+page.ts new file mode 100644 index 000000000..452540397 --- /dev/null +++ b/web/src/routes/[handle]/[repo]/search/+page.ts @@ -0,0 +1,6 @@ +import type { PageLoad } from "./$types"; + +// the search itself is client-side +export const load: PageLoad = (event) => ({ + query: event.url.searchParams.get("q") ?? "" +}); -- 2.51.2