From f934389bb4afac19592833209e197eeebe456015 Mon Sep 17 00:00:00 2001 From: eti Date: Sat, 29 Aug 2026 00:57:45 +0200 Subject: [PATCH] web/{api,components}: add git blame Blame asks gitmirror first and falls back to the knot only when the mirror cannot speak for the repo (RepoNotFound) or has not caught up to the commit yet (RefNotFound). The blob page asks for the commit its lines came from instead of the ref they were requested by, so a mirror that is a push behind answers RefNotFound rather than blaming an older snapshot onto the file the knot served. Signed-off-by: eti Signed-off-by: dawn --- web/src/lib/api/blame.test.ts | 261 ++++++++++++++++++ web/src/lib/api/blame.ts | 156 +++++++++++ web/src/lib/api/gitmirror.ts | 5 + web/src/lib/api/knot.ts | 4 + web/src/lib/components/repo/BlobHeader.svelte | 21 +- web/src/lib/components/repo/BlobView.svelte | 23 +- web/src/lib/components/repo/CommitView.svelte | 19 +- .../lib/components/repo/DiffFileCard.svelte | 21 ++ web/src/lib/components/repo/DiffTopbar.svelte | 9 + web/src/lib/components/repo/DiffView.svelte | 13 + web/src/lib/components/repo/PierreDiff.svelte | 59 ++-- web/src/lib/components/repo/PierreFile.svelte | 46 ++- .../components/repo/blame/BlameChip.svelte | 78 ++++++ .../components/repo/blame/BlameOverlay.svelte | 45 +++ .../lib/components/repo/blame/hover.svelte.ts | 54 ++++ .../repo/blame/request.stories.svelte | 67 +++++ .../components/repo/blame/request.svelte.ts | 82 ++++++ .../lib/components/repo/blame/types.test.ts | 30 ++ web/src/lib/components/repo/blame/types.ts | 35 +++ .../[repo]/blob/[ref]/[...path]/+page.svelte | 7 + .../[handle]/[repo]/commit/[ref]/+page.svelte | 4 + 21 files changed, 1007 insertions(+), 32 deletions(-) create mode 100644 web/src/lib/api/blame.test.ts create mode 100644 web/src/lib/api/blame.ts create mode 100644 web/src/lib/components/repo/blame/BlameChip.svelte create mode 100644 web/src/lib/components/repo/blame/BlameOverlay.svelte create mode 100644 web/src/lib/components/repo/blame/hover.svelte.ts create mode 100644 web/src/lib/components/repo/blame/request.stories.svelte create mode 100644 web/src/lib/components/repo/blame/request.svelte.ts create mode 100644 web/src/lib/components/repo/blame/types.test.ts create mode 100644 web/src/lib/components/repo/blame/types.ts diff --git a/web/src/lib/api/blame.test.ts b/web/src/lib/api/blame.test.ts new file mode 100644 index 000000000..e9ad86a89 --- /dev/null +++ b/web/src/lib/api/blame.test.ts @@ -0,0 +1,261 @@ +import type { ResourceUri } from "@atcute/lexicons"; +import type { Did } from "@atcute/lexicons/syntax"; +import { describe, expect, it, vi } from "vitest"; +import { blameFor, hydrateBlameAuthors } from "$lib/api/blame"; +import { createBobbinClient } from "$lib/api/client"; + +// bobbin mints a repo did that is not the owner's: the mirror is keyed by the +// former, the knot by the record's at-uri +const REPO = "at://did:plc:owner/sh.tangled.repo/core" as ResourceUri; +const REPO_DID = "did:plc:repo" as Did; + +const jsonResponse = (body: unknown): Response => + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" } + }); + +const errorResponse = (status: number, error: string): Response => + new Response(JSON.stringify({ error }), { + status, + headers: { "content-type": "application/json" } + }); + +const mirrorResponse = (body: { + commits: Array>; + runs: Array>; +}): Response => jsonResponse({ commit: "abc123", ...body }); + +const makeCtx = (fetchMock: typeof globalThis.fetch) => + createBobbinClient({ serviceUrl: "https://bobbin.example", fetch: fetchMock }); + +const blameCall = (ctx: ReturnType) => + blameFor(ctx, { repo: REPO, repoDid: REPO_DID }, "main", "src/main.rs"); + +describe("blameFor", () => { + it("requests gitmirror getBlame and maps its one-based runs", async () => { + const fetchMock = vi.fn().mockResolvedValue( + mirrorResponse({ + commits: [ + { + oid: "abc123", + message: "add main\n\nlonger explanation", + author: { + name: "dawn", + email: "did:plc:dawn", + when: "2026-06-01T12:30:00Z" + } + } + ], + runs: [{ start: 1, count: 3, commit: "abc123" }] + }) + ); + const ctx = makeCtx(fetchMock); + + await expect(blameCall(ctx)).resolves.toEqual([ + { + start: 1, + count: 3, + commit: { + sha: "abc123", + subject: "add main", + when: "2026-06-01T12:30:00Z", + author: { name: "dawn", email: "did:plc:dawn", did: "did:plc:dawn" } + } + } + ]); + + const url = new URL(String(fetchMock.mock.calls[0][0])); + expect(url.pathname).toBe("/xrpc/sh.tangled.git.temp2.getBlame"); + expect(url.searchParams.get("repo")).toBe(REPO_DID); + expect(url.searchParams.get("ref")).toBe("main"); + expect(url.searchParams.get("path")).toBe("src/main.rs"); + }); + + it("keeps only the subject of the mirror's full commit message", async () => { + const fetchMock = vi.fn().mockResolvedValue( + mirrorResponse({ + commits: [ + { + oid: "abc123", + message: "subject line\n\nbody", + author: { + name: "dawn", + email: "dawn@example.com", + when: "2026-06-01T12:30:00Z" + } + } + ], + runs: [{ start: 1, count: 1, commit: "abc123" }] + }) + ); + const ctx = makeCtx(fetchMock); + + const runs = await blameCall(ctx); + expect(runs[0].commit.subject).toBe("subject line"); + expect(runs[0].commit.author.did).toBeUndefined(); + }); + + // a repo the mirror doesn't have, and a commit it hasn't fetched yet, both mean "ask the knot" + it.each(["RepoNotFound", "RefNotFound"])( + "falls back to the knot when the mirror answers %s", + async (miss) => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(errorResponse(404, miss)) + .mockResolvedValueOnce( + jsonResponse({ + commit: "abc123", + commits: [ + { + sha: "abc123", + subject: "add main", + authoredAt: "2026-06-01T12:30:00Z", + author: { name: "dawn", email: "did:plc:dawn" } + } + ], + runs: [{ start: 1, count: 3, commit: "abc123" }] + }) + ); + const ctx = makeCtx(fetchMock); + + await expect(blameCall(ctx)).resolves.toEqual([ + { + start: 1, + count: 3, + commit: { + sha: "abc123", + subject: "add main", + when: "2026-06-01T12:30:00Z", + author: { name: "dawn", email: "did:plc:dawn", did: "did:plc:dawn" } + } + } + ]); + + const urls = fetchMock.mock.calls.map((call) => new URL(String(call[0])).pathname); + expect(urls).toEqual([ + "/xrpc/sh.tangled.git.temp2.getBlame", + "/xrpc/sh.tangled.repo.blame" + ]); + const knotUrl = new URL(String(fetchMock.mock.calls[1][0])); + expect(knotUrl.searchParams.get("repo")).toBe(REPO); + } + ); + + // bobbin never indexed the repo, so there is no did to ask the mirror with + it("goes straight to the knot without a repo did", async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + commit: "abc123", + commits: [ + { + sha: "abc123", + subject: "add main", + authoredAt: "2026-06-01T12:30:00Z", + author: { name: "dawn", email: "did:plc:dawn" } + } + ], + runs: [{ start: 1, count: 3, commit: "abc123" }] + }) + ); + const ctx = makeCtx(fetchMock); + + const runs = await blameFor(ctx, { repo: REPO }, "main", "src/main.rs"); + expect(runs[0].commit.sha).toBe("abc123"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(new URL(String(fetchMock.mock.calls[0][0])).pathname).toBe( + "/xrpc/sh.tangled.repo.blame" + ); + }); + + it("propagates mirror errors that are not a mirror miss", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(errorResponse(500, "InternalError")); + const ctx = makeCtx(fetchMock); + + await expect(blameCall(ctx)).rejects.toThrow("InternalError"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("rejects duplicate commit metadata", async () => { + const commit = { + oid: "abc123", + message: "add main", + author: { name: "dawn", email: "dawn@example.com", when: "2026-06-01T12:30:00Z" } + }; + const fetchMock = vi.fn().mockResolvedValue( + mirrorResponse({ + commits: [commit, commit], + runs: [{ start: 1, count: 1, commit: "abc123" }] + }) + ); + const ctx = makeCtx(fetchMock); + + await expect(blameCall(ctx)).rejects.toThrow("duplicate blame commit: abc123"); + }); + + it("rejects runs that reference missing commits", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + mirrorResponse({ commits: [], runs: [{ start: 1, count: 1, commit: "abc123" }] }) + ); + const ctx = makeCtx(fetchMock); + + await expect(blameCall(ctx)).rejects.toThrow("unknown blame commit: abc123"); + }); + + it("rejects non-contiguous run tables", async () => { + const fetchMock = vi.fn().mockResolvedValue( + mirrorResponse({ + commits: [ + { + oid: "abc123", + message: "add main", + author: { + name: "dawn", + email: "dawn@example.com", + when: "2026-06-01T12:30:00Z" + } + } + ], + runs: [{ start: 2, count: 1, commit: "abc123" }] + }) + ); + const ctx = makeCtx(fetchMock); + + await expect(blameCall(ctx)).rejects.toThrow("invalid blame run at line 2"); + }); +}); + +describe("hydrateBlameAuthors", () => { + it("bounds identity lookups for large blame results", async () => { + const blame = Array.from({ length: 300 }, (_, index) => ({ + start: index + 1, + count: 1, + commit: { + sha: `sha-${index}`, + subject: `commit ${index}`, + when: "2026-06-01T12:30:00Z", + author: { + name: `author ${index}`, + email: `did:plc:blameauthor${index}`, + did: `did:plc:blameauthor${index}` + } + } + })); + const fetchMock = vi.fn(async (input) => { + const url = new URL(String(input)); + const did = url.searchParams.get("identifier")!; + return jsonResponse({ did, handle: `${did.slice("did:plc:".length)}.example` }); + }); + + const ctx = createBobbinClient({ serviceUrl: "https://bobbin.example", fetch: fetchMock }); + const hydrated = await hydrateBlameAuthors(blame, ctx); + + expect(fetchMock).toHaveBeenCalledTimes(250); + expect(hydrated[249].commit.author.handle).toBe("blameauthor249.example"); + expect(hydrated[250].commit.author.handle).toBeUndefined(); + }); +}); diff --git a/web/src/lib/api/blame.ts b/web/src/lib/api/blame.ts new file mode 100644 index 000000000..efc285c4c --- /dev/null +++ b/web/src/lib/api/blame.ts @@ -0,0 +1,156 @@ +import type { ResourceUri } from "@atcute/lexicons"; +import type { Did } from "@atcute/lexicons/syntax"; +import { ClientResponseError, type BobbinContext, type XrpcRequestInit } from "$lib/api/client"; +import { resolveEmailToDid } from "$lib/api/emailDid"; +import { getBlame } from "$lib/api/gitmirror"; +import { resolveMiniDoc } from "$lib/api/identity"; +import { blame as knotBlame } from "$lib/api/knot"; +import { didFromSignature, splitMessage } from "$lib/api/repo"; +import type * as GetBlame from "$lib/api/lexicons/types/sh/tangled/git/temp2/getBlame"; +import type * as KnotBlame from "$lib/api/lexicons/types/sh/tangled/repo/blame"; +import type { Blame, BlameCommit } from "$lib/components/repo/blame/types"; + +type RunTable = { start: number; count: number; commit: string }; + +// knot and mirror payloads share the run-table contract: commits must be unique +// and runs must be a gap-free, sorted cover of the file starting at line 1 +const toBlame = (commits: readonly BlameCommit[], runs: readonly RunTable[]): Blame => { + const byShort = new Map(); + for (const commit of commits) { + if (byShort.has(commit.sha)) throw new Error(`duplicate blame commit: ${commit.sha}`); + byShort.set(commit.sha, commit); + } + const blame: Blame = []; + let nextLine = 1; + for (const run of runs) { + if (run.start !== nextLine || run.count < 1 || !Number.isSafeInteger(run.count)) { + throw new Error(`invalid blame run at line ${run.start}`); + } + const commit = byShort.get(run.commit); + if (!commit) throw new Error(`unknown blame commit: ${run.commit}`); + blame.push({ start: run.start, count: run.count, commit }); + nextLine += run.count; + } + return blame; +}; + +// a did in the author slot is a tangled account signing its own commits +const toAuthor = ({ name, email }: { name: string; email: string }) => ({ + name, + email, + did: didFromSignature(email) +}); + +const fromKnot = (result: KnotBlame.$output): Blame => + toBlame( + result.commits.map((commit) => ({ + sha: commit.sha, + subject: commit.subject, + when: commit.authoredAt, + author: toAuthor(commit.author) + })), + result.runs + ); + +const fromMirror = (result: GetBlame.$output): Blame => + toBlame( + result.commits.map((commit) => ({ + sha: commit.oid, + subject: splitMessage(commit.message)[0], + when: commit.author.when, + author: toAuthor(commit.author) + })), + result.runs + ); + +// the mirror only speaks for what it has fetched: a repo it doesn't mirror is +// RepoNotFound, a commit it hasn't caught up to is RefNotFound, and the knot can +// still answer both +const mirrorMiss = (cause: unknown): boolean => + cause instanceof ClientResponseError && + (cause.error === "RepoNotFound" || cause.error === "RefNotFound"); + +// the mirror keys repos by bobbin's repo did, the knot by the record's at-uri, so +// blame needs both names for the same repo +export const blameFor = async ( + ctx: BobbinContext, + repo: { repo: ResourceUri; repoDid?: Did }, + ref: string, + path: string, + init?: XrpcRequestInit +): Promise => { + if (repo.repoDid) { + try { + return fromMirror(await getBlame(ctx, { repo: repo.repoDid, ref, path }, init)); + } catch (cause) { + if (!mirrorMiss(cause)) throw cause; + } + } + return fromKnot(await knotBlame(ctx, { repo: repo.repo, ref, path }, init)); +}; + +const MAX_AUTHOR_HYDRATIONS = 250; +const AUTHOR_HYDRATION_CONCURRENCY = 8; + +const mapConcurrent = async ( + items: T[], + concurrency: number, + fn: (item: T) => Promise +): Promise => { + const results = new Array(items.length); + let next = 0; + await Promise.all( + Array.from({ length: Math.min(concurrency, items.length) }, async () => { + while (next < items.length) { + const index = next++; + results[index] = await fn(items[index]); + } + }) + ); + return results; +}; + +export const hydrateBlameAuthors = async (blame: Blame, ctx: BobbinContext): Promise => { + const emails = [ + ...new Set( + blame.flatMap(({ commit }) => + commit.author.did || !commit.author.email ? [] : [commit.author.email] + ) + ) + ].slice(0, MAX_AUTHOR_HYDRATIONS); + const emailDids = await resolveEmailToDid(ctx.fetch, emails); + const didFor = (email: string): string | undefined => + email.startsWith("did:") ? email : emailDids.get(email); + const dids = [ + ...new Set( + blame.flatMap(({ commit }) => { + const did = commit.author.did ?? didFor(commit.author.email); + return did ? [did] : []; + }) + ) + ].slice(0, MAX_AUTHOR_HYDRATIONS); + const docs = await mapConcurrent( + dids, + AUTHOR_HYDRATION_CONCURRENCY, + async (did) => [did, await resolveMiniDoc(ctx, did).catch(() => null)] as const + ); + const handles = new Map( + docs.flatMap(([did, doc]) => + doc && !doc.handle.endsWith(".invalid") ? [[did, doc.handle] as const] : [] + ) + ); + return blame.map((run) => { + const did = run.commit.author.did ?? didFor(run.commit.author.email); + return { + ...run, + commit: { + ...run.commit, + author: { + ...run.commit.author, + did, + handle: did ? handles.get(did) : undefined + } + } + }; + }); +}; diff --git a/web/src/lib/api/gitmirror.ts b/web/src/lib/api/gitmirror.ts index 0856d589c..186471b1f 100644 --- a/web/src/lib/api/gitmirror.ts +++ b/web/src/lib/api/gitmirror.ts @@ -11,6 +11,7 @@ import type { Did } from "@atcute/lexicons/syntax"; import type { BobbinContext, XrpcRequestInit } from "$lib/api/client"; import type { Revspec } from "$lib/api/pullRoute"; import type * as ShTangledGitDefs from "$lib/api/lexicons/types/sh/tangled/git/defs"; +import type * as GetBlame from "$lib/api/lexicons/types/sh/tangled/git/temp2/getBlame"; import type * as GetDiff from "$lib/api/lexicons/types/sh/tangled/git/temp2/getDiff"; import type * as GetInterdiff from "$lib/api/lexicons/types/sh/tangled/git/temp2/getInterdiff"; import type * as ListCommits from "$lib/api/lexicons/types/sh/tangled/git/temp2/listCommits"; @@ -21,8 +22,12 @@ import type * as MergeCheck from "$lib/api/lexicons/types/sh/tangled/git/temp2/m const LIST_COMMITS_NSID = "sh.tangled.git.temp2.listCommits"; const GET_DIFF_NSID = "sh.tangled.git.temp2.getDiff"; const GET_INTERDIFF_NSID = "sh.tangled.git.temp2.getInterdiff"; +const GET_BLAME_NSID = "sh.tangled.git.temp2.getBlame"; const MERGE_CHECK_NSID = "sh.tangled.git.temp2.mergeCheck"; +export const getBlame = (ctx: BobbinContext, params: GetBlame.$params, init?: XrpcRequestInit) => + jsonGet(ctx, GET_BLAME_NSID, params, init); + export type MirrorCommit = ListCommits.Commit; export const listCommits = ( diff --git a/web/src/lib/api/knot.ts b/web/src/lib/api/knot.ts index 50391d895..01632e74c 100644 --- a/web/src/lib/api/knot.ts +++ b/web/src/lib/api/knot.ts @@ -1,6 +1,7 @@ import type { BobbinContext, QueryValue, XrpcRequestInit } from "$lib/api/client"; import { buildUrl, jsonGet, rawGet } from "$lib/api/_request"; import type * as Archive from "$lib/api/lexicons/types/sh/tangled/repo/archive"; +import type * as Blame from "$lib/api/lexicons/types/sh/tangled/repo/blame"; import type * as Blob_ from "$lib/api/lexicons/types/sh/tangled/repo/blob"; import type * as Branch from "$lib/api/lexicons/types/sh/tangled/repo/branch"; import type * as Branches from "$lib/api/lexicons/types/sh/tangled/repo/branches"; @@ -26,6 +27,9 @@ export const tree = (ctx: BobbinContext, params: Tree.$params, init?: XrpcReques export const blob = (ctx: BobbinContext, params: Blob_.$params, init?: XrpcRequestInit) => jsonGet(ctx, "sh.tangled.repo.blob", asParams(params), init); +export const blame = (ctx: BobbinContext, params: Blame.$params, init?: XrpcRequestInit) => + jsonGet(ctx, "sh.tangled.repo.blame", asParams(params), init); + export const branch = (ctx: BobbinContext, params: Branch.$params, init?: XrpcRequestInit) => jsonGet(ctx, "sh.tangled.repo.branch", asParams(params), init); diff --git a/web/src/lib/components/repo/BlobHeader.svelte b/web/src/lib/components/repo/BlobHeader.svelte index 85b23b972..78bb235b8 100644 --- a/web/src/lib/components/repo/BlobHeader.svelte +++ b/web/src/lib/components/repo/BlobHeader.svelte @@ -29,6 +29,10 @@ view?: BlobViewMode; wrap?: boolean; showWrap?: boolean; + showBlame?: boolean; + blame?: boolean; + blameLoading?: boolean; + blameError?: string | null; copyText?: string | null; } @@ -44,6 +48,10 @@ view = $bindable("code"), wrap = $bindable(false), showWrap = false, + showBlame = false, + blame = $bindable(false), + blameLoading = false, + blameError = null, copyText = null }: Props = $props(); @@ -53,6 +61,7 @@ const showLines = $derived(lines !== null && view === "code"); const showWrapToggle = $derived(showWrap && hasTextView(kind) && view === "code"); + const showBlameToggle = $derived(showBlame && hasTextView(kind) && view === "code"); const languageColor = $derived( language ? (LANGUAGE_COLORS[language] ?? LANGUAGE_COLOR_FALLBACK) : null ); @@ -97,8 +106,18 @@ {#if kind !== "submodule"}
- {#if showWrapToggle || hasRenderedView(kind)} + {#if showWrapToggle || showBlameToggle || hasRenderedView(kind)}
+ {#if showBlameToggle} + + {blameLoading ? "Blame…" : "Blame"} + + {/if} {#if showWrapToggle} Wrap diff --git a/web/src/lib/components/repo/BlobView.svelte b/web/src/lib/components/repo/BlobView.svelte index 1ca2727e8..631defced 100644 --- a/web/src/lib/components/repo/BlobView.svelte +++ b/web/src/lib/components/repo/BlobView.svelte @@ -6,6 +6,7 @@ import LastCommitPanel from "$lib/components/repo/LastCommitPanel.svelte"; import PierreFile from "$lib/components/repo/PierreFile.svelte"; import { baseName, rawBlobHref } from "$lib/components/repo/urls"; + import { createBlame, type BlameSource } from "$lib/components/repo/blame/request.svelte"; import TabPanel from "$lib/components/ui/TabPanel.svelte"; interface Props { @@ -16,9 +17,10 @@ prerenderedHTML?: string; // enry language name override, defaults to the extension guess language?: string | null; + blameSource?: BlameSource; } - let { ownerHandle, repoName, blob, prerenderedHTML, language }: Props = $props(); + let { ownerHandle, repoName, blob, prerenderedHTML, language, blameSource }: Props = $props(); const rawHref = $derived(rawBlobHref(ownerHandle, repoName, blob.ref, blob.path)); const fileName = $derived(baseName(blob.path)); @@ -57,6 +59,16 @@ }); let wrap = $state(false); + let blame = $state(false); + // blame the commit these lines came from, not the branch name they were requested by: the + // mirror can be a push behind the knot that served the file, and an immutable sha makes it + // answer RefNotFound instead of blaming an older snapshot onto the rendered lines + const blameRequest = createBlame({ + enabled: () => blame, + source: () => blameSource, + ref: () => blob.lastCommit?.hash ?? blob.ref, + path: () => blob.path + }); const textViewActive = $derived( hasTextView(blob.kind) && view === "code" && !blob.fileTooLarge @@ -108,10 +120,18 @@ lines={blob.lines} bind:view bind:wrap + bind:blame {showWrap} + showBlame={blameSource !== undefined} + blameLoading={blameRequest.loading} + blameError={blameRequest.error} copyText={blob.contents} /> + {#if blameRequest.error} + + {/if} + {#if blob.lastCommit} {/if} @@ -160,6 +180,7 @@ {wrap} {prerenderedHTML} {onPostRender} + blame={blame ? blameRequest.runs : undefined} />
{:else} diff --git a/web/src/lib/components/repo/CommitView.svelte b/web/src/lib/components/repo/CommitView.svelte index d54debf14..63ef92ae8 100644 --- a/web/src/lib/components/repo/CommitView.svelte +++ b/web/src/lib/components/repo/CommitView.svelte @@ -1,4 +1,6 @@ @@ -57,6 +71,9 @@ {/snippet} {#snippet actions()} + {#if blameRequest.loading} + Loading blame… + {/if} {#if blobUrl} +

+ {request.loading + ? "loading" + : (request.error ?? + (request.runs === undefined ? "idle" : `loaded:${request.runs.length}`))} +

+ diff --git a/web/src/lib/components/repo/blame/request.svelte.ts b/web/src/lib/components/repo/blame/request.svelte.ts new file mode 100644 index 000000000..9ef4e275c --- /dev/null +++ b/web/src/lib/components/repo/blame/request.svelte.ts @@ -0,0 +1,82 @@ +import type { ResourceUri } from "@atcute/lexicons"; +import type { Did } from "@atcute/lexicons/syntax"; +import { blameFor, hydrateBlameAuthors } from "$lib/api/blame"; +import { createBobbinClient } from "$lib/api/client"; +import { errorMessage } from "$lib/api/load"; +import type { Blame } from "$lib/components/repo/blame/types"; + +export interface BlameSource { + bobbinUrl: string; + repo: ResourceUri; + // the mirror stores repos under bobbin's repo did, not the owner's; absent + // when bobbin never indexed the repo, and then only the knot can answer + repoDid?: Did; +} + +interface BlameOptions { + enabled: () => boolean; + source: () => BlameSource | undefined; + ref: () => string | undefined; + path: () => string | undefined; +} + +export const createBlame = (options: BlameOptions) => { + let runs = $state(); + let loading = $state(false); + let error = $state(null); + let loadedKey: string | undefined; + + $effect(() => { + const enabled = options.enabled(); + const source = options.source(); + const ref = options.ref(); + const path = options.path(); + if (!enabled || !source || !ref || !path) { + loading = false; + error = null; + return; + } + const key = JSON.stringify([source.bobbinUrl, source.repo, source.repoDid, ref, path]); + if (loadedKey === key && runs !== undefined) return; + loadedKey = key; + + const controller = new AbortController(); + const { signal } = controller; + const ctx = createBobbinClient({ serviceUrl: source.bobbinUrl, fetch: globalThis.fetch }); + runs = undefined; + loading = true; + error = null; + + blameFor(ctx, source, ref, path, { signal }) + .then((found) => { + if (signal.aborted) return; + runs = found; + loading = false; + hydrateBlameAuthors(found, ctx) + .then((hydrated) => { + if (!signal.aborted) runs = hydrated; + }) + .catch(() => {}); + }) + .catch((cause: unknown) => { + if (signal.aborted) return; + runs = undefined; + loading = false; + error = errorMessage(cause, "Could not load blame."); + }); + + return () => controller.abort(); + }); + + return { + get runs() { + return runs; + }, + get loading() { + return loading; + }, + get error() { + return error; + } + }; +}; diff --git a/web/src/lib/components/repo/blame/types.test.ts b/web/src/lib/components/repo/blame/types.test.ts new file mode 100644 index 000000000..f537cd501 --- /dev/null +++ b/web/src/lib/components/repo/blame/types.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { commitForLine, type Blame } from "$lib/components/repo/blame/types"; + +const commit = (sha: string) => ({ + sha, + subject: sha, + when: "2026-06-01T12:30:00Z", + author: { name: "dawn", email: "dawn@example.com" } +}); + +const blame: Blame = [ + { start: 1, count: 2, commit: commit("aaa") }, + { start: 3, count: 4, commit: commit("bbb") }, + { start: 7, count: 1, commit: commit("ccc") } +]; + +describe("commitForLine", () => { + it("finds both ends of every one-based run", () => { + expect(commitForLine(blame, 1)?.sha).toBe("aaa"); + expect(commitForLine(blame, 2)?.sha).toBe("aaa"); + expect(commitForLine(blame, 3)?.sha).toBe("bbb"); + expect(commitForLine(blame, 6)?.sha).toBe("bbb"); + expect(commitForLine(blame, 7)?.sha).toBe("ccc"); + }); + + it("returns undefined outside the blamed lines", () => { + expect(commitForLine(blame, 0)).toBeUndefined(); + expect(commitForLine(blame, 8)).toBeUndefined(); + }); +}); diff --git a/web/src/lib/components/repo/blame/types.ts b/web/src/lib/components/repo/blame/types.ts new file mode 100644 index 000000000..01b96fc54 --- /dev/null +++ b/web/src/lib/components/repo/blame/types.ts @@ -0,0 +1,35 @@ +export interface BlameAuthor { + name: string; + email: string; + did?: string; + handle?: string; +} + +export interface BlameCommit { + sha: string; + subject: string; + when: string; + author: BlameAuthor; +} + +// one entry per run of consecutive lines sharing a commit, sorted by start +export interface BlameRun { + start: number; + count: number; + commit: BlameCommit; +} + +export type Blame = BlameRun[]; + +export const commitForLine = (blame: Blame, line: number): BlameCommit | undefined => { + let low = 0; + let high = blame.length - 1; + while (low <= high) { + const mid = (low + high) >> 1; + const run = blame[mid]; + if (line < run.start) high = mid - 1; + else if (line >= run.start + run.count) low = mid + 1; + else return run.commit; + } + return undefined; +}; diff --git a/web/src/routes/[handle]/[repo]/blob/[ref]/[...path]/+page.svelte b/web/src/routes/[handle]/[repo]/blob/[ref]/[...path]/+page.svelte index 85c0a8960..7bc1d593c 100644 --- a/web/src/routes/[handle]/[repo]/blob/[ref]/[...path]/+page.svelte +++ b/web/src/routes/[handle]/[repo]/blob/[ref]/[...path]/+page.svelte @@ -1,4 +1,6 @@