diff --git a/web/src/lib/api/load.ts b/web/src/lib/api/load.ts index eb93f2a0..cbd1e556 100644 --- a/web/src/lib/api/load.ts +++ b/web/src/lib/api/load.ts @@ -22,11 +22,12 @@ export const httpStatusFor = (cause: unknown): number => { return 500; }; +export const errorMessage = (cause: unknown, fallbackMessage = "Request failed"): string => + cause instanceof ClientResponseError ? (cause.description ?? cause.error) : fallbackMessage; + export const toHttpError = (cause: unknown, fallbackMessage = "Request failed"): never => { const status = httpStatusFor(cause) as NumericRange<400, 599>; - const message = - cause instanceof ClientResponseError ? (cause.description ?? cause.error) : fallbackMessage; - throw error(status, message); + throw error(status, errorMessage(cause, fallbackMessage)); }; export const parallel = async >>( diff --git a/web/src/lib/api/search.test.ts b/web/src/lib/api/search.test.ts new file mode 100644 index 00000000..18da7b2b --- /dev/null +++ b/web/src/lib/api/search.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it, vi, type Mock } from "vitest"; +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; +import * as v from "@atcute/lexicons/validations"; +import { ClientResponseError, createBobbinClient } from "./client"; +import { fileResultSchema } from "./lexicons/types/org/tangled/temp/search/searchCode"; +import { searchCode } from "./search"; + +const NSID = "org.tangled.temp.search.searchCode"; +const BOBBIN_URL = "http://127.0.0.1:8090"; + +const jsonResponse = (body: unknown, init: ResponseInit = {}): Response => + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + ...init + }); + +// the agent is only ever asked for a service-auth token, so a stub that answers +// getServiceAuth is enough, and its call args are where the minted claims are. +const makeAgent = (token = "jwt-1") => { + const handle = vi + .fn<(pathname: string, init: RequestInit) => Promise>() + .mockImplementation(() => Promise.resolve(jsonResponse({ token }))); + return { agent: { handle, sub: "did:plc:tester" } as unknown as OAuthUserAgent, handle }; +}; + +const mintedParams = (handle: Mock): URLSearchParams => + new URL(String(handle.mock.calls[0][0]), "https://pds.test").searchParams; + +const makeCtx = (fetchMock: typeof globalThis.fetch, token = "jwt-1") => { + const { agent, handle } = makeAgent(token); + return { ctx: createBobbinClient({ serviceUrl: BOBBIN_URL, agent, fetch: fetchMock }), handle }; +}; + +const WIRE_RESULTS = [ + { + repo: { + did: "did:plc:wshs7t2adsemcrrd4snkeqli", + slug: "knotserver", + owner: { did: "did:plc:wshs7t2adsemcrrd4snkeqli", handle: "alice.tangled.sh" }, + createdAt: "2025-06-01T10:30:00Z", + starCount: 42 + }, + branches: ["main"], + commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", + path: "cmd/knot/main.go", + language: "Go", + chunks: [ + { + content: "func main() {\n\trun()\n}", + lineStart: 24, + highlights: [{ start: 5, end: 9 }] + } + ] + }, + { + repo: { + did: "did:plc:5bqvvbduvkc4d5g2vy4hqz3m", + slug: "web", + owner: { did: "did:plc:hd2rzezxhjqjvcpgnbdtqvvt", handle: "bob.tangled.sh" }, + createdAt: "2025-07-14T08:00:00Z" + }, + commit: "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c", + path: "src/lib/api/main.ts", + // a filename match: no chunks, the ranges cover "main" in the path instead + ranges: [{ start: 12, end: 16 }] + } +]; + +describe("searchCode", () => { + // a failing call has to reject rather than resolve with an empty page: the tab + // tells the two apart to decide between the error box and "no results found". + it("rejects when bobbin fails", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + jsonResponse( + { error: "MethodNotImplemented", message: "code search is not configured" }, + { status: 501 } + ) + ); + const { ctx } = makeCtx(fetchMock); + + await expect(searchCode(ctx, { q: "main" })).rejects.toThrow(ClientResponseError); + }); + + it("calls the configured bobbin at the searchCode nsid", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ results: [] })); + const { ctx } = makeCtx(fetchMock); + + await searchCode(ctx, { q: "main", limit: 50 }); + + const url = new URL(String(fetchMock.mock.calls[0][0])); + expect(url.origin).toBe(BOBBIN_URL); + expect(url.pathname).toBe(`/xrpc/${NSID}`); + expect(url.searchParams.get("q")).toBe("main"); + expect(url.searchParams.get("limit")).toBe("50"); + }); + + // the fixture has to satisfy the lexicon or it isn't testing the real wire shape. + // this is what catches it drifting the next time searchCode.json changes + it("uses a fixture that validates against the fileResult schema", () => { + for (const result of WIRE_RESULTS) { + expect(() => v.parse(fileResultSchema, result)).not.toThrow(); + } + }); + + it("hands back bobbin's results and cursor untouched", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ results: WIRE_RESULTS, cursor: "50" })); + const { ctx } = makeCtx(fetchMock); + + const out = await searchCode(ctx, { q: "main", limit: 50 }); + + expect(out.results).toEqual(WIRE_RESULTS); + expect(out.cursor).toBe("50"); + // the filename-only match survives with no chunks rather than being dropped + expect(out.results[1].chunks).toBeUndefined(); + }); + + // the last page omits the cursor, which is what ends the Load more button + it("reports no cursor when bobbin omits one", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ results: WIRE_RESULTS })); + const { ctx } = makeCtx(fetchMock); + + expect((await searchCode(ctx, { q: "main" })).cursor).toBeUndefined(); + }); + + it("forwards the cursor when asking for the next page", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ results: [] })); + const { ctx } = makeCtx(fetchMock); + + await searchCode(ctx, { q: "main", limit: 50, cursor: "50" }); + + expect(new URL(String(fetchMock.mock.calls[0][0])).searchParams.get("cursor")).toBe("50"); + }); + + it("mints a token audienced at the bobbin host, scoped to the method", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ results: [] })); + const { ctx, handle } = makeCtx(fetchMock, "jwt-abc"); + + await searchCode(ctx, { q: "main" }); + + const minted = mintedParams(handle); + expect(minted.get("aud")).toBe("did:web:127.0.0.1%3A8090"); + expect(minted.get("lxm")).toBe(NSID); + + const headers = new Headers((fetchMock.mock.calls[0][1] as RequestInit | undefined)?.headers); + expect(headers.get("authorization")).toBe("Bearer jwt-abc"); + }); +}); diff --git a/web/src/lib/api/search.ts b/web/src/lib/api/search.ts index f95f0bcc..dfca7a00 100644 --- a/web/src/lib/api/search.ts +++ b/web/src/lib/api/search.ts @@ -1,6 +1,7 @@ import type { BobbinContext, XrpcRequestInit } from "./client"; import { jsonGet } from "./_request"; import { paginateBy } from "./pagination"; +import * as OrgTangledTempSearchSearchCode from "./lexicons/types/org/tangled/temp/search/searchCode"; export interface SearchHit { uri: string; @@ -45,3 +46,15 @@ export async function* searchAll( return { items: page.hits, cursor: page.cursor }; }, options); } + +export const searchCode = ( + ctx: BobbinContext, + params: OrgTangledTempSearchSearchCode.$params, + init?: XrpcRequestInit +): Promise => + jsonGet( + ctx, + OrgTangledTempSearchSearchCode.mainSchema.nsid, + { ...params }, + init + ); diff --git a/web/src/lib/components/search/CodeResultCard.svelte b/web/src/lib/components/search/CodeResultCard.svelte new file mode 100644 index 00000000..ec0e9123 --- /dev/null +++ b/web/src/lib/components/search/CodeResultCard.svelte @@ -0,0 +1,149 @@ + + +{#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} + +
+
+ +
+ {#each branches as branch (branch)} + {branch} + {/each} +
+ {#if result.repo.starCount !== undefined} +
+
+ {/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/SearchBar.svelte b/web/src/lib/components/search/SearchBar.svelte new file mode 100644 index 00000000..2ee55981 --- /dev/null +++ b/web/src/lib/components/search/SearchBar.svelte @@ -0,0 +1,31 @@ + + +
+ + + + + {/each} + +

+ Click a language to add it to your query, then search. You can also type + lang:name + into the search bar yourself. +

+ + + {@render children?.()} + + diff --git a/web/src/lib/components/search/SearchTabs.svelte b/web/src/lib/components/search/SearchTabs.svelte new file mode 100644 index 00000000..f179681b --- /dev/null +++ b/web/src/lib/components/search/SearchTabs.svelte @@ -0,0 +1,24 @@ + + + + + diff --git a/web/src/lib/components/search/chunks.test.ts b/web/src/lib/components/search/chunks.test.ts new file mode 100644 index 00000000..952b7edc --- /dev/null +++ b/web/src/lib/components/search/chunks.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { chunkLines, matchCount } from "./chunks"; + +/** compact view of a line: line number + its spans as "plain" / "[match]" */ +const shape = (chunk: Parameters[0]) => + chunkLines(chunk).map((l) => [l.num, l.spans.map((s) => (s.match ? `[${s.text}]` : s.text))]); + +describe("chunkLines", () => { + it("numbers lines from lineStart and splits each into plain/matched spans", () => { + // "main" at bytes 5-9, "main" again at 21-25 + const content = "func main() {\n\tcallMain()\n}\n"; + expect(shape({ content, lineStart: 12, highlights: [{ start: 5, end: 9 }] })).toEqual([ + [12, ["func ", "[main]", "() {"]], + [13, ["\tcallMain()"]], + [14, ["}"]] + ]); + }); + + it("clips a highlight that spans a newline to each line it covers", () => { + // bytes 2-8 cover "c\nde" — "b\n" is at 1-3 + const content = "abc\ndef"; + expect(shape({ content, lineStart: 1, highlights: [{ start: 2, end: 6 }] })).toEqual([ + [1, ["ab", "[c]"]], + [2, ["[de]", "f"]] + ]); + }); + + it("uses byte offsets, not UTF-16 indices", () => { + // "héllo wörld": h(1) é(2) l l o space w -> "wörld" starts at byte 7 + const content = "héllo wörld"; + expect(shape({ content, lineStart: 1, highlights: [{ start: 7, end: 13 }] })).toEqual([ + [1, ["héllo ", "[wörld]"]] + ]); + }); + + it("emits an empty span list for a blank line and no spurious trailing line", () => { + expect(shape({ content: "a\n\nb\n", lineStart: 3 })).toEqual([ + [3, ["a"]], + [4, []], + [5, ["b"]] + ]); + }); + + it("merges overlapping and adjacent highlights", () => { + const content = "abcdef"; + expect( + shape({ + content, + lineStart: 1, + highlights: [ + { start: 3, end: 5 }, + { start: 1, end: 3 }, + { start: 4, end: 6 } + ] + }) + ).toEqual([[1, ["a", "[bcdef]"]]]); + }); +}); + +describe("matchCount", () => { + it("sums highlights across chunks, counting a missing list as zero", () => { + expect( + matchCount([ + { content: "a", lineStart: 1, highlights: [{ start: 0, end: 1 }] }, + { content: "b", lineStart: 2 } + ]) + ).toBe(1); + }); +}); diff --git a/web/src/lib/components/search/chunks.ts b/web/src/lib/components/search/chunks.ts new file mode 100644 index 00000000..9551f504 --- /dev/null +++ b/web/src/lib/components/search/chunks.ts @@ -0,0 +1,79 @@ +import type { Chunk, Highlight } from "$lib/api/lexicons/types/org/tangled/temp/search/searchCode"; + +export interface Span { + text: string; + match?: boolean; +} + +export interface Line { + num: number; + /** empty for a blank line */ + spans: Span[]; + /** true when any part of this line matched */ + highlight: boolean; +} + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +/** sorts and merges overlapping or adjacent ranges */ +function mergeRanges(highlights: Highlight[]): Highlight[] { + const sorted = highlights.filter((h) => h.end > h.start).sort((a, b) => a.start - b.start); + const out: Highlight[] = []; + for (const h of sorted) { + const last = out.at(-1); + if (last && h.start <= last.end) { + last.end = Math.max(last.end, h.end); + } else { + out.push({ ...h }); + } + } + return out; +} + +function lineAt( + bytes: Uint8Array, + start: number, + end: number, + num: number, + ranges: Highlight[] +): Line { + const slice = (from: number, to: number) => decoder.decode(bytes.subarray(from, to)); + const spans: Span[] = []; + let highlight = false; + let pos = start; + + // ranges are sorted, so walking them once keeps `pos` monotonic + for (const r of ranges) { + const s = Math.max(r.start, start); + const e = Math.min(r.end, end); + if (s >= e) continue; + highlight = true; + if (s > pos) spans.push({ text: slice(pos, s) }); + spans.push({ text: slice(s, e), match: true }); + pos = e; + } + if (pos < end) spans.push({ text: slice(pos, end) }); + + return { num, spans, highlight }; +} + +export function chunkLines(chunk: Chunk): Line[] { + const ranges = mergeRanges(chunk.highlights ?? []); + // trim a single trailing newline so we don't emit a spurious empty line + const bytes = encoder.encode(chunk.content.replace(/\n$/, "")); + const firstNum = Math.max(1, chunk.lineStart); + + const lines: Line[] = []; + let start = 0; + for (let i = 0; i <= bytes.length; i++) { + if (i < bytes.length && bytes[i] !== 0x0a) continue; + lines.push(lineAt(bytes, start, i, firstNum + lines.length, ranges)); + start = i + 1; + } + return lines; +} + +/** total match count across a file result's chunks, for the stats line */ +export const matchCount = (chunks: Chunk[]) => + chunks.reduce((n, c) => n + (c.highlights?.length ?? 0), 0); diff --git a/web/src/lib/components/search/tabs/CodeSearchTab.svelte b/web/src/lib/components/search/tabs/CodeSearchTab.svelte new file mode 100644 index 00000000..f3e3eb14 --- /dev/null +++ b/web/src/lib/components/search/tabs/CodeSearchTab.svelte @@ -0,0 +1,175 @@ + + + + Code Search · Tangled + + +
+

Code Search

+
+
+ + + {#if !signedIn} + + Login + + {:else if !query} + + {:else if loading} +
+ +
+ {:else if error} + + {:else} +
+ {#each results as result (`${result.repo.did}/${result.path}`)} + + {:else} + + {/each} +
+ + {#if moreError} + + {/if} + + {#if cursor} + + {/if} + {/if} +
+ + + {#if matches > 0} +
+ Found {matches} + {matches === 1 ? "match" : "matches"} in {results.length} + {results.length === 1 ? "file" : "files"} +
+ {/if} +
+
+
diff --git a/web/src/lib/components/search/tabs/RepoSearchTab.svelte b/web/src/lib/components/search/tabs/RepoSearchTab.svelte new file mode 100644 index 00000000..4d74d99e --- /dev/null +++ b/web/src/lib/components/search/tabs/RepoSearchTab.svelte @@ -0,0 +1,29 @@ + + + + Search · Tangled + + +
+

Search

+
+
+ + + + Search code instead + +
+ + +
+
diff --git a/web/src/lib/oauth-client-metadata.json b/web/src/lib/oauth-client-metadata.json index 49966103..3f489fac 100644 --- a/web/src/lib/oauth-client-metadata.json +++ b/web/src/lib/oauth-client-metadata.json @@ -5,7 +5,7 @@ "redirect_uris": [ "https://tangled.org/oauth/callback" ], - "scope": "atproto repo:sh.tangled.actor.profile repo:org.tangled.feed.subscription repo:sh.tangled.feed.comment repo:sh.tangled.feed.reaction repo:sh.tangled.feed.star repo:sh.tangled.graph.follow repo:sh.tangled.graph.vouch repo:sh.tangled.knot repo:sh.tangled.knot.member repo:sh.tangled.label.definition repo:sh.tangled.label.op repo:sh.tangled.publicKey repo:sh.tangled.repo repo:sh.tangled.repo.artifact repo:sh.tangled.repo.collaborator repo:sh.tangled.repo.issue repo:sh.tangled.repo.issue.comment repo:sh.tangled.repo.issue.state repo:sh.tangled.repo.pull repo:sh.tangled.repo.pull.comment repo:sh.tangled.repo.pull.status repo:sh.tangled.spindle repo:sh.tangled.spindle.member repo:sh.tangled.string blob:*/* rpc:sh.tangled.graph.listNetworkVouches?aud=* rpc:sh.tangled.knot.addMember?aud=* rpc:sh.tangled.knot.removeMember?aud=* rpc:sh.tangled.ci.triggerPipeline?aud=* rpc:sh.tangled.ci.cancelPipeline?aud=* rpc:sh.tangled.repo.addCollaborator?aud=* rpc:sh.tangled.repo.addSecret?aud=* rpc:sh.tangled.repo.create?aud=* rpc:sh.tangled.repo.delete?aud=* rpc:sh.tangled.repo.deleteBranch?aud=* rpc:sh.tangled.repo.forkStatus?aud=* rpc:sh.tangled.repo.forkSync?aud=* rpc:sh.tangled.repo.hiddenRef?aud=* rpc:sh.tangled.repo.listSecrets?aud=* rpc:sh.tangled.repo.merge?aud=* rpc:sh.tangled.repo.mergeCheck?aud=* rpc:sh.tangled.repo.removeCollaborator?aud=* rpc:sh.tangled.repo.removeSecret?aud=* rpc:sh.tangled.repo.setDefaultBranch?aud=* rpc:org.tangled.temp.notification.getPreferences?aud=* rpc:org.tangled.temp.notification.updatePreferences?aud=* rpc:org.tangled.temp.notification.getUnreadCount?aud=* rpc:org.tangled.temp.notification.listNotifications?aud=* rpc:org.tangled.temp.notification.markAllRead?aud=* rpc:org.tangled.temp.notification.markEntityRead?aud=* rpc:org.tangled.temp.notification.updateSeen?aud=* rpc:org.tangled.temp.site.getDomainClaim?aud=* rpc:org.tangled.temp.site.claimDomain?aud=* rpc:org.tangled.temp.site.releaseDomain?aud=* rpc:sh.tangled.git.keepCommit?aud=* rpc:sh.tangled.git.mergeCommit?aud=* rpc:com.atproto.moderation.createReport?aud=*", + "scope": "atproto repo:sh.tangled.actor.profile repo:org.tangled.feed.subscription repo:sh.tangled.feed.comment repo:sh.tangled.feed.reaction repo:sh.tangled.feed.star repo:sh.tangled.graph.follow repo:sh.tangled.graph.vouch repo:sh.tangled.knot repo:sh.tangled.knot.member repo:sh.tangled.label.definition repo:sh.tangled.label.op repo:sh.tangled.publicKey repo:sh.tangled.repo repo:sh.tangled.repo.artifact repo:sh.tangled.repo.collaborator repo:sh.tangled.repo.issue repo:sh.tangled.repo.issue.comment repo:sh.tangled.repo.issue.state repo:sh.tangled.repo.pull repo:sh.tangled.repo.pull.comment repo:sh.tangled.repo.pull.status repo:sh.tangled.spindle repo:sh.tangled.spindle.member repo:sh.tangled.string blob:*/* rpc:sh.tangled.graph.listNetworkVouches?aud=* rpc:sh.tangled.knot.addMember?aud=* rpc:sh.tangled.knot.removeMember?aud=* rpc:sh.tangled.ci.triggerPipeline?aud=* rpc:sh.tangled.ci.cancelPipeline?aud=* rpc:sh.tangled.repo.addCollaborator?aud=* rpc:sh.tangled.repo.addSecret?aud=* rpc:sh.tangled.repo.create?aud=* rpc:sh.tangled.repo.delete?aud=* rpc:sh.tangled.repo.deleteBranch?aud=* rpc:sh.tangled.repo.forkStatus?aud=* rpc:sh.tangled.repo.forkSync?aud=* rpc:sh.tangled.repo.hiddenRef?aud=* rpc:sh.tangled.repo.listSecrets?aud=* rpc:sh.tangled.repo.merge?aud=* rpc:sh.tangled.repo.mergeCheck?aud=* rpc:sh.tangled.repo.removeCollaborator?aud=* rpc:sh.tangled.repo.removeSecret?aud=* rpc:sh.tangled.repo.setDefaultBranch?aud=* rpc:org.tangled.temp.notification.getPreferences?aud=* rpc:org.tangled.temp.notification.updatePreferences?aud=* rpc:org.tangled.temp.notification.getUnreadCount?aud=* rpc:org.tangled.temp.notification.listNotifications?aud=* rpc:org.tangled.temp.notification.markAllRead?aud=* rpc:org.tangled.temp.notification.markEntityRead?aud=* rpc:org.tangled.temp.notification.updateSeen?aud=* rpc:org.tangled.temp.site.getDomainClaim?aud=* rpc:org.tangled.temp.site.claimDomain?aud=* rpc:org.tangled.temp.site.releaseDomain?aud=* rpc:org.tangled.temp.search.searchCode?aud=* rpc:sh.tangled.git.keepCommit?aud=* rpc:sh.tangled.git.mergeCommit?aud=* rpc:com.atproto.moderation.createReport?aud=*", "grant_types": [ "authorization_code", "refresh_token" diff --git a/web/src/routes/search/+page.svelte b/web/src/routes/search/+page.svelte new file mode 100644 index 00000000..803319d1 --- /dev/null +++ b/web/src/routes/search/+page.svelte @@ -0,0 +1,13 @@ + + +{#if data.type === "code"} + +{:else} + +{/if} diff --git a/web/src/routes/search/+page.ts b/web/src/routes/search/+page.ts new file mode 100644 index 00000000..c4741f5a --- /dev/null +++ b/web/src/routes/search/+page.ts @@ -0,0 +1,8 @@ +import type { PageLoad } from "./$types"; + +export const load: PageLoad = (event) => { + const query = event.url.searchParams.get("q") ?? ""; + const type = event.url.searchParams.get("type") === "code" ? "code" : "repo"; + + return { query, type }; +};