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 @@