Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410// the fixture pull request the pull page stories share: ten commits over three// versions, a five-file diff, and a fetch mock standing in for bobbin, the knot// mirror and gitmirror, so a story can reach a rendered diff instead of an error.import type { CommentView } from "$lib/components/comment/comments";import type { CommitSummary } from "$lib/api/repo";import type { MirrorFileDiff } from "$lib/api/gitmirror";import type { Revspec } from "$lib/api/pullRoute";
export const bobbinUrl = "https://bobbin.example.test";export const knotMirrorUrl = "https://mirror.example.test";
export const repoDid = "did:plc:core";export const sourceRepoDid = "did:plc:alice-core";export const pullRkey = "3mreview";export const targetBranch = "sv-fe";export const sourceBranch = "feature/notifs-ui";
export const pullAuthor = { did: "did:plc:anirudh", handle: "anirudh.fi" };export const pullCollaborator = { did: "did:plc:oppi", handle: "oppi.tangled.sh" };
export const pullUri = `at://${pullAuthor.did}/sh.tangled.repo.pull/${pullRkey}`;
// the merge base every version was cut from, and the target branch tipexport const base = "ffffffffffffffffffffffffffffffffffffffff";export const targetHead = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
const subjects = [ "mark notifications read on visit", "move mention parsing", "fan out mention notifications", "remove unread toggle", "fix notification links", "load notifications from deliberi", "ingest pull state collections", "update hydrant filters", "add subscription lookups", "watch notification preferences"];
const oid = (index: number) => index.toString(16).repeat(40).slice(0, 40);
// newest first, the order the pull page's commit rows expectexport const pullCommits: CommitSummary[] = subjects.map((subject, index) => ({ hash: oid(index), shortHash: oid(index).slice(0, 8), subject, body: "", authorName: index % 2 === 0 ? "anirudh" : "oppi", authorEmail: index % 2 === 0 ? "anirudh@example.test" : "oppi@example.test", when: `2026-08-${String(18 - index).padStart(2, "0")}T12:00:00Z`, // the row below it, and the merge base under the oldest one parent: index === subjects.length - 1 ? base : oid(index + 1), changeId: `I${oid(index).slice(0, 12)}`, commentCount: index === 0 ? 1 : index === 3 ? 1 : 0}));
// each resubmit brought one more commit, so the heads walk the list backwardsexport const pullVersions: (Revspec & { createdAt: string })[] = [ { base, head: oid(2), createdAt: "2026-08-16T09:00:00Z" }, { base, head: oid(1), createdAt: "2026-08-17T15:30:00Z" }, { base, head: oid(0), createdAt: "2026-08-18T12:00:00Z" }];
export const pullHead = pullVersions[pullVersions.length - 1]!.head;
const commentUri = (rkey: string, did: string) => `at://${did}/sh.tangled.feed.comment/${rkey}`;
const pinned = (oid: string) => ({ $type: "sh.tangled.embed.commit", repo: sourceRepoDid, commit: { $type: "sh.tangled.git.oid", oid } }) as CommentView["embed"];
// two loose comments and two pinned to a commit, spread across the versions so// the discussion shows its version headers and the range filter has something// to hideexport const pullComments: CommentView[] = [ { uri: commentUri("3mcone", pullCollaborator.did), rkey: "3mcone", authorDid: pullCollaborator.did, authorHandle: pullCollaborator.handle, createdAt: "2026-08-16T18:00:00Z", body: "The fan-out looks right, but can the mention parser move first? It reads like two changes.", bodyHtml: "<p>The fan-out looks right, but can the mention parser move first? It reads like two changes.</p>" }, { uri: commentUri("3mctwo", pullAuthor.did), rkey: "3mctwo", authorDid: pullAuthor.did, authorHandle: pullAuthor.handle, createdAt: "2026-08-17T09:10:00Z", body: "Split out, and the unread toggle is gone in v1.", bodyHtml: "<p>Split out, and the unread toggle is gone in v1.</p>" }, { uri: commentUri("3mcthree", pullCollaborator.did), rkey: "3mcthree", authorDid: pullCollaborator.did, authorHandle: pullCollaborator.handle, createdAt: "2026-08-17T19:45:00Z", body: "This one drops the toggle without touching its preference key.", bodyHtml: "<p>This one drops the toggle without touching its preference key.</p>", embed: pinned(oid(3)) }, { uri: commentUri("3mcfour", pullCollaborator.did), rkey: "3mcfour", authorDid: pullCollaborator.did, authorHandle: pullCollaborator.handle, createdAt: "2026-08-18T14:20:00Z", body: "Marking on visit is the part I wanted. Ship it.", bodyHtml: "<p>Marking on visit is the part I wanted. Ship it.</p>", embed: pinned(oid(0)) }];
// one fixture file: its two snapshots, and the commit row that changed it, so a// narrowed commit range answers with the files that range really touchesinterface FixtureFile { path: string; oldPath?: string; row: number; old?: string; next?: string; binary?: boolean;}
const files: FixtureFile[] = [ { path: "src/lib/notifications.svelte.ts", row: 0, old: `import { getContext, setContext } from "svelte";
export const NOTIFICATIONS_KEY = Symbol("notifications");
export const createNotifications = () => { let unread = $state(0);
return { get unread() { return unread; }, set unread(value: number) { unread = value; } };};`, next: `import { getContext, setContext } from "svelte";
export const NOTIFICATIONS_KEY = Symbol("notifications");
export const createNotifications = () => { let unread = $state(0); const visited = new SvelteSet<string>();
return { get unread() { return unread; }, markEntityVisited: async (uri: string) => { if (visited.has(uri)) return; visited.add(uri); unread = Math.max(unread - 1, 0); } };};` }, { path: "src/lib/components/notifications/NotificationList.svelte", row: 2, next: `<script lang="ts"> import NotificationRow from "./NotificationRow.svelte"; import type { NotificationView } from "$lib/api/notifications";
interface Props { notifications: NotificationView[]; }
let { notifications }: Props = $props();</script>
<ul class="divide-y divide-border-default"> {#each notifications as notification (notification.uri)} <li><NotificationRow {notification} /></li> {/each}</ul>` }, { path: "src/routes/notifications/+page.server.ts", row: 3, old: `import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ locals, url }) => { const unreadOnly = url.searchParams.get("unread") === "1"; return { notifications: await locals.deliberi.list({ unreadOnly }) };};` }, { path: "src/lib/api/notifications.ts", oldPath: "src/lib/api/notifs.ts", row: 5, old: `import { jsonGet } from "./_request";
const LIST_NSID = "org.tangled.notification.list";
export const listNotifications = (ctx: BobbinContext) => jsonGet<ListOutput>(ctx, LIST_NSID, {});`, next: `import { jsonGet } from "./_request";
const LIST_NSID = "org.tangled.notification.list";
export const listNotifications = (ctx: BobbinContext, cursor?: string) => jsonGet<ListOutput>(ctx, LIST_NSID, { cursor, limit: 50 });` }, // both sides exist, so the card shows its name and a binary note instead of a diff { path: "static/notification-bell.png", row: 4, old: "", next: "", binary: true }];
const rowOf = (ref: string): number => { const row = pullCommits.findIndex((commit) => commit.hash === ref); // the merge base and anything unknown sit under the oldest commit return row === -1 ? pullCommits.length : row;};
// rows are newest first, so a file reads as changed at every ref at or above the// row that changed itconst textAt = (file: FixtureFile, ref: string): string => (rowOf(ref) <= file.row ? file.next : file.old) ?? "";
const zeroOid = "0".repeat(40);const blobOid = (path: string, side: "lhs" | "rhs") => `${side === "lhs" ? "a" : "b"}${path.length.toString(16)}` .padEnd(40, "1a2b3c4d5e6f7809") .slice(0, 40);
const src = (file: FixtureFile, side: "lhs" | "rhs", text: string | undefined) => ({ path: text === undefined ? "" : side === "lhs" ? (file.oldPath ?? file.path) : file.path, oid: text === undefined ? zeroOid : blobOid(file.path, side), size: file.binary ? 12_384 : new TextEncoder().encode(text ?? "").length, isBinary: !!file.binary, isSubmodule: false});
// the payload carries aligned line numbers only -- pierre diffs the two texts// itself -- so one hunk over the changed span is all a card needs to size itselfconst hunkOf = (oldText: string, newText: string) => { const lhs = oldText === "" ? [] : oldText.split("\n"); const rhs = newText === "" ? [] : newText.split("\n"); let start = 0; while (start < lhs.length && start < rhs.length && lhs[start] === rhs[start]) start++; let tail = 0; while ( tail < lhs.length - start && tail < rhs.length - start && lhs[lhs.length - 1 - tail] === rhs[rhs.length - 1 - tail] ) tail++; const lhsSpan = lhs.length - tail - start; const rhsSpan = rhs.length - tail - start; const lines = Array.from({ length: Math.max(lhsSpan, rhsSpan) }, (_, index) => ({ lhs: index < lhsSpan ? start + index : undefined, rhs: index < rhsSpan ? start + index : undefined })); return { lines, novelLhs: lines.filter((line) => line.lhs !== undefined).map((line) => line.lhs as number), novelRhs: lines.filter((line) => line.rhs !== undefined).map((line) => line.rhs as number) };};
const fileDiff = (file: FixtureFile, version: Revspec): MirrorFileDiff => { const oldText = file.old === undefined ? undefined : textAt(file, version.base); const newText = file.next === undefined ? undefined : textAt(file, version.head); return { lhsSrc: src(file, "lhs", oldText), rhsSrc: src(file, "rhs", newText), hasSyntacticChanges: !file.binary, hunks: file.binary ? [] : [hunkOf(oldText ?? "", newText ?? "")] };};
// a range is [head row, base row), the same half-open span the commit list usesconst inRange = (file: FixtureFile, version: Revspec) => file.row >= rowOf(version.head) && file.row < rowOf(version.base);
export type DiffOutcome = "ok" | "empty" | "error" | "pending";export type SubscriptionState = "none" | "active" | "error";
export interface PullFetchOptions { /** the source branch tip; a value past the newest head arms Resubmit */ sourceHead?: string; conflicted?: boolean; subscription?: SubscriptionState; diff?: DiffOutcome;}
const json = (body: unknown) => Response.json(body);const notFound = () => new Response(null, { status: 404 });const failed = (message: string) => Response.json({ error: "InternalError", message }, { status: 500 });
const mirrorCommit = (commit: CommitSummary) => ({ oid: commit.hash, message: commit.subject, author: { name: commit.authorName, email: commit.authorEmail, when: commit.when }, committer: { name: commit.authorName, email: commit.authorEmail, when: commit.when }, parents: commit.parent ? [commit.parent] : [], extraHeaders: commit.changeId ? [{ key: "change-id", value: commit.changeId }] : []});
/** * Answers every read the pull page makes: the subscription lookup, the branch * and merge-base resolutions, the merge check, the diff payloads and the blob * reads behind each file card. Install it before the page mounts -- the page * captures `globalThis.fetch` when it builds its clients. */export const mockPullFetch = (options: PullFetchOptions = {}): typeof fetch => (async (input: RequestInfo | URL) => { const url = new URL(String(input instanceof Request ? input.url : input)); const nsid = url.pathname.replace(/^\/xrpc\//, ""); const param = (name: string) => url.searchParams.get(name) ?? "";
switch (nsid) { case "org.tangled.feed.subscription.getForActor": switch (options.subscription ?? "none") { case "active": return json({ uri: `at://${param("actor")}/org.tangled.feed.subscription/3msub` }); case "error": return failed("bobbin is down"); default: return notFound(); }
case "sh.tangled.git.temp.getBranch": return json({ reference: { name: param("name") }, hash: param("name") === sourceBranch ? (options.sourceHead ?? pullHead) : targetHead });
case "sh.tangled.git.temp.getMergeBase": return json({ commit: base });
case "sh.tangled.git.temp2.mergeCheck": return json({ isConflicted: !!options.conflicted, conflicts: options.conflicted ? [{ filename: "src/lib/notifications.svelte.ts", reason: "both modified" }] : [] });
case "sh.tangled.git.temp2.listCommits": return json({ commits: pullCommits.map(mirrorCommit) });
case "sh.tangled.git.temp2.getDiff": case "sh.tangled.git.temp2.getInterdiff": { if (options.diff === "pending") return new Promise<Response>(() => {}); if (options.diff === "error") return failed("gitmirror is unreachable"); if (options.diff === "empty") return json({ diffs: [] }); // an interdiff's spans don't map onto one commit range, so it // answers with the whole set const version = nsid === "sh.tangled.git.temp2.getDiff" ? { base: param("baseCommit"), head: param("headCommit") } : { base, head: pullHead }; return json({ diffs: files .filter((file) => inRange(file, version)) .map((file) => fileDiff(file, version)) }); }
case "sh.tangled.git.temp.getEntry": { const file = files.find( (entry) => entry.path === param("path") || entry.oldPath === param("path") ); if (!file) return notFound(); return json({ name: param("path").split("/").pop(), mode: "100644", size: file.binary ? 12_384 : new TextEncoder().encode(textAt(file, param("ref"))).length }); }
case "sh.tangled.git.temp.getBlob": { const file = files.find( (entry) => entry.path === param("path") || entry.oldPath === param("path") ); if (!file) return notFound(); return new Response(textAt(file, param("ref"))); } } return notFound(); }) as typeof fetch;