This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

wip: web: code search

Signed-off-by: Seongmin Lee <git@boltless.me>

author
Seongmin Lee
date (Aug 1, 2026, 8:26 PM +0900) commit d3d792ec parent 34f58de4 change-id qzksvoys
+873 -6
+1
localinfra/readme.md
··· 70 70 71 71 ```bash 72 72 BOBBIN_URL=http://127.0.0.1:8090 73 + TANGLED_API_URL=http://127.0.0.1:3000 73 74 # leave unset to read repositories directly from their knots 74 75 KNOTMIRROR_URL=https://mirror.tngl.boltless.dev 75 76 VITE_HANDLE_RESOLVER_URL=https://pds.tngl.boltless.dev
+83
web/src/lib/api/client.test.ts
··· 1 1 import { describe, expect, it, vi, type Mock } from "vitest"; 2 + import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; 2 3 import { ClientResponseError, createBobbinClient, type BobbinContext } from "./client"; 3 4 import { jsonGet, rawGet } from "./_request"; 4 5 ··· 90 91 expect(cre.status).toBe(502); 91 92 expect(cre.error).toBe("XRPCError"); 92 93 expect(cre.description).toBe("Bad Gateway"); 94 + }); 95 + }); 96 + 97 + describe("createBobbinClient service auth", () => { 98 + // mintServiceAuth goes through `new Client({ handler: agent })`, and 99 + // OAuthUserAgent satisfies FetchHandlerObject — so `handle` is where the 100 + // com.atproto.server.getServiceAuth call lands and where the minted claims 101 + // can be read back off the query string. 102 + const makeAgent = (token = "jwt-1") => { 103 + const handle = vi 104 + .fn<(pathname: string, init: RequestInit) => Promise<Response>>() 105 + .mockImplementation(() => Promise.resolve(jsonResponse({ token }))); 106 + return { agent: { handle, sub: "did:plc:tester" } as unknown as OAuthUserAgent, handle }; 107 + }; 108 + 109 + const mintedParams = (handle: Mock, n = 0): URLSearchParams => 110 + new URL(String(handle.mock.calls[n][0]), "https://pds.test").searchParams; 111 + 112 + const authHeader = (fetchMock: Mock<typeof globalThis.fetch>, n = 0): string | null => 113 + new Headers((fetchMock.mock.calls[n][1] as RequestInit | undefined)?.headers).get( 114 + "authorization" 115 + ); 116 + 117 + // a Response body reads once, so each call needs its own 118 + const okFetch = () => 119 + vi 120 + .fn<typeof globalThis.fetch>() 121 + .mockImplementation(() => Promise.resolve(jsonResponse({ ok: 1 }))); 122 + 123 + it("sends no authorization header when no agent is given", async () => { 124 + const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(jsonResponse({ ok: 1 })); 125 + await jsonGet(makeCtx(fetchMock), "sh.tangled.x"); 126 + expect(authHeader(fetchMock)).toBeNull(); 127 + }); 128 + 129 + it("attaches a minted bearer token when an agent is given", async () => { 130 + const fetchMock = okFetch(); 131 + const { agent } = makeAgent("jwt-abc"); 132 + const ctx = createBobbinClient({ serviceUrl: "https://appview.test", fetch: fetchMock, agent }); 133 + 134 + await jsonGet(ctx, "org.tangled.temp.search.searchCode", { q: "main" }); 135 + 136 + expect(authHeader(fetchMock)).toBe("Bearer jwt-abc"); 137 + }); 138 + 139 + it("scopes each token to the method being called", async () => { 140 + const fetchMock = okFetch(); 141 + const { agent, handle } = makeAgent(); 142 + const ctx = createBobbinClient({ serviceUrl: "https://appview.test", fetch: fetchMock, agent }); 143 + 144 + await jsonGet(ctx, "org.tangled.temp.search.searchCode", { q: "main" }); 145 + await jsonGet(ctx, "org.tangled.temp.notification.getUnreadCount"); 146 + 147 + expect(handle).toHaveBeenCalledTimes(2); 148 + expect(mintedParams(handle, 0).get("lxm")).toBe("org.tangled.temp.search.searchCode"); 149 + expect(mintedParams(handle, 1).get("lxm")).toBe("org.tangled.temp.notification.getUnreadCount"); 150 + }); 151 + 152 + it("audiences the token at did:web:<host>, percent-encoding the port", async () => { 153 + const fetchMock = okFetch(); 154 + const { agent, handle } = makeAgent(); 155 + // the dev default from lib/server/config.ts 156 + const ctx = createBobbinClient({ 157 + serviceUrl: "http://127.0.0.1:8080", 158 + fetch: fetchMock, 159 + agent 160 + }); 161 + 162 + await jsonGet(ctx, "org.tangled.temp.search.searchCode", { q: "main" }); 163 + 164 + expect(mintedParams(handle).get("aud")).toBe("did:web:127.0.0.1%3A8080"); 165 + }); 166 + 167 + it("mints nothing for a fetch that is not an xrpc call", async () => { 168 + const fetchMock = okFetch(); 169 + const { agent, handle } = makeAgent(); 170 + const ctx = createBobbinClient({ serviceUrl: "https://appview.test", fetch: fetchMock, agent }); 171 + 172 + await ctx.fetch("https://appview.test/healthz"); 173 + 174 + expect(handle).not.toHaveBeenCalled(); 175 + expect(authHeader(fetchMock)).toBeNull(); 93 176 }); 94 177 }); 95 178
+4 -3
web/src/lib/api/load.ts
··· 22 22 return 500; 23 23 }; 24 24 25 + export const errorMessage = (cause: unknown, fallbackMessage = "Request failed"): string => 26 + cause instanceof ClientResponseError ? (cause.description ?? cause.error) : fallbackMessage; 27 + 25 28 export const toHttpError = (cause: unknown, fallbackMessage = "Request failed"): never => { 26 29 const status = httpStatusFor(cause) as NumericRange<400, 599>; 27 - const message = 28 - cause instanceof ClientResponseError ? (cause.description ?? cause.error) : fallbackMessage; 29 - throw error(status, message); 30 + throw error(status, errorMessage(cause, fallbackMessage)); 30 31 }; 31 32 32 33 export const parallel = async <T extends Record<string, Promise<unknown>>>(
+84
web/src/lib/api/search.test.ts
··· 1 + import { describe, expect, it, vi, type Mock } from "vitest"; 2 + import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; 3 + import { createAppviewClient } from "./appview"; 4 + import { ClientResponseError } from "./client"; 5 + import { searchCode } from "./search"; 6 + 7 + const NSID = "org.tangled.temp.search.searchCode"; 8 + const API_URL = "http://127.0.0.1:3000"; 9 + 10 + const jsonResponse = (body: unknown, init: ResponseInit = {}): Response => 11 + new Response(JSON.stringify(body), { 12 + status: 200, 13 + headers: { "content-type": "application/json" }, 14 + ...init 15 + }); 16 + 17 + // the agent is only ever asked for a service-auth token, so a stub that answers 18 + // getServiceAuth is enough, and its call args are where the minted claims are. 19 + const makeAgent = (token = "jwt-1") => { 20 + const handle = vi 21 + .fn<(pathname: string, init: RequestInit) => Promise<Response>>() 22 + .mockImplementation(() => Promise.resolve(jsonResponse({ token }))); 23 + return { agent: { handle, sub: "did:plc:tester" } as unknown as OAuthUserAgent, handle }; 24 + }; 25 + 26 + const mintedParams = (handle: Mock): URLSearchParams => 27 + new URL(String(handle.mock.calls[0][0]), "https://pds.test").searchParams; 28 + 29 + const makeCtx = (fetchMock: typeof globalThis.fetch, token = "jwt-1") => { 30 + const { agent, handle } = makeAgent(token); 31 + return { ctx: createAppviewClient({ apiUrl: API_URL, agent, fetch: fetchMock }), handle }; 32 + }; 33 + 34 + describe("searchCode", () => { 35 + // the appview reply is discarded until the lexicon carries the fields 36 + // CodeResultCard needs, so a failing call is the only thing observable from 37 + // outside. it has to reject rather than fall back to the fake results. 38 + it("rejects when the appview fails, rather than resolving with fake results", async () => { 39 + const fetchMock = vi 40 + .fn<typeof globalThis.fetch>() 41 + .mockResolvedValue( 42 + jsonResponse( 43 + { error: "MethodNotImplemented", message: "code search is not configured" }, 44 + { status: 501 } 45 + ) 46 + ); 47 + const { ctx } = makeCtx(fetchMock); 48 + 49 + await expect(searchCode(ctx, { q: "main" })).rejects.toThrow(ClientResponseError); 50 + }); 51 + 52 + it("calls the configured appview at the searchCode nsid", async () => { 53 + const fetchMock = vi 54 + .fn<typeof globalThis.fetch>() 55 + .mockResolvedValue(jsonResponse({ results: [] })); 56 + const { ctx } = makeCtx(fetchMock); 57 + 58 + await searchCode(ctx, { q: "main", limit: 50 }); 59 + 60 + const url = new URL(String(fetchMock.mock.calls[0][0])); 61 + expect(url.origin).toBe(API_URL); 62 + expect(url.pathname).toBe(`/xrpc/${NSID}`); 63 + expect(url.searchParams.get("q")).toBe("main"); 64 + expect(url.searchParams.get("limit")).toBe("50"); 65 + }); 66 + 67 + // the aud has to name the appview, or VerifyServiceAuth rejects with a 403. 68 + // deriving it from apiUrl is what keeps it from drifting off the target host. 69 + it("mints a token audienced at the appview host, scoped to the method", async () => { 70 + const fetchMock = vi 71 + .fn<typeof globalThis.fetch>() 72 + .mockResolvedValue(jsonResponse({ results: [] })); 73 + const { ctx, handle } = makeCtx(fetchMock, "jwt-abc"); 74 + 75 + await searchCode(ctx, { q: "main" }); 76 + 77 + const minted = mintedParams(handle); 78 + expect(minted.get("aud")).toBe("did:web:127.0.0.1%3A3000"); 79 + expect(minted.get("lxm")).toBe(NSID); 80 + 81 + const headers = new Headers((fetchMock.mock.calls[0][1] as RequestInit | undefined)?.headers); 82 + expect(headers.get("authorization")).toBe("Bearer jwt-abc"); 83 + }); 84 + });
+107
web/src/lib/api/search.ts
··· 1 + import type { CodeResult } from "$lib/components/search/types"; 2 + import { authedGet, type AppviewContext } from "./appview"; 1 3 import type { BobbinContext, XrpcRequestInit } from "./client"; 2 4 import { jsonGet } from "./_request"; 3 5 import { paginateBy } from "./pagination"; 6 + import * as OrgTangledTempSearchSearchCode from "./lexicons/types/org/tangled/temp/search/searchCode"; 4 7 5 8 export interface SearchHit { 6 9 uri: string; ··· 45 48 return { items: page.hits, cursor: page.cursor }; 46 49 }, options); 47 50 } 51 + 52 + export interface CodeSearchPage { 53 + results: CodeResult[]; 54 + cursor: string | null; 55 + } 56 + 57 + export const searchCode = async ( 58 + ctx: AppviewContext, 59 + params: OrgTangledTempSearchSearchCode.$params, 60 + init?: XrpcRequestInit 61 + ): Promise<CodeSearchPage> => { 62 + const out = await authedGet<OrgTangledTempSearchSearchCode.$output>( 63 + ctx, 64 + OrgTangledTempSearchSearchCode.mainSchema.nsid, 65 + { ...params }, 66 + init 67 + ); 68 + console.info("[searchCode] live response", out); 69 + 70 + // TODO: pass down results 71 + return { results: fakeResultsFor(params.q), cursor: null }; 72 + }; 73 + 74 + const fakeResultsFor = (q: string): CodeResult[] => { 75 + const lang = /(?:^|\s)lang:(\S+)/i.exec(q)?.[1]?.toLowerCase(); 76 + return lang ? FAKE_RESULTS.filter((r) => r.language?.toLowerCase() === lang) : FAKE_RESULTS; 77 + }; 78 + 79 + const FAKE_RESULTS: CodeResult[] = [ 80 + { 81 + repoDid: "did:plc:wshs7t2adsemcrrd4snkeqli", 82 + ownerHandle: "alice.tangled.sh", 83 + repoName: "knotserver", 84 + ref: "main", 85 + path: "cmd/knot/main.go", 86 + language: "Go", 87 + chunks: [ 88 + { 89 + content: 90 + 'func main() {\n\tif err := run(); err != nil {\n\t\tlog.Fatal("main:", err)\n\t}\n}', 91 + lineStart: 24, 92 + highlights: [ 93 + { start: 5, end: 9 }, 94 + { start: 58, end: 62 } 95 + ] 96 + } 97 + ] 98 + }, 99 + { 100 + repoDid: "did:plc:wshs7t2adsemcrrd4snkeqli", 101 + ownerHandle: "bob.tangled.sh", 102 + repoName: "web", 103 + ref: "main", 104 + path: "src/lib/api/client.ts", 105 + language: "TypeScript", 106 + chunks: [ 107 + { 108 + content: 109 + "export interface BobbinContext {\n\tserviceUrl: string;\n\tfetch: typeof fetch;\n}", 110 + lineStart: 7, 111 + highlights: [{ start: 17, end: 30 }] 112 + }, 113 + { 114 + content: 115 + "export const createBobbinClient = (init: ClientInit): BobbinContext => ({\n\tserviceUrl: init.serviceUrl,", 116 + lineStart: 19, 117 + highlights: [{ start: 13, end: 31 }] 118 + }, 119 + { 120 + content: "\t\treturn createBobbinClient({ serviceUrl, fetch });", 121 + lineStart: 41, 122 + highlights: [{ start: 9, end: 27 }] 123 + }, 124 + { 125 + content: "// createBobbinClient is the only place the service url is normalised", 126 + lineStart: 58, 127 + highlights: [{ start: 3, end: 21 }] 128 + }, 129 + { 130 + content: "const ctx = createBobbinClient({ serviceUrl: BOBBIN });", 131 + lineStart: 73, 132 + highlights: [{ start: 12, end: 30 }] 133 + } 134 + ] 135 + }, 136 + { 137 + repoDid: "did:plc:wshs7t2adsemcrrd4snkeqli", 138 + ownerHandle: "carol.tangled.sh", 139 + repoName: "notes", 140 + ref: "trunk", 141 + path: "docs/naïve-search.md", 142 + chunks: [ 143 + { 144 + // non-ascii and a blank line, to prove the byte-offset split 145 + content: "# naïve search\n\nthe naïve approach walks every document.", 146 + lineStart: 1, 147 + highlights: [ 148 + { start: 2, end: 8 }, 149 + { start: 21, end: 27 } 150 + ] 151 + } 152 + ] 153 + } 154 + ];
+1 -1
web/src/lib/auth/guards.ts
··· 9 9 handle: string; 10 10 } 11 11 12 - const loginWithReturn = (returnUrl: string): string => 12 + export const loginWithReturn = (returnUrl: string): string => 13 13 `/login?return_url=${encodeURIComponent(returnUrl)}`; 14 14 15 15 export const requireAuth = (event: RequestEvent): RequireAuthResult => {
+115
web/src/lib/components/search/CodeResultCard.svelte
··· 1 + <script lang="ts"> 2 + import { resolve } from "$app/paths"; 3 + import BookMarked from "$icon/book-marked"; 4 + import ChevronDown from "$icon/chevron-down"; 5 + import ChevronUp from "$icon/chevron-up"; 6 + import File from "$icon/file"; 7 + import { LANGUAGE_COLORS, LANGUAGE_COLOR_FALLBACK } from "$lib/components/repo/language-colors"; 8 + import { chunkLines, matchCount } from "./chunks"; 9 + import type { Chunk, CodeResult } from "./types"; 10 + 11 + // rest of the chunks go behind a "show more" disclosure 12 + const VISIBLE_CHUNKS = 3; 13 + 14 + let { result }: { result: CodeResult } = $props(); 15 + 16 + const base = $derived(`/${result.ownerHandle}/${result.repoName}`); 17 + const filePath = $derived(`${base}/blob/${encodeURIComponent(result.ref)}/${result.path}`); 18 + const repoHref = $derived(resolve(base as "/")); 19 + const fileHref = $derived(resolve(filePath as "/")); 20 + const lineHref = (num: number) => resolve(`${filePath}#L${num}` as "/"); 21 + 22 + const visible = $derived(result.chunks.slice(0, VISIBLE_CHUNKS)); 23 + const hidden = $derived(result.chunks.slice(VISIBLE_CHUNKS)); 24 + const hiddenMatches = $derived(matchCount(hidden)); 25 + const dotColor = $derived( 26 + result.language ? (LANGUAGE_COLORS[result.language] ?? LANGUAGE_COLOR_FALLBACK) : undefined 27 + ); 28 + </script> 29 + 30 + {#snippet gap()} 31 + <div class="bg-background-subtle text-center"> 32 + <span class="text-sm text-foreground-subtle select-none">···</span> 33 + </div> 34 + {/snippet} 35 + 36 + {#snippet body(chunk: Chunk)} 37 + <div class="overflow-x-auto font-mono text-sm"> 38 + {#each chunkLines(chunk) as line (line.num)} 39 + <div 40 + class="flex w-max min-w-full gap-3 px-3 {line.highlight 41 + ? 'bg-background-highlight/30' 42 + : ''}" 43 + > 44 + <a 45 + href={lineHref(line.num)} 46 + class="block min-w-10 shrink-0 text-right text-foreground-disabled no-underline select-none hover:text-foreground-subtle hover:underline" 47 + >{line.num}</a 48 + > 49 + <!-- prettier-ignore --> 50 + <div class="whitespace-pre text-foreground-muted" 51 + >{#each line.spans as span, i (i)}{#if span.match}<span class="rounded-sm bg-background-highlight/70">{span.text}</span>{:else}{span.text}{/if}{:else}&ZeroWidthSpace;{/each}</div 52 + > 53 + </div> 54 + {/each} 55 + </div> 56 + {/snippet} 57 + 58 + <div 59 + class="flex flex-col gap-2 rounded border border-border-default bg-background-default p-3 text-foreground-default" 60 + > 61 + <div class="flex min-w-0 items-center gap-1.5 px-1 font-medium"> 62 + <BookMarked class="size-4 shrink-0 text-foreground-subtle" aria-hidden="true" /> 63 + <a href={repoHref} class="min-w-0 truncate text-foreground-default no-underline hover:underline" 64 + >{result.ownerHandle}/{result.repoName}</a 65 + > 66 + </div> 67 + 68 + <div class="divide-y divide-border-default rounded-sm border border-border-default"> 69 + <div class="flex items-center justify-between gap-3 px-3.5 py-2 text-sm"> 70 + <div class="flex min-w-0 items-center gap-2"> 71 + <File class="size-4 shrink-0 text-foreground-subtle" aria-hidden="true" /> 72 + <a href={fileHref} class="min-w-0 truncate font-mono no-underline hover:underline" 73 + >{result.path}</a 74 + > 75 + </div> 76 + {#if result.language} 77 + <div class="flex shrink-0 items-center gap-2 text-foreground-muted"> 78 + <span 79 + class="inline-block size-2.5 shrink-0 rounded-full" 80 + style={`background-color: ${dotColor}`} 81 + ></span> 82 + <span>{result.language}</span> 83 + </div> 84 + {/if} 85 + </div> 86 + 87 + {#each visible as chunk, i (chunk.lineStart)} 88 + {#if i > 0}{@render gap()}{/if} 89 + {@render body(chunk)} 90 + {/each} 91 + 92 + {#if hidden.length > 0} 93 + <details class="group flex flex-col divide-y divide-border-default"> 94 + <summary 95 + class="cursor-pointer list-none bg-background-subtle px-1 py-0.5 text-foreground-subtle group-open:order-last group-open:border-t group-open:border-border-default" 96 + > 97 + <div class="flex group-open:hidden"> 98 + <ChevronDown class="m-1 size-3" aria-hidden="true" /> 99 + <span class="text-sm select-none" 100 + >Show {hiddenMatches} more {hiddenMatches === 1 ? "match" : "matches"}</span 101 + > 102 + </div> 103 + <div class="hidden group-open:flex"> 104 + <ChevronUp class="m-1 size-3" aria-hidden="true" /> 105 + <span class="text-sm select-none">Show less</span> 106 + </div> 107 + </summary> 108 + {#each hidden as chunk (chunk.lineStart)} 109 + {@render gap()} 110 + {@render body(chunk)} 111 + {/each} 112 + </details> 113 + {/if} 114 + </div> 115 + </div>
+31
web/src/lib/components/search/SearchBar.svelte
··· 1 + <script lang="ts"> 2 + import Search from "$icon/search"; 3 + import Button from "$lib/components/ui/Button.svelte"; 4 + import Input from "$lib/components/ui/Input.svelte"; 5 + import ButtonGroup from "$lib/components/ui/ButtonGroup.svelte"; 6 + 7 + interface Props { 8 + // TODO(boltless): embed 'type' field into search query 9 + type: string; 10 + query: string; 11 + placeholder: string; 12 + mono?: boolean; 13 + } 14 + 15 + let { type, query = $bindable(), placeholder, mono = false }: Props = $props(); 16 + </script> 17 + 18 + <form method="GET" class="flex items-center gap-2"> 19 + <input type="hidden" name="type" value={type} /> 20 + <ButtonGroup class="w-full"> 21 + <Input 22 + name="q" 23 + type="search" 24 + bind:value={query} 25 + {placeholder} 26 + iconLeft={Search} 27 + class={mono ? "flex-1 font-mono" : "flex-1"} 28 + /> 29 + <Button type="submit" icon={Search} aria-label="Search" /> 30 + </ButtonGroup> 31 + </form>
+69
web/src/lib/components/search/SearchSidebar.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from "svelte"; 3 + import { LANGUAGE_COLORS, LANGUAGE_COLOR_FALLBACK } from "$lib/components/repo/language-colors"; 4 + import SearchTabs from "./SearchTabs.svelte"; 5 + 6 + const LANGUAGES = [ 7 + "Go", 8 + "JavaScript", 9 + "TypeScript", 10 + "Python", 11 + "Rust", 12 + "OCaml", 13 + "Haskell", 14 + "C", 15 + "C++", 16 + "Ruby", 17 + "Swift" 18 + ]; 19 + 20 + interface Props { 21 + query: string; 22 + active: string; 23 + /** tab-specific result metadata, rendered under the filters */ 24 + children?: Snippet; 25 + } 26 + 27 + let { query = $bindable(), active, children }: Props = $props(); 28 + 29 + const addLang = (lang: string) => { 30 + const token = `lang:${lang}`; 31 + if (!query.split(/\s+/).includes(token)) query = `${token} ${query}`.trim(); 32 + }; 33 + const langColor = (lang: string) => LANGUAGE_COLORS[lang] ?? LANGUAGE_COLOR_FALLBACK; 34 + </script> 35 + 36 + <div class="col-span-1 hidden md:sticky md:top-2 md:block md:self-start"> 37 + <div class="flex flex-col gap-6 px-2 md:px-0"> 38 + <div> 39 + <h3 class="mb-3 text-sm font-semibold text-foreground-muted">Filter by</h3> 40 + <SearchTabs {query} {active} /> 41 + </div> 42 + 43 + <div> 44 + <h3 class="mb-3 text-sm font-semibold text-foreground-muted">Languages</h3> 45 + <div class="flex flex-wrap gap-2"> 46 + {#each LANGUAGES as lang (lang)} 47 + <button 48 + type="button" 49 + onclick={() => addLang(lang)} 50 + class="flex w-fit shrink-0 items-center gap-2 rounded border border-border-default bg-background-default px-2 py-1 text-sm text-foreground-default transition-colors hover:bg-background-subtle" 51 + > 52 + <span 53 + class="inline-block size-2.5 shrink-0 rounded-full" 54 + style={`background-color: ${langColor(lang)}`} 55 + ></span> 56 + <span>{lang}</span> 57 + </button> 58 + {/each} 59 + </div> 60 + <p class="mt-3 text-xs text-foreground-subtle"> 61 + Click a language to add it to your query, then search. You can also type 62 + <code class="rounded bg-background-inset px-1 py-0.5 text-xs">lang:name</code> 63 + into the search bar yourself. 64 + </p> 65 + </div> 66 + 67 + {@render children?.()} 68 + </div> 69 + </div>
+24
web/src/lib/components/search/SearchTabs.svelte
··· 1 + <script module lang="ts"> 2 + import { resolve } from "$app/paths"; 3 + 4 + export const searchUrl = (type: string, q: string) => 5 + resolve(`/search?type=${type}&q=${encodeURIComponent(q)}` as "/"); 6 + </script> 7 + 8 + <script lang="ts"> 9 + import Tabs, { type TabDef } from "$lib/components/ui/Tabs.svelte"; 10 + 11 + interface Props { 12 + query: string; 13 + active: string; 14 + } 15 + 16 + let { query, active }: Props = $props(); 17 + 18 + const tabs = $derived<TabDef[]>([ 19 + { id: "repo", label: "Repositories", href: searchUrl("repo", query) }, 20 + { id: "code", label: "Code", href: searchUrl("code", query) } 21 + ]); 22 + </script> 23 + 24 + <Tabs {tabs} {active} label="Search type" vertical />
+69
web/src/lib/components/search/chunks.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { chunkLines, matchCount } from "./chunks"; 3 + 4 + /** compact view of a line: line number + its spans as "plain" / "[match]" */ 5 + const shape = (chunk: Parameters<typeof chunkLines>[0]) => 6 + chunkLines(chunk).map((l) => [l.num, l.spans.map((s) => (s.match ? `[${s.text}]` : s.text))]); 7 + 8 + describe("chunkLines", () => { 9 + it("numbers lines from lineStart and splits each into plain/matched spans", () => { 10 + // "main" at bytes 5-9, "main" again at 21-25 11 + const content = "func main() {\n\tcallMain()\n}\n"; 12 + expect(shape({ content, lineStart: 12, highlights: [{ start: 5, end: 9 }] })).toEqual([ 13 + [12, ["func ", "[main]", "() {"]], 14 + [13, ["\tcallMain()"]], 15 + [14, ["}"]] 16 + ]); 17 + }); 18 + 19 + it("clips a highlight that spans a newline to each line it covers", () => { 20 + // bytes 2-8 cover "c\nde" — "b\n" is at 1-3 21 + const content = "abc\ndef"; 22 + expect(shape({ content, lineStart: 1, highlights: [{ start: 2, end: 6 }] })).toEqual([ 23 + [1, ["ab", "[c]"]], 24 + [2, ["[de]", "f"]] 25 + ]); 26 + }); 27 + 28 + it("uses byte offsets, not UTF-16 indices", () => { 29 + // "héllo wörld": h(1) é(2) l l o space w -> "wörld" starts at byte 7 30 + const content = "héllo wörld"; 31 + expect(shape({ content, lineStart: 1, highlights: [{ start: 7, end: 13 }] })).toEqual([ 32 + [1, ["héllo ", "[wörld]"]] 33 + ]); 34 + }); 35 + 36 + it("emits an empty span list for a blank line and no spurious trailing line", () => { 37 + expect(shape({ content: "a\n\nb\n", lineStart: 3 })).toEqual([ 38 + [3, ["a"]], 39 + [4, []], 40 + [5, ["b"]] 41 + ]); 42 + }); 43 + 44 + it("merges overlapping and adjacent highlights", () => { 45 + const content = "abcdef"; 46 + expect( 47 + shape({ 48 + content, 49 + lineStart: 1, 50 + highlights: [ 51 + { start: 3, end: 5 }, 52 + { start: 1, end: 3 }, 53 + { start: 4, end: 6 } 54 + ] 55 + }) 56 + ).toEqual([[1, ["a", "[bcdef]"]]]); 57 + }); 58 + }); 59 + 60 + describe("matchCount", () => { 61 + it("sums highlights across chunks, counting a missing list as zero", () => { 62 + expect( 63 + matchCount([ 64 + { content: "a", lineStart: 1, highlights: [{ start: 0, end: 1 }] }, 65 + { content: "b", lineStart: 2 } 66 + ]) 67 + ).toBe(1); 68 + }); 69 + });
+87
web/src/lib/components/search/chunks.ts
··· 1 + import type { Chunk, Highlight } from "./types"; 2 + 3 + export interface Span { 4 + text: string; 5 + match?: boolean; 6 + } 7 + 8 + export interface Line { 9 + num: number; 10 + /** empty for a blank line */ 11 + spans: Span[]; 12 + /** true when any part of this line matched */ 13 + highlight: boolean; 14 + } 15 + 16 + const encoder = new TextEncoder(); 17 + const decoder = new TextDecoder(); 18 + 19 + /** sorts and merges overlapping or adjacent ranges */ 20 + function mergeRanges(highlights: Highlight[]): Highlight[] { 21 + const sorted = highlights.filter((h) => h.end > h.start).sort((a, b) => a.start - b.start); 22 + const out: Highlight[] = []; 23 + for (const h of sorted) { 24 + const last = out.at(-1); 25 + if (last && h.start <= last.end) { 26 + last.end = Math.max(last.end, h.end); 27 + } else { 28 + out.push({ ...h }); 29 + } 30 + } 31 + return out; 32 + } 33 + 34 + function lineAt( 35 + bytes: Uint8Array, 36 + start: number, 37 + end: number, 38 + num: number, 39 + ranges: Highlight[] 40 + ): Line { 41 + const slice = (from: number, to: number) => decoder.decode(bytes.subarray(from, to)); 42 + const spans: Span[] = []; 43 + let highlight = false; 44 + let pos = start; 45 + 46 + // ranges are sorted, so walking them once keeps `pos` monotonic 47 + for (const r of ranges) { 48 + const s = Math.max(r.start, start); 49 + const e = Math.min(r.end, end); 50 + if (s >= e) continue; 51 + highlight = true; 52 + if (s > pos) spans.push({ text: slice(pos, s) }); 53 + spans.push({ text: slice(s, e), match: true }); 54 + pos = e; 55 + } 56 + if (pos < end) spans.push({ text: slice(pos, end) }); 57 + 58 + return { num, spans, highlight }; 59 + } 60 + 61 + /** 62 + * Renders a chunk's content into per-line spans for display. 63 + * 64 + * Highlights are byte offsets into `content` and a single range may span 65 + * newlines, so the split happens in byte space — UTF-16 string indices would 66 + * skew on any line containing non-ASCII. Inverse of `chunkHighlights` in 67 + * appview/xrpc/search.go. 68 + */ 69 + export function chunkLines(chunk: Chunk): Line[] { 70 + const ranges = mergeRanges(chunk.highlights ?? []); 71 + // trim a single trailing newline so we don't emit a spurious empty line 72 + const bytes = encoder.encode(chunk.content.replace(/\n$/, "")); 73 + const firstNum = Math.max(1, chunk.lineStart); 74 + 75 + const lines: Line[] = []; 76 + let start = 0; 77 + for (let i = 0; i <= bytes.length; i++) { 78 + if (i < bytes.length && bytes[i] !== 0x0a) continue; 79 + lines.push(lineAt(bytes, start, i, firstNum + lines.length, ranges)); 80 + start = i + 1; 81 + } 82 + return lines; 83 + } 84 + 85 + /** total match count across a file result's chunks, for the stats line */ 86 + export const matchCount = (chunks: Chunk[]) => 87 + chunks.reduce((n, c) => n + (c.highlights?.length ?? 0), 0);
+129
web/src/lib/components/search/tabs/CodeSearchTab.svelte
··· 1 + <script lang="ts"> 2 + import { resolve } from "$app/paths"; 3 + import { page } from "$app/state"; 4 + import { createAppviewClient } from "$lib/api/appview"; 5 + import { errorMessage } from "$lib/api/load"; 6 + import { searchCode } from "$lib/api/search"; 7 + import { getAuth } from "$lib/auth.svelte"; 8 + import { loginWithReturn } from "$lib/auth/guards"; 9 + import EmptyState from "$lib/components/ui/EmptyState.svelte"; 10 + import ErrorBox from "$lib/components/ui/Error.svelte"; 11 + import Spinner from "$lib/components/ui/Spinner.svelte"; 12 + import CodeResultCard from "../CodeResultCard.svelte"; 13 + import SearchBar from "../SearchBar.svelte"; 14 + import SearchSidebar from "../SearchSidebar.svelte"; 15 + import { searchUrl } from "../SearchTabs.svelte"; 16 + import { matchCount } from "../chunks"; 17 + import type { CodeResult } from "../types"; 18 + 19 + const PAGE_LIMIT = 50; 20 + 21 + interface Props { 22 + /** the committed query from the url, not the search box's draft value */ 23 + query: string; 24 + } 25 + 26 + let { query }: Props = $props(); 27 + 28 + const auth = getAuth(); 29 + 30 + let results = $state<CodeResult[]>([]); 31 + let requesting = $state(false); 32 + let error = $state<string | null>(null); 33 + 34 + const apiUrl = $derived(page.data.publicConfig?.apiUrl as string | undefined); 35 + 36 + const signedIn = $derived(auth.currentDid !== null); 37 + const waitingForAgent = $derived(signedIn && auth.agent === null); 38 + const loading = $derived(requesting || (waitingForAgent && query.trim() !== "")); 39 + 40 + // fetch results on page load 41 + $effect(() => { 42 + const q = query.trim(); 43 + const agent = auth.agent; 44 + const service = apiUrl; 45 + 46 + // no agent is idle, not a failure: the request simply waits for one 47 + if (!q || !agent || !service) { 48 + results = []; 49 + requesting = false; 50 + error = null; 51 + return; 52 + } 53 + 54 + const controller = new AbortController(); 55 + const { signal } = controller; 56 + requesting = true; 57 + error = null; 58 + 59 + searchCode( 60 + createAppviewClient({ apiUrl: service, agent }), 61 + { q, limit: PAGE_LIMIT }, 62 + { signal } 63 + ) 64 + .then((found) => { 65 + if (signal.aborted) return; 66 + results = found.results; 67 + }) 68 + .catch((cause: unknown) => { 69 + if (signal.aborted) return; 70 + results = []; 71 + error = errorMessage(cause, "Could not search code."); 72 + }) 73 + .finally(() => { 74 + if (signal.aborted) return; 75 + requesting = false; 76 + }); 77 + 78 + return () => controller.abort(); 79 + }); 80 + 81 + let draft = $derived(query); 82 + const loginHref = $derived(resolve(loginWithReturn(searchUrl("code", query)) as "/login")); 83 + const matches = $derived(results.reduce((n, r) => n + matchCount(r.chunks), 0)); 84 + </script> 85 + 86 + <svelte:head> 87 + <title>Code Search &middot; Tangled</title> 88 + </svelte:head> 89 + 90 + <div class="mx-auto flex w-full max-w-screen-lg flex-col gap-4 px-4 py-8"> 91 + <h1 class="mb-4 px-2 text-2xl font-semibold">Code Search</h1> 92 + <div class="grid grid-cols-1 gap-4 px-2 md:grid-cols-4"> 93 + <div class="col-span-1 space-y-4 md:col-span-3"> 94 + <SearchBar type="code" bind:query={draft} placeholder="Search code..." mono /> 95 + 96 + {#if !signedIn} 97 + <EmptyState message="Code search needs an account."> 98 + <a href={loginHref} class="underline">Login</a> 99 + </EmptyState> 100 + {:else if !query} 101 + <EmptyState message="Enter a query to search code." /> 102 + {:else if loading} 103 + <div class="flex justify-center p-12 text-foreground-subtle"> 104 + <Spinner class="size-6" /> 105 + </div> 106 + {:else if error} 107 + <ErrorBox label={error} /> 108 + {:else} 109 + <div class="grid grid-cols-1 gap-4"> 110 + {#each results as result (`${result.ownerHandle}/${result.repoName}/${result.path}`)} 111 + <CodeResultCard {result} /> 112 + {:else} 113 + <EmptyState message="No results found." /> 114 + {/each} 115 + </div> 116 + {/if} 117 + </div> 118 + 119 + <SearchSidebar bind:query={draft} active="code"> 120 + {#if matches > 0} 121 + <div class="border-t border-border-default py-2 text-xs text-foreground-muted"> 122 + Found {matches} 123 + {matches === 1 ? "match" : "matches"} in {results.length} 124 + {results.length === 1 ? "file" : "files"} 125 + </div> 126 + {/if} 127 + </SearchSidebar> 128 + </div> 129 + </div>
+29
web/src/lib/components/search/tabs/RepoSearchTab.svelte
··· 1 + <script lang="ts"> 2 + import EmptyState from "$lib/components/ui/EmptyState.svelte"; 3 + import SearchBar from "../SearchBar.svelte"; 4 + import SearchSidebar from "../SearchSidebar.svelte"; 5 + import { searchUrl } from "../SearchTabs.svelte"; 6 + 7 + let { query }: { query: string } = $props(); 8 + 9 + let draft = $derived(query); 10 + </script> 11 + 12 + <svelte:head> 13 + <title>Search &middot; Tangled</title> 14 + </svelte:head> 15 + 16 + <div class="mx-auto flex w-full max-w-screen-lg flex-col gap-4 px-4 py-8"> 17 + <h1 class="mb-4 px-2 text-2xl font-semibold">Search</h1> 18 + <div class="grid grid-cols-1 gap-4 px-2 md:grid-cols-4"> 19 + <div class="col-span-1 space-y-4 md:col-span-3"> 20 + <SearchBar type="repo" bind:query={draft} placeholder="Search repositories..." /> 21 + 22 + <EmptyState message="Repository search is coming soon."> 23 + <a href={searchUrl("code", draft)} class="underline">Search code instead</a> 24 + </EmptyState> 25 + </div> 26 + 27 + <SearchSidebar bind:query={draft} active="repo" /> 28 + </div> 29 + </div>
+17
web/src/lib/components/search/types.ts
··· 1 + import type { FileResult } from "$lib/api/lexicons/types/org/tangled/temp/search/searchCode"; 2 + 3 + // the wire types are the generated lexicon's. `Chunk.highlights` are byte offsets 4 + // into `Chunk.content`, which is what chunks.ts splits on. 5 + export type { 6 + Chunk, 7 + FileResult, 8 + Highlight 9 + } from "$lib/api/lexicons/types/org/tangled/temp/search/searchCode"; 10 + 11 + // the lexicon returns only repoDid, so the load has to resolve owner/slug/ref for 12 + // the blob links — these three fields are that enrichment step 13 + export interface CodeResult extends FileResult { 14 + ownerHandle: string; 15 + repoName: string; 16 + ref: string; 17 + }
+1 -1
web/src/lib/oauth-client-metadata.json
··· 3 3 "client_name": "Tangled", 4 4 "client_uri": "https://tangled.org", 5 5 "redirect_uris": ["https://tangled.org/oauth/callback"], 6 - "scope": "atproto repo:sh.tangled.actor.profile 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.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.site.getDomainClaim?aud=* rpc:org.tangled.temp.site.claimDomain?aud=* rpc:org.tangled.temp.site.releaseDomain?aud=*", 6 + "scope": "atproto repo:sh.tangled.actor.profile 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.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.site.getDomainClaim?aud=* rpc:org.tangled.temp.site.claimDomain?aud=* rpc:org.tangled.temp.site.releaseDomain?aud=* rpc:org.tangled.temp.search.searchCode?aud=*", 7 7 "grant_types": ["authorization_code", "refresh_token"], 8 8 "response_types": ["code"], 9 9 "token_endpoint_auth_method": "none",
+1 -1
web/src/routes/+layout.svelte
··· 9 9 let { children, data } = $props(); 10 10 11 11 const auth = createAuth( 12 - data.publicConfig.bobbinUrl, 12 + untrack(() => data.publicConfig.bobbinUrl), 13 13 untrack(() => data.auth) 14 14 ); 15 15 setContext(AUTH_KEY, auth);
+13
web/src/routes/search/+page.svelte
··· 1 + <script lang="ts"> 2 + import CodeSearchTab from "$lib/components/search/tabs/CodeSearchTab.svelte"; 3 + import RepoSearchTab from "$lib/components/search/tabs/RepoSearchTab.svelte"; 4 + import type { PageData } from "./$types"; 5 + 6 + let { data }: { data: PageData } = $props(); 7 + </script> 8 + 9 + {#if data.type === "code"} 10 + <CodeSearchTab query={data.query} /> 11 + {:else} 12 + <RepoSearchTab query={data.query} /> 13 + {/if}
+8
web/src/routes/search/+page.ts
··· 1 + import type { PageLoad } from "./$types"; 2 + 3 + export const load: PageLoad = (event) => { 4 + const query = event.url.searchParams.get("q") ?? ""; 5 + const type = event.url.searchParams.get("type") === "code" ? "code" : "repo"; 6 + 7 + return { query, type }; 8 + };