From 881f896f99de3ed66f2f22c7970bd3616fdd97fa Mon Sep 17 00:00:00 2001 From: eti Date: Mon, 31 Aug 2026 16:41:03 +0200 Subject: [PATCH] web/repo: add a file history page Signed-off-by: eti --- web/boneyard.config.json | 7 +- web/src/lib/api/fileHistory.test.ts | 64 +++ web/src/lib/api/fileHistory.ts | 133 ++++++ ...lehistoryview--skeleton-fixture.bones.json | 417 ++++++++++++++++++ .../repo/FileHistoryView.stories.svelte | 85 ++++ .../components/repo/FileHistoryView.svelte | 196 ++++++++ web/src/lib/components/repo/urls.ts | 10 + .../history/[ref]/[...path]/+page.svelte | 38 ++ .../[repo]/history/[ref]/[...path]/+page.ts | 59 +++ 9 files changed, 1008 insertions(+), 1 deletion(-) create mode 100644 web/src/lib/api/fileHistory.test.ts create mode 100644 web/src/lib/api/fileHistory.ts create mode 100644 web/src/lib/bones/repo-filehistoryview--skeleton-fixture.bones.json create mode 100644 web/src/lib/components/repo/FileHistoryView.stories.svelte create mode 100644 web/src/lib/components/repo/FileHistoryView.svelte create mode 100644 web/src/routes/[handle]/[repo]/history/[ref]/[...path]/+page.svelte create mode 100644 web/src/routes/[handle]/[repo]/history/[ref]/[...path]/+page.ts diff --git a/web/boneyard.config.json b/web/boneyard.config.json index 1b323e73a..c317709c6 100644 --- a/web/boneyard.config.json +++ b/web/boneyard.config.json @@ -37,6 +37,9 @@ "repo-commitview--full-commit": { "route": "/iframe.html?id=repo-commitview--full-commit&viewMode=story" }, + "repo-filehistoryview--skeleton-fixture": { + "route": "/iframe.html?id=repo-filehistoryview--skeleton-fixture&viewMode=story" + }, "repo-repoforkform--skeleton-fixture": { "route": "/iframe.html?id=repo-repoforkform--skeleton-fixture&viewMode=story" }, @@ -118,6 +121,8 @@ "ui-loadingprimitives--short-text": { "route": "/iframe.html?id=ui-loadingprimitives--short-text&viewMode=story" }, - "ui-toggle--checked": { "route": "/iframe.html?id=ui-toggle--checked&viewMode=story" } + "ui-toggle--checked": { + "route": "/iframe.html?id=ui-toggle--checked&viewMode=story" + } } } diff --git a/web/src/lib/api/fileHistory.test.ts b/web/src/lib/api/fileHistory.test.ts new file mode 100644 index 000000000..a78a7b8bf --- /dev/null +++ b/web/src/lib/api/fileHistory.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { authorsOf, mockFileHistory } from "./fileHistory"; +import type { CommitSummary } from "./repo"; + +const repo = "at://did:plc:o/sh.tangled.repo/abc"; +const now = new Date("2026-08-31T12:00:00Z"); + +describe("mockFileHistory", () => { + it("invents the same history for one path and a different one per path", () => { + const once = mockFileHistory(repo, "src/app.css", [], now); + const again = mockFileHistory(repo, "src/app.css", [], now); + const other = mockFileHistory(repo, "README.md", [], now); + + expect(again).toEqual(once); + expect(other).not.toEqual(once); + }); + + it("reads newest first and stays inside the sha and date shapes the view needs", () => { + const history = mockFileHistory(repo, "src/app.css", [], now); + + expect(history.length).toBeGreaterThan(0); + for (const commit of history) { + expect(commit.hash).toMatch(/^[0-9a-f]{40}$/); + expect(commit.shortHash).toBe(commit.hash.slice(0, 8)); + expect(commit.subject).not.toBe(""); + expect(new Date(commit.when).getTime()).not.toBeNaN(); + } + const times = history.map((commit) => new Date(commit.when).getTime()); + expect(times).toEqual([...times].sort((a, b) => b - a)); + expect(times[0]).toBeLessThanOrEqual(now.getTime()); + }); + + it("credits the authors it was given", () => { + const history = mockFileHistory( + repo, + "src/app.css", + [{ authorName: "eti", authorEmail: "did:plc:e", authorDid: "did:plc:e" }], + now + ); + expect(new Set(history.map((commit) => commit.authorName))).toEqual(new Set(["eti"])); + }); +}); + +describe("authorsOf", () => { + it("keeps one entry per identity, did first", () => { + const commit = (authorName: string, extra: Partial): CommitSummary => ({ + hash: "0".repeat(40), + shortHash: "00000000", + subject: "s", + body: "", + authorName, + authorEmail: "", + when: now.toISOString(), + ...extra + }); + const authors = authorsOf([ + commit("eti", { authorDid: "did:plc:e" }), + commit("eti again", { authorDid: "did:plc:e" }), + commit("dawn", { authorEmail: "dawn@tangled.org" }), + commit("nobody", { authorEmail: "" }) + ]); + expect(authors.map((author) => author.authorName)).toEqual(["eti", "dawn"]); + }); +}); diff --git a/web/src/lib/api/fileHistory.ts b/web/src/lib/api/fileHistory.ts new file mode 100644 index 000000000..dc312a939 --- /dev/null +++ b/web/src/lib/api/fileHistory.ts @@ -0,0 +1,133 @@ +import type { CommitSummary } from "./repo"; + +// MOCK. sh.tangled.repo.log takes a `path` param but no knot filters on it +// yet, so there is no real file history to read: asking for one makes bobbin +// skip the mirror and the knot answers RepoNotFound. Until the filter lands, +// the history page shows a history invented from the repo's own authors. +// +// to drop the mock: delete this file and have the loader call +// `log(git, { ref, path, limit, cursor })` again, through a target that reaches +// the knot. +export const MOCKED = true; + +export interface MockAuthor { + authorName: string; + authorEmail: string; + authorDid?: string; + authorHandle?: string; +} + +const SUBJECTS = [ + "pull the loader out of the component", + "stop the header from reflowing on hover", + "drop the dead compatibility branch", + "name the magic number", + "let the caller pass its own fetch", + "fix the off-by-one in the page cursor", + "reuse the existing chip instead of a new one", + "move the guard up to the boundary", + "keep the empty state on one line", + "tighten the types around the response", + "cache the resolved handle for the session", + "handle the ref with a slash in it", + "split the view out of the route", + "stop swallowing the parse error", + "round the sizes to the token scale", + "make the toggle keyboard reachable", + "read the default from the config", + "collapse the two nearly identical helpers", + "defer the icon import", + "treat a missing body as empty, not null", + "walk the parents once, not twice", + "put the separator back between the rows", + "trim the trailing newline before hashing", + "spell the aria label out loud" +]; + +const BODIES = [ + "the old path only ever ran on the first render, so nothing depended on it.", + "measured before and after: the reflow is gone and the header holds its height.", + "this keeps the call site honest about what it owns.\n\nno behaviour change.", + "turns out the knot already sorts these, so the second pass was pure cost.", + "reported in chat: a branch called release/2.0 lost everything after the slash." +]; + +const FALLBACK_AUTHORS: MockAuthor[] = [ + { authorName: "dawn", authorEmail: "dawn@tangled.org" }, + { authorName: "oppi", authorEmail: "oppi@tangled.sh" }, + { authorName: "icy", authorEmail: "icy@tangled.sh" } +]; + +const seedOf = (key: string): number => { + let hash = 0x811c9dc5; + for (let index = 0; index < key.length; index += 1) { + hash ^= key.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +}; + +// mulberry32, so one path always invents the same history +const random = (seed: number) => { + let state = seed; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let drawn = Math.imul(state ^ (state >>> 15), 1 | state); + drawn = (drawn + Math.imul(drawn ^ (drawn >>> 7), 61 | drawn)) ^ drawn; + return ((drawn ^ (drawn >>> 14)) >>> 0) / 4294967296; + }; +}; + +const DAY_MS = 86_400_000; + +/** + * an invented history for one path: deterministic in `${repo}:${path}`, newest + * first, spread over enough months to group and often long enough to paginate + */ +export const mockFileHistory = ( + repo: string, + path: string, + authors: MockAuthor[], + now: Date = new Date() +): CommitSummary[] => { + const next = random(seedOf(`${repo}:${path}`)); + const pool = authors.length > 0 ? authors : FALLBACK_AUTHORS; + const count = 8 + Math.floor(next() * 160); + + const hex = () => + Array.from({ length: 40 }, () => "0123456789abcdef"[Math.floor(next() * 16)]).join(""); + + let when = now.getTime() - Math.floor(next() * 4 * DAY_MS); + return Array.from({ length: count }, () => { + const hash = hex(); + const body = next() < 0.25 ? BODIES[Math.floor(next() * BODIES.length)] : ""; + const commit: CommitSummary = { + hash, + shortHash: hash.slice(0, 8), + subject: SUBJECTS[Math.floor(next() * SUBJECTS.length)], + body, + when: new Date(when).toISOString(), + ...pool[Math.floor(next() * pool.length)] + }; + if (next() < 0.5) commit.changeId = hex().slice(0, 32); + when -= Math.floor(next() * 7 * DAY_MS); + return commit; + }); +}; + +export const authorsOf = (commits: CommitSummary[]): MockAuthor[] => { + const seen = new Set(); + return commits.flatMap((commit) => { + const key = commit.authorDid ?? commit.authorEmail; + if (!key || seen.has(key)) return []; + seen.add(key); + return [ + { + authorName: commit.authorName, + authorEmail: commit.authorEmail, + authorDid: commit.authorDid, + authorHandle: commit.authorHandle + } + ]; + }); +}; diff --git a/web/src/lib/bones/repo-filehistoryview--skeleton-fixture.bones.json b/web/src/lib/bones/repo-filehistoryview--skeleton-fixture.bones.json new file mode 100644 index 000000000..9ecb9b79c --- /dev/null +++ b/web/src/lib/bones/repo-filehistoryview--skeleton-fixture.bones.json @@ -0,0 +1,417 @@ +{ + "breakpoints": { + "375": { + "name": "repo-filehistoryview--skeleton-fixture", + "viewportWidth": 343, + "width": 343, + "height": 887, + "bones": [ + [0, 0, 100, 887, 4, true, 15, true], + [4.9563, 21, 4.6647, 16, 0], + [49.9453, 21, 33.5869, 15, 4], + [4.9563, 68, 25.3189, 17, 4], + [0.2915, 97, 99.4169, 781, 0, true, 5, false], + [0.2915, 98, 99.4169, 78, 0, true, 4, false], + [4.9563, 108, 33.9103, 17, 4], + [41.199, 111, 5.8309, 12, 4], + [4.9563, 136, 7.5802, 26, "50%", true, 15, true], + [6.9971, 143, 3.4985, 12, 0], + [13.7026, 141, 10.5457, 17, 4], + [27.7469, 141, 16.7957, 15, 4], + [46.2919, 131, 12.2449, 36, 4], + [59.1199, 131, 12.2449, 36, 4], + [73.6972, 141, 13.4885, 17, 4], + [0.2915, 176, 99.4169, 78, 0, true, 4, false], + [4.9563, 186, 33.9103, 17, 4], + [4.9563, 214, 7.5802, 26, "50%", true, 15, true], + [6.9971, 221, 3.4985, 12, 0], + [13.7026, 219, 10.5457, 17, 4], + [27.7469, 219, 16.7957, 15, 4], + [46.2919, 209, 12.2449, 36, 4], + [59.1199, 209, 12.2449, 36, 4], + [73.6972, 219, 13.4885, 17, 4], + [0.2915, 254, 99.4169, 78, 0, true, 4, false], + [4.9563, 264, 33.9103, 17, 4], + [4.9563, 292, 7.5802, 26, "50%", true, 15, true], + [6.9971, 299, 3.4985, 12, 0], + [13.7026, 297, 10.5457, 17, 4], + [27.7469, 297, 16.7957, 15, 4], + [46.2919, 287, 12.2449, 36, 4], + [59.1199, 287, 12.2449, 36, 4], + [73.6972, 297, 13.4885, 17, 4], + [0.2915, 332, 99.4169, 78, 0, true, 4, false], + [4.9563, 342, 33.9103, 17, 4], + [4.9563, 370, 7.5802, 26, "50%", true, 15, true], + [6.9971, 377, 3.4985, 12, 0], + [13.7026, 375, 10.5457, 17, 4], + [27.7469, 375, 16.7957, 15, 4], + [46.2919, 365, 12.2449, 36, 4], + [59.1199, 365, 12.2449, 36, 4], + [73.6972, 375, 13.4885, 17, 4], + [0.2915, 410, 99.4169, 78, 0, true, 4, false], + [4.9563, 420, 33.9103, 17, 4], + [4.9563, 448, 7.5802, 26, "50%", true, 15, true], + [6.9971, 455, 3.4985, 12, 0], + [13.7026, 453, 10.5457, 17, 4], + [27.7469, 453, 16.7957, 15, 4], + [46.2919, 443, 12.2449, 36, 4], + [59.1199, 443, 12.2449, 36, 4], + [73.6972, 453, 13.4885, 17, 4], + [0.2915, 488, 99.4169, 78, 0, true, 4, false], + [4.9563, 498, 33.9103, 17, 4], + [4.9563, 526, 7.5802, 26, "50%", true, 15, true], + [6.9971, 533, 3.4985, 12, 0], + [13.7026, 531, 10.5457, 17, 4], + [27.7469, 531, 16.7957, 15, 4], + [46.2919, 521, 12.2449, 36, 4], + [59.1199, 521, 12.2449, 36, 4], + [73.6972, 531, 13.4885, 17, 4], + [0.2915, 566, 99.4169, 78, 0, true, 4, false], + [4.9563, 576, 33.9103, 17, 4], + [4.9563, 604, 7.5802, 26, "50%", true, 15, true], + [6.9971, 611, 3.4985, 12, 0], + [13.7026, 609, 10.5457, 17, 4], + [27.7469, 609, 16.7957, 15, 4], + [46.2919, 599, 12.2449, 36, 4], + [59.1199, 599, 12.2449, 36, 4], + [73.6972, 609, 13.4885, 17, 4], + [0.2915, 644, 99.4169, 78, 0, true, 4, false], + [4.9563, 654, 33.9103, 17, 4], + [4.9563, 682, 7.5802, 26, "50%", true, 15, true], + [6.9971, 689, 3.4985, 12, 0], + [13.7026, 687, 10.5457, 17, 4], + [27.7469, 687, 16.7957, 15, 4], + [46.2919, 677, 12.2449, 36, 4], + [59.1199, 677, 12.2449, 36, 4], + [73.6972, 687, 13.4885, 17, 4], + [0.2915, 722, 99.4169, 78, 0, true, 4, false], + [4.9563, 732, 33.9103, 17, 4], + [4.9563, 760, 7.5802, 26, "50%", true, 15, true], + [6.9971, 767, 3.4985, 12, 0], + [13.7026, 765, 10.5457, 17, 4], + [27.7469, 765, 16.7957, 15, 4], + [46.2919, 755, 12.2449, 36, 4], + [59.1199, 755, 12.2449, 36, 4], + [73.6972, 765, 13.4885, 17, 4], + [4.9563, 810, 36.557, 17, 4], + [4.9563, 838, 7.5802, 26, "50%", true, 15, true], + [6.9971, 845, 3.4985, 12, 0], + [13.7026, 843, 10.5457, 17, 4], + [27.7469, 843, 16.7957, 15, 4], + [46.2919, 833, 12.2449, 36, 4], + [59.1199, 833, 12.2449, 36, 4], + [73.6972, 843, 13.4885, 17, 4] + ] + }, + "768": { + "name": "repo-filehistoryview--skeleton-fixture", + "viewportWidth": 736, + "width": 736, + "height": 477, + "bones": [ + [0, 0, 100, 477, 4, true, 15, true], + [2.3098, 21, 2.1739, 16, 0], + [23.2762, 21, 15.6526, 15, 4], + [2.3098, 68, 11.7994, 17, 4], + [0.1359, 97, 99.7283, 371, 0, true, 5, false], + [0.1359, 98, 99.7283, 37, 0, true, 4, false], + [2.3098, 103, 3.5326, 26, "50%", true, 15, true], + [3.2609, 110, 1.6304, 12, 0], + [6.3859, 108, 4.9147, 17, 4], + [22.8112, 108, 7.8274, 15, 4], + [31.4538, 98, 5.7065, 36, 4], + [37.4321, 98, 5.7065, 36, 4], + [46.3995, 108, 15.8033, 17, 4], + [63.2897, 110, 2.7174, 12, 4], + [91.4041, 108, 6.2861, 17, 4], + [0.1359, 135, 99.7283, 37, 0, true, 4, false], + [2.3098, 140, 3.5326, 26, "50%", true, 15, true], + [3.2609, 147, 1.6304, 12, 0], + [6.3859, 145, 4.9147, 17, 4], + [22.8112, 145, 7.8274, 15, 4], + [31.4538, 135, 5.7065, 36, 4], + [37.4321, 135, 5.7065, 36, 4], + [46.3995, 145, 15.8033, 17, 4], + [91.4041, 145, 6.2861, 17, 4], + [0.1359, 172, 99.7283, 37, 0, true, 4, false], + [2.3098, 177, 3.5326, 26, "50%", true, 15, true], + [3.2609, 184, 1.6304, 12, 0], + [6.3859, 182, 4.9147, 17, 4], + [22.8112, 182, 7.8274, 15, 4], + [31.4538, 172, 5.7065, 36, 4], + [37.4321, 172, 5.7065, 36, 4], + [46.3995, 182, 15.8033, 17, 4], + [91.4041, 182, 6.2861, 17, 4], + [0.1359, 209, 99.7283, 37, 0, true, 4, false], + [2.3098, 214, 3.5326, 26, "50%", true, 15, true], + [3.2609, 221, 1.6304, 12, 0], + [6.3859, 219, 4.9147, 17, 4], + [22.8112, 219, 7.8274, 15, 4], + [31.4538, 209, 5.7065, 36, 4], + [37.4321, 209, 5.7065, 36, 4], + [46.3995, 219, 15.8033, 17, 4], + [91.4041, 219, 6.2861, 17, 4], + [0.1359, 246, 99.7283, 37, 0, true, 4, false], + [2.3098, 251, 3.5326, 26, "50%", true, 15, true], + [3.2609, 258, 1.6304, 12, 0], + [6.3859, 256, 4.9147, 17, 4], + [22.8112, 256, 7.8274, 15, 4], + [31.4538, 246, 5.7065, 36, 4], + [37.4321, 246, 5.7065, 36, 4], + [46.3995, 256, 15.8033, 17, 4], + [91.4041, 256, 6.2861, 17, 4], + [0.1359, 283, 99.7283, 37, 0, true, 4, false], + [2.3098, 288, 3.5326, 26, "50%", true, 15, true], + [3.2609, 295, 1.6304, 12, 0], + [6.3859, 293, 4.9147, 17, 4], + [22.8112, 293, 7.8274, 15, 4], + [31.4538, 283, 5.7065, 36, 4], + [37.4321, 283, 5.7065, 36, 4], + [46.3995, 293, 15.8033, 17, 4], + [91.4041, 293, 6.2861, 17, 4], + [0.1359, 320, 99.7283, 37, 0, true, 4, false], + [2.3098, 325, 3.5326, 26, "50%", true, 15, true], + [3.2609, 332, 1.6304, 12, 0], + [6.3859, 330, 4.9147, 17, 4], + [22.8112, 330, 7.8274, 15, 4], + [31.4538, 320, 5.7065, 36, 4], + [37.4321, 320, 5.7065, 36, 4], + [46.3995, 330, 15.8033, 17, 4], + [91.4041, 330, 6.2861, 17, 4], + [0.1359, 357, 99.7283, 37, 0, true, 4, false], + [2.3098, 362, 3.5326, 26, "50%", true, 15, true], + [3.2609, 369, 1.6304, 12, 0], + [6.3859, 367, 4.9147, 17, 4], + [22.8112, 367, 7.8274, 15, 4], + [31.4538, 357, 5.7065, 36, 4], + [37.4321, 357, 5.7065, 36, 4], + [46.3995, 367, 15.8033, 17, 4], + [91.4041, 367, 6.2861, 17, 4], + [0.1359, 394, 99.7283, 37, 0, true, 4, false], + [2.3098, 399, 3.5326, 26, "50%", true, 15, true], + [3.2609, 406, 1.6304, 12, 0], + [6.3859, 404, 4.9147, 17, 4], + [22.8112, 404, 7.8274, 15, 4], + [31.4538, 394, 5.7065, 36, 4], + [37.4321, 394, 5.7065, 36, 4], + [46.3995, 404, 15.8033, 17, 4], + [91.4041, 404, 6.2861, 17, 4], + [2.3098, 436, 3.5326, 26, "50%", true, 15, true], + [3.2609, 443, 1.6304, 12, 0], + [6.3859, 441, 4.9147, 17, 4], + [22.8112, 441, 7.8274, 15, 4], + [31.4538, 431, 5.7065, 36, 4], + [37.4321, 431, 5.7065, 36, 4], + [46.3995, 441, 17.0368, 17, 4], + [91.4041, 441, 6.2861, 17, 4] + ] + }, + "1024": { + "name": "repo-filehistoryview--skeleton-fixture", + "viewportWidth": 992, + "width": 992, + "height": 477, + "bones": [ + [0, 0, 100, 477, 4, true, 15, true], + [1.7137, 21, 1.6129, 16, 0], + [17.2694, 21, 11.6132, 15, 4], + [1.7137, 68, 8.7544, 17, 4], + [0.1008, 97, 99.7984, 371, 0, true, 5, false], + [0.1008, 98, 99.7984, 37, 0, true, 4, false], + [1.7137, 103, 2.621, 26, "50%", true, 15, true], + [2.4194, 110, 1.2097, 12, 0], + [4.7379, 108, 3.6464, 17, 4], + [24.2975, 108, 5.8074, 15, 4], + [30.7097, 98, 4.2339, 36, 4], + [35.1452, 98, 4.2339, 36, 4], + [41.7985, 108, 11.7251, 17, 4], + [54.33, 110, 2.0161, 12, 4], + [93.6224, 108, 4.6639, 17, 4], + [0.1008, 135, 99.7984, 37, 0, true, 4, false], + [1.7137, 140, 2.621, 26, "50%", true, 15, true], + [2.4194, 147, 1.2097, 12, 0], + [4.7379, 145, 3.6464, 17, 4], + [24.2975, 145, 5.8074, 15, 4], + [30.7097, 135, 4.2339, 36, 4], + [35.1452, 135, 4.2339, 36, 4], + [41.7985, 145, 11.7251, 17, 4], + [93.6224, 145, 4.6639, 17, 4], + [0.1008, 172, 99.7984, 37, 0, true, 4, false], + [1.7137, 177, 2.621, 26, "50%", true, 15, true], + [2.4194, 184, 1.2097, 12, 0], + [4.7379, 182, 3.6464, 17, 4], + [24.2975, 182, 5.8074, 15, 4], + [30.7097, 172, 4.2339, 36, 4], + [35.1452, 172, 4.2339, 36, 4], + [41.7985, 182, 11.7251, 17, 4], + [93.6224, 182, 4.6639, 17, 4], + [0.1008, 209, 99.7984, 37, 0, true, 4, false], + [1.7137, 214, 2.621, 26, "50%", true, 15, true], + [2.4194, 221, 1.2097, 12, 0], + [4.7379, 219, 3.6464, 17, 4], + [24.2975, 219, 5.8074, 15, 4], + [30.7097, 209, 4.2339, 36, 4], + [35.1452, 209, 4.2339, 36, 4], + [41.7985, 219, 11.7251, 17, 4], + [93.6224, 219, 4.6639, 17, 4], + [0.1008, 246, 99.7984, 37, 0, true, 4, false], + [1.7137, 251, 2.621, 26, "50%", true, 15, true], + [2.4194, 258, 1.2097, 12, 0], + [4.7379, 256, 3.6464, 17, 4], + [24.2975, 256, 5.8074, 15, 4], + [30.7097, 246, 4.2339, 36, 4], + [35.1452, 246, 4.2339, 36, 4], + [41.7985, 256, 11.7251, 17, 4], + [93.6224, 256, 4.6639, 17, 4], + [0.1008, 283, 99.7984, 37, 0, true, 4, false], + [1.7137, 288, 2.621, 26, "50%", true, 15, true], + [2.4194, 295, 1.2097, 12, 0], + [4.7379, 293, 3.6464, 17, 4], + [24.2975, 293, 5.8074, 15, 4], + [30.7097, 283, 4.2339, 36, 4], + [35.1452, 283, 4.2339, 36, 4], + [41.7985, 293, 11.7251, 17, 4], + [93.6224, 293, 4.6639, 17, 4], + [0.1008, 320, 99.7984, 37, 0, true, 4, false], + [1.7137, 325, 2.621, 26, "50%", true, 15, true], + [2.4194, 332, 1.2097, 12, 0], + [4.7379, 330, 3.6464, 17, 4], + [24.2975, 330, 5.8074, 15, 4], + [30.7097, 320, 4.2339, 36, 4], + [35.1452, 320, 4.2339, 36, 4], + [41.7985, 330, 11.7251, 17, 4], + [93.6224, 330, 4.6639, 17, 4], + [0.1008, 357, 99.7984, 37, 0, true, 4, false], + [1.7137, 362, 2.621, 26, "50%", true, 15, true], + [2.4194, 369, 1.2097, 12, 0], + [4.7379, 367, 3.6464, 17, 4], + [24.2975, 367, 5.8074, 15, 4], + [30.7097, 357, 4.2339, 36, 4], + [35.1452, 357, 4.2339, 36, 4], + [41.7985, 367, 11.7251, 17, 4], + [93.6224, 367, 4.6639, 17, 4], + [0.1008, 394, 99.7984, 37, 0, true, 4, false], + [1.7137, 399, 2.621, 26, "50%", true, 15, true], + [2.4194, 406, 1.2097, 12, 0], + [4.7379, 404, 3.6464, 17, 4], + [24.2975, 404, 5.8074, 15, 4], + [30.7097, 394, 4.2339, 36, 4], + [35.1452, 394, 4.2339, 36, 4], + [41.7985, 404, 11.7251, 17, 4], + [93.6224, 404, 4.6639, 17, 4], + [1.7137, 436, 2.621, 26, "50%", true, 15, true], + [2.4194, 443, 1.2097, 12, 0], + [4.7379, 441, 3.6464, 17, 4], + [24.2975, 441, 5.8074, 15, 4], + [30.7097, 431, 4.2339, 36, 4], + [35.1452, 431, 4.2339, 36, 4], + [41.7985, 441, 12.6402, 17, 4], + [93.6224, 441, 4.6639, 17, 4] + ] + }, + "1440": { + "name": "repo-filehistoryview--skeleton-fixture", + "viewportWidth": 1408, + "width": 1408, + "height": 477, + "bones": [ + [0, 0, 100, 477, 4, true, 15, true], + [1.2074, 21, 1.1364, 16, 0], + [12.1671, 21, 8.182, 15, 4], + [1.2074, 68, 6.1679, 17, 4], + [0.071, 97, 99.858, 371, 0, true, 5, false], + [0.071, 98, 99.858, 37, 0, true, 4, false], + [1.2074, 103, 1.8466, 26, "50%", true, 15, true], + [1.7045, 110, 0.8523, 12, 0], + [3.3381, 108, 2.569, 17, 4], + [25.5604, 108, 4.0916, 15, 4], + [30.0781, 98, 2.983, 36, 4], + [33.2031, 98, 2.983, 36, 4], + [37.8906, 108, 8.2608, 17, 4], + [46.7196, 110, 1.4205, 12, 4], + [95.5067, 108, 3.2859, 17, 4], + [0.071, 135, 99.858, 37, 0, true, 4, false], + [1.2074, 140, 1.8466, 26, "50%", true, 15, true], + [1.7045, 147, 0.8523, 12, 0], + [3.3381, 145, 2.569, 17, 4], + [25.5604, 145, 4.0916, 15, 4], + [30.0781, 135, 2.983, 36, 4], + [33.2031, 135, 2.983, 36, 4], + [37.8906, 145, 8.2608, 17, 4], + [95.5067, 145, 3.2859, 17, 4], + [0.071, 172, 99.858, 37, 0, true, 4, false], + [1.2074, 177, 1.8466, 26, "50%", true, 15, true], + [1.7045, 184, 0.8523, 12, 0], + [3.3381, 182, 2.569, 17, 4], + [25.5604, 182, 4.0916, 15, 4], + [30.0781, 172, 2.983, 36, 4], + [33.2031, 172, 2.983, 36, 4], + [37.8906, 182, 8.2608, 17, 4], + [95.5067, 182, 3.2859, 17, 4], + [0.071, 209, 99.858, 37, 0, true, 4, false], + [1.2074, 214, 1.8466, 26, "50%", true, 15, true], + [1.7045, 221, 0.8523, 12, 0], + [3.3381, 219, 2.569, 17, 4], + [25.5604, 219, 4.0916, 15, 4], + [30.0781, 209, 2.983, 36, 4], + [33.2031, 209, 2.983, 36, 4], + [37.8906, 219, 8.2608, 17, 4], + [95.5067, 219, 3.2859, 17, 4], + [0.071, 246, 99.858, 37, 0, true, 4, false], + [1.2074, 251, 1.8466, 26, "50%", true, 15, true], + [1.7045, 258, 0.8523, 12, 0], + [3.3381, 256, 2.569, 17, 4], + [25.5604, 256, 4.0916, 15, 4], + [30.0781, 246, 2.983, 36, 4], + [33.2031, 246, 2.983, 36, 4], + [37.8906, 256, 8.2608, 17, 4], + [95.5067, 256, 3.2859, 17, 4], + [0.071, 283, 99.858, 37, 0, true, 4, false], + [1.2074, 288, 1.8466, 26, "50%", true, 15, true], + [1.7045, 295, 0.8523, 12, 0], + [3.3381, 293, 2.569, 17, 4], + [25.5604, 293, 4.0916, 15, 4], + [30.0781, 283, 2.983, 36, 4], + [33.2031, 283, 2.983, 36, 4], + [37.8906, 293, 8.2608, 17, 4], + [95.5067, 293, 3.2859, 17, 4], + [0.071, 320, 99.858, 37, 0, true, 4, false], + [1.2074, 325, 1.8466, 26, "50%", true, 15, true], + [1.7045, 332, 0.8523, 12, 0], + [3.3381, 330, 2.569, 17, 4], + [25.5604, 330, 4.0916, 15, 4], + [30.0781, 320, 2.983, 36, 4], + [33.2031, 320, 2.983, 36, 4], + [37.8906, 330, 8.2608, 17, 4], + [95.5067, 330, 3.2859, 17, 4], + [0.071, 357, 99.858, 37, 0, true, 4, false], + [1.2074, 362, 1.8466, 26, "50%", true, 15, true], + [1.7045, 369, 0.8523, 12, 0], + [3.3381, 367, 2.569, 17, 4], + [25.5604, 367, 4.0916, 15, 4], + [30.0781, 357, 2.983, 36, 4], + [33.2031, 357, 2.983, 36, 4], + [37.8906, 367, 8.2608, 17, 4], + [95.5067, 367, 3.2859, 17, 4], + [0.071, 394, 99.858, 37, 0, true, 4, false], + [1.2074, 399, 1.8466, 26, "50%", true, 15, true], + [1.7045, 406, 0.8523, 12, 0], + [3.3381, 404, 2.569, 17, 4], + [25.5604, 404, 4.0916, 15, 4], + [30.0781, 394, 2.983, 36, 4], + [33.2031, 394, 2.983, 36, 4], + [37.8906, 404, 8.2608, 17, 4], + [95.5067, 404, 3.2859, 17, 4], + [1.2074, 436, 1.8466, 26, "50%", true, 15, true], + [1.7045, 443, 0.8523, 12, 0], + [3.3381, 441, 2.569, 17, 4], + [25.5604, 441, 4.0916, 15, 4], + [30.0781, 431, 2.983, 36, 4], + [33.2031, 431, 2.983, 36, 4], + [37.8906, 441, 8.9056, 17, 4], + [95.5067, 441, 3.2859, 17, 4] + ] + } + }, + "_hash": "9870a306a4b948ae81cc2e5b34f1dbe2" +} diff --git a/web/src/lib/components/repo/FileHistoryView.stories.svelte b/web/src/lib/components/repo/FileHistoryView.stories.svelte new file mode 100644 index 000000000..727ad23e4 --- /dev/null +++ b/web/src/lib/components/repo/FileHistoryView.stories.svelte @@ -0,0 +1,85 @@ + + + + + + + diff --git a/web/src/lib/components/repo/FileHistoryView.svelte b/web/src/lib/components/repo/FileHistoryView.svelte new file mode 100644 index 000000000..de03fc0e7 --- /dev/null +++ b/web/src/lib/components/repo/FileHistoryView.svelte @@ -0,0 +1,196 @@ + + +{#snippet author(commit: CommitSummary)} + + {#if commit.authorHandle} + + {:else if commit.authorName && commit.authorEmail && !commit.authorDid} + + + {commit.authorName} + + {:else} + + {commit.authorName} + {/if} + +{/snippet} + +{#snippet info(commit: CommitSummary)} + + + {commit.shortHash} + +
+ +
+
+{/snippet} + +{#snippet subject(commit: CommitSummary)} + + + {commit.subject} + + {#if commit.body} + + {/if} + +{/snippet} + +{#snippet when(commit: CommitSummary)} + +{/snippet} + +{#snippet row(commit: CommitSummary)} +
+ + +
+ {@render subject(commit)} +
+ {@render author(commit)} + {@render info(commit)} + {@render when(commit)} +
+
+ + {#if commit.body && expanded[commit.hash]} +

+ {commit.body} +

+ {/if} +
+{/snippet} + + +

+

+ + {#if commits.length === 0} +

+ No commits touch this file at {ref}. +

+ {:else} +
+ {#each groups as group (group.commits[0].hash)} +
+

+ {group.label} +

+
+ {#each group.commits as commit (commit.hash)} + {@render row(commit)} + {/each} +
+
+ {/each} +
+ {/if} +
+ +{#if pageCount > 1} +
+ +
+{/if} diff --git a/web/src/lib/components/repo/urls.ts b/web/src/lib/components/repo/urls.ts index 690c1e809..6ef65b5be 100644 --- a/web/src/lib/components/repo/urls.ts +++ b/web/src/lib/components/repo/urls.ts @@ -11,3 +11,13 @@ export const rawBlobHref = (ownerHandle: string, repoName: string, ref: string, resolve( `/${ownerHandle}/${repoName}/raw/${encodeURIComponent(ref)}/${encodePathSegments(path)}` as "/" ); + +export const blobHref = (ownerHandle: string, repoName: string, ref: string, path: string) => + resolve( + `/${ownerHandle}/${repoName}/blob/${encodeURIComponent(ref)}/${encodePathSegments(path)}` as "/" + ); + +export const fileHistoryHref = (ownerHandle: string, repoName: string, ref: string, path: string) => + resolve( + `/${ownerHandle}/${repoName}/history/${encodeURIComponent(ref)}/${encodePathSegments(path)}` as "/" + ); diff --git a/web/src/routes/[handle]/[repo]/history/[ref]/[...path]/+page.svelte b/web/src/routes/[handle]/[repo]/history/[ref]/[...path]/+page.svelte new file mode 100644 index 000000000..bbf89f5be --- /dev/null +++ b/web/src/routes/[handle]/[repo]/history/[ref]/[...path]/+page.svelte @@ -0,0 +1,38 @@ + + + + {#snippet skeleton()} + + {/snippet} + {@const repo = await data.repo} + + diff --git a/web/src/routes/[handle]/[repo]/history/[ref]/[...path]/+page.ts b/web/src/routes/[handle]/[repo]/history/[ref]/[...path]/+page.ts new file mode 100644 index 000000000..181e30ca7 --- /dev/null +++ b/web/src/routes/[handle]/[repo]/history/[ref]/[...path]/+page.ts @@ -0,0 +1,59 @@ +import { error } from "@sveltejs/kit"; +import { gitTarget, log } from "$lib/api/gitclient"; +import { authorsOf, mockFileHistory } from "$lib/api/fileHistory"; +import { stream } from "$lib/api/load"; +import { withAuthorHandles } from "$lib/api/repoIndex"; +import { toCommitSummary } from "$lib/api/repo"; +import type { PageLoad } from "./$types"; + +const COMMIT_LIMIT = 50; +// enough of the real log to collect the repo's authors for the mock +const AUTHOR_SAMPLE = 20; + +// the ref is a single encoded segment, `feature/x` arrives intact. the rest +// param is already the path as git knows it +export const load: PageLoad = async (event) => { + // a pathless history is the whole repo log, which lives at /commits + if (event.params.path === "") error(404, "Not found"); + const parent = await event.parent(); + const ref = event.params.ref; + const path = event.params.path; + + const rawPage = event.url.searchParams.get("page") ?? ""; + const parsed = /^\d+$/.test(rawPage) ? Number(rawPage) : 1; + const page = parsed >= 1 ? parsed : 1; + + // MOCK: the knot ignores log's `path`, so the history is invented from the + // repo's own authors. see $lib/api/fileHistory + const historyPromise = Promise.resolve(parent.repo).then(async (repo) => { + const git = gitTarget(parent.publicConfig, repo, event.fetch); + const sampled = await log(git, { ref, limit: AUTHOR_SAMPLE }) + .then((res) => + withAuthorHandles( + (res.commits ?? []).map(toCommitSummary), + parent.publicConfig.bobbinUrl, + event.fetch + ) + ) + // a repo we cannot read still gets a history, just with invented names + .catch(() => []); + return mockFileHistory(repo.uri, path, authorsOf(sampled)); + }); + + const pageCount = historyPromise.then((history) => + Math.max(Math.ceil(history.length / COMMIT_LIMIT), 1) + ); + // a page past the end shows the last one, not an empty list + const commits = Promise.all([historyPromise, pageCount]).then(([history, count]) => { + const shown = Math.min(page, count); + return history.slice((shown - 1) * COMMIT_LIMIT, shown * COMMIT_LIMIT); + }); + + return { + ref, + path, + page, + commits: stream(commits), + pageCount: stream(pageCount) + }; +}; -- 2.51.2