mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat(hub): show what changed between file versions (BEA-10) (#61)
Per-file history rows carried identity and a byte size and nothing else — to answer "what did the agent change?" you had to download two blobs and diff them by hand. Every non-first version now expands to a line diff against the previous version of that path, with a +N −M count. No new endpoint: /blob?sha= already serves both sides and the history response already names both shas. No new dependency: the LCS is ~40 lines in src/lib/diff.ts, unit-tested on node's built-in runner (npm test) — node ≥ 23 strips the types, so the frontend gains no dev dependency. Blobs are fetched only on expand and cached by sha with an infinite staleTime (content-addressed, so staleness never applies). Binary is decided on the bytes, never the extension; either side over 1 MB gets the too-large fallback, checked against Content-Length before the body is read. Diffs are per-file only — the subtree feed mixes paths. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
63612743df
commit
224b3d6f53
@@ -44,6 +44,7 @@ classDiagram
|
||||
|
||||
class api {
|
||||
+getJSON / postJSON / api
|
||||
+getResponse (raw bytes)
|
||||
types.ts server contracts
|
||||
}
|
||||
note for api "api/http.ts — all URLs root-absolute so deep paths never break relative resolution"
|
||||
@@ -52,18 +53,25 @@ classDiagram
|
||||
+useConfig
|
||||
+useHub
|
||||
+useBrowse
|
||||
+useBlobText (sha-keyed, immutable)
|
||||
}
|
||||
note for hooks "TanStack Query wrappers over the viewer APIs"
|
||||
|
||||
class components {
|
||||
FileView FolderListing FileTree
|
||||
HistoryView HistoryRow VersionBanner
|
||||
Insights ShareDialog OrgAdmin
|
||||
HubSettings ProjectSettings
|
||||
HistoryView HistoryRow DiffView VersionBanner
|
||||
Insights ShareDialog
|
||||
OrgAdmin HubSettings ProjectSettings
|
||||
Palette shell AccountBar ...
|
||||
}
|
||||
note for components "components/ui — shadcn/ui primitives (Radix, copied in), themed from BearDrive tokens in tw.css; rendered markdown is transformed as a string before mounting, link clicks delegated on the container — never patch the dangerouslySetInnerHTML subtree"
|
||||
|
||||
class lib {
|
||||
+diff.ts splitLines lcsDiff diffText
|
||||
+utils.ts
|
||||
}
|
||||
note for lib "pure, no React, unit-tested on node (npm test) — the line diff is ~40 lines, cheaper than auditing a diff package"
|
||||
|
||||
App --> HubApp
|
||||
App --> VolumeApp
|
||||
HubApp --> Browser
|
||||
@@ -73,6 +81,7 @@ classDiagram
|
||||
Browser --> components
|
||||
HubApp --> components
|
||||
components --> nav : linkProps navigate
|
||||
components --> lib : diffText
|
||||
hooks --> api
|
||||
Browser --> hooks
|
||||
HubApp --> hooks
|
||||
|
||||
@@ -160,6 +160,9 @@ func seedE2E(t *testing.T, state, prefix, projectID string) {
|
||||
png := "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89" +
|
||||
"\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
put("assets/logo.png", png, 24*time.Hour)
|
||||
// A second version of the same binary, so the history diff has a
|
||||
// predecessor to refuse to diff (the "binary — no diff" path).
|
||||
put("assets/logo.png", png+"\x00trailing", 3*time.Hour)
|
||||
// One removed file, so the history feed has a delete row: deletes have no
|
||||
// content, so their rows stay unclickable while every other row is now an
|
||||
// address for its own version.
|
||||
|
||||
@@ -134,6 +134,67 @@ test("history: whole project, newest first, and per-file versions", async ({ pag
|
||||
await page.waitForURL(new RegExp(`/${pid}/guide\\.md\\?v=[0-9a-f]{64}$`));
|
||||
});
|
||||
|
||||
test("per-file history: a version expands to show what changed", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.goto(`/${pid}/history/guide.md`);
|
||||
const rows = page.locator(".history .hentry");
|
||||
await expect(rows).toHaveCount(2);
|
||||
// Nothing is fetched until a row is expanded.
|
||||
const blobReqs: string[] = [];
|
||||
page.on("request", (r) => {
|
||||
if (r.url().includes("/blob?sha=")) blobReqs.push(r.url());
|
||||
});
|
||||
await expect(page.locator(".dv")).toHaveCount(0);
|
||||
|
||||
// Newest version diffs against the one before it.
|
||||
await rows.nth(0).locator(".hdiff-btn").click();
|
||||
const dv = page.locator(".dv");
|
||||
await expect(dv).toBeVisible();
|
||||
await expect(dv.locator(".dv-rm")).toContainText("First version of the guide.");
|
||||
await expect(dv.locator(".dv-ins")).toContainText("Second version of the guide, with more detail.");
|
||||
await expect(dv.locator(".dv-add")).toHaveText("+1");
|
||||
await expect(dv.locator(".dv-del")).toHaveText("−1");
|
||||
expect(blobReqs.length).toBe(2); // exactly the two versions, once each
|
||||
|
||||
// Collapsing and re-expanding is free: the blobs are cached by sha.
|
||||
await rows.nth(0).locator(".hdiff-btn").click();
|
||||
await expect(page.locator(".dv")).toHaveCount(0);
|
||||
await rows.nth(0).locator(".hdiff-btn").click();
|
||||
await expect(page.locator(".dv")).toBeVisible();
|
||||
expect(blobReqs.length).toBe(2);
|
||||
|
||||
// The first version has nothing behind it, and says so.
|
||||
await expect(rows.nth(1).locator(".hdiff-btn")).toHaveCount(0);
|
||||
await expect(rows.nth(1).locator(".hdiff-none")).toContainText("nothing to compare against");
|
||||
|
||||
// Expanding never navigates away.
|
||||
await expect(page).toHaveURL(`/${pid}/history/guide.md`);
|
||||
});
|
||||
|
||||
test("per-file history: a binary version says so instead of diffing", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.goto(`/${pid}/history/assets/logo.png`);
|
||||
await page.locator(".history .hentry").nth(0).locator(".hdiff-btn").click();
|
||||
const dv = page.locator(".dv");
|
||||
await expect(dv).toContainText("Binary file — no diff available");
|
||||
// Both versions stay reachable by download.
|
||||
await expect(dv.locator("a", { hasText: "download previous" })).toHaveAttribute(
|
||||
"href",
|
||||
/blob\?sha=[0-9a-f]{64}&name=logo\.png&download=1/,
|
||||
);
|
||||
await expect(dv.locator("a", { hasText: "download this version" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("the whole-project feed carries no diff controls", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.goto(`/${pid}/history`);
|
||||
await expect(page.locator(".history .hentry").first()).toBeVisible();
|
||||
await expect(page.locator(".history .hdiff-btn")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("folder listing's Full history goes to the subtree feed", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"test": "node --test 'src/**/*.test.ts'",
|
||||
"e2e": "playwright test -c e2e"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -48,6 +48,15 @@ export async function getJSON<T>(url: string): Promise<T> {
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// Same auth/error handling as getJSON, but hands back the raw Response —
|
||||
// for endpoints that serve bytes rather than JSON (blob?sha=).
|
||||
export async function getResponse(url: string): Promise<Response> {
|
||||
const r = await fetch(url);
|
||||
if (r.status === 401) toLogin();
|
||||
if (!r.ok) await fail(r);
|
||||
return r;
|
||||
}
|
||||
|
||||
/* fetch wrapper for methods without a body-returning helper */
|
||||
export async function api<T = unknown>(method: string, url: string, body?: unknown): Promise<T> {
|
||||
const opt: RequestInit = { method };
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useMemo } from "react";
|
||||
import { diffText } from "../lib/diff";
|
||||
import { blobURL, useBlobText } from "../hooks/useBlob";
|
||||
|
||||
/* What actually changed between one version of a file and the one before
|
||||
it: a line diff, computed in the browser from the two blobs the history
|
||||
response already named. Mounted only once a row is expanded — nothing
|
||||
here fetches until then. */
|
||||
|
||||
function basename(path: string): string {
|
||||
return path.slice(path.lastIndexOf("/") + 1);
|
||||
}
|
||||
|
||||
// Both versions, side by side, for the cases we cannot diff.
|
||||
function Downloads({ apiBase, path, prev, cur }: { apiBase: string; path: string; prev: string; cur: string }) {
|
||||
const name = basename(path);
|
||||
return (
|
||||
<span className="dv-dl">
|
||||
<a href={blobURL(apiBase, prev, name, true)}>download previous</a>
|
||||
<a href={blobURL(apiBase, cur, name, true)}>download this version</a>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function DiffView({
|
||||
apiBase,
|
||||
path,
|
||||
prev,
|
||||
cur,
|
||||
}: {
|
||||
apiBase: string;
|
||||
path: string;
|
||||
prev: string; // sha of the previous version of this path
|
||||
cur: string; // sha of the version this row describes
|
||||
}) {
|
||||
const a = useBlobText(apiBase, prev, true);
|
||||
const b = useBlobText(apiBase, cur, true);
|
||||
const ready = a.data?.kind === "text" && b.data?.kind === "text";
|
||||
const result = useMemo(
|
||||
() =>
|
||||
a.data?.kind === "text" && b.data?.kind === "text"
|
||||
? diffText(a.data.text, b.data.text)
|
||||
: null,
|
||||
[a.data, b.data],
|
||||
);
|
||||
|
||||
if (a.error || b.error) {
|
||||
return <div className="dv dv-msg">Could not load one of the versions.</div>;
|
||||
}
|
||||
if (!a.data || !b.data) return <div className="dv dv-msg">Loading changes…</div>;
|
||||
if (!ready) {
|
||||
const tooLarge = a.data.kind === "too-large" || b.data.kind === "too-large";
|
||||
return (
|
||||
<div className="dv dv-msg">
|
||||
{tooLarge ? "Too large to diff — download to compare." : "Binary file — no diff available."}
|
||||
<Downloads apiBase={apiBase} path={path} prev={prev} cur={cur} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const { lines, add, del } = result!;
|
||||
return (
|
||||
<div className="dv">
|
||||
<div className="dv-head">
|
||||
<span className="dv-stat">
|
||||
<span className="dv-add">+{add}</span> <span className="dv-del">−{del}</span>
|
||||
</span>
|
||||
{add === 0 && del === 0 && <span className="dv-same">No line changes</span>}
|
||||
</div>
|
||||
<div className="dv-body">
|
||||
{lines.map((l, i) => (
|
||||
<div key={i} className={"dv-line dv-" + (l.op === "=" ? "ctx" : l.op === "+" ? "ins" : "rm")}>
|
||||
<span className="dv-n">{l.an ?? ""}</span>
|
||||
<span className="dv-n">{l.bn ?? ""}</span>
|
||||
<span className="dv-mark">{l.op === "=" ? " " : l.op}</span>
|
||||
<span className="dv-text">{l.line || " "}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import type { HistoryEntry } from "../api/types";
|
||||
import { humanSize, whoChanged } from "../util";
|
||||
import { Icon } from "./shell";
|
||||
import { DiffView } from "./DiffView";
|
||||
|
||||
/* One change as a row: what happened (added / edited / deleted), to which
|
||||
file, by whom, from where — with the note (session link) expandable. */
|
||||
@@ -11,17 +12,26 @@ const KIND_LABEL: Record<string, string> = { add: "added", edit: "edited", delet
|
||||
export function HistoryRow({
|
||||
entry: e,
|
||||
onOpen,
|
||||
diff,
|
||||
}: {
|
||||
entry: HistoryEntry;
|
||||
// The row's own version (e.blob) rides along: a row is an address for the
|
||||
// bytes it describes, not a shortcut to whatever the file says now.
|
||||
onOpen: (path: string, version?: string) => void;
|
||||
// Present only in the per-file history view, where "the previous version"
|
||||
// is unambiguous. `prev` is the sha of the entry before this one on the
|
||||
// same path; absent means this is the first version.
|
||||
diff?: { apiBase: string; prev?: string };
|
||||
}) {
|
||||
const [noteOpen, setNoteOpen] = useState(false);
|
||||
const [diffOpen, setDiffOpen] = useState(false);
|
||||
const kind = e.kind === "put" ? "edit" : e.kind; // older servers report raw "put" ops
|
||||
const who = whoChanged(e);
|
||||
const dev = [e.device.name || e.device.id, e.device.os, e.device.ip].filter(Boolean).join(" · ");
|
||||
const clickable = kind !== "delete";
|
||||
// A delete has no content, and a first version has nothing behind it.
|
||||
const diffable = !!diff && kind !== "delete" && !!e.blob;
|
||||
const toggleDiff = () => setDiffOpen(!diffOpen);
|
||||
const open = (ev: React.MouseEvent | React.KeyboardEvent) => {
|
||||
if ((ev.target as HTMLElement).tagName === "A") return;
|
||||
if (clickable) onOpen(e.path, e.blob);
|
||||
@@ -86,6 +96,33 @@ export function HistoryRow({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Its own control, never the kind glyph: expanding a row must not
|
||||
navigate, so this stops the click from reaching the row. */}
|
||||
{diffable &&
|
||||
(diff!.prev ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={"hdiff-btn" + (diffOpen ? " open" : "")}
|
||||
aria-expanded={diffOpen}
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
toggleDiff();
|
||||
}}
|
||||
onKeyDown={(ev) => ev.stopPropagation()}
|
||||
>
|
||||
<Icon name={diffOpen ? "chevd" : "chev"} />
|
||||
{diffOpen ? "hide changes" : "show changes"}
|
||||
</button>
|
||||
{diffOpen && (
|
||||
<div onClick={(ev) => ev.stopPropagation()}>
|
||||
<DiffView apiBase={diff!.apiBase} path={e.path} prev={diff!.prev} cur={e.blob!} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="hdiff-none">First version — nothing to compare against</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,11 +42,26 @@ export function HistoryView(props: {
|
||||
|
||||
if (!data) return null;
|
||||
const entries = data.entries || [];
|
||||
// Diffs are a per-file affair: the subtree feed mixes paths, and each row
|
||||
// there would need its own predecessor lookup for no review benefit.
|
||||
const perFile = !!target && !isFolder(target);
|
||||
// Entries arrive newest-first, so a row's predecessor is the next entry
|
||||
// below it on the same path that still has content.
|
||||
const prevBlob = (i: number) => {
|
||||
for (let j = i + 1; j < entries.length; j++) {
|
||||
if (entries[j].path === entries[i].path && entries[j].kind !== "delete") return entries[j].blob;
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="history">
|
||||
{entries.length === 0 && <div className="empty">No history yet.</div>}
|
||||
{entries.map((e, i) => (
|
||||
<HistoryRow key={i} entry={e} onOpen={props.onOpen} />
|
||||
<HistoryRow
|
||||
key={i}
|
||||
entry={e}
|
||||
onOpen={props.onOpen}
|
||||
diff={perFile ? { apiBase, prev: prevBlob(i) } : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getResponse } from "../api/http";
|
||||
|
||||
// One exact version's bytes, decoded as text when they are text. Blobs are
|
||||
// content-addressed and immutable, so a sha is a perfect cache key and
|
||||
// staleness never applies — re-expanding a history row costs no request.
|
||||
|
||||
// Both sides of a diff are held in memory at once, so this bound is
|
||||
// load-bearing, not cosmetic.
|
||||
const MAX_BYTES = 1 << 20; // 1 MB
|
||||
const SNIFF = 8192;
|
||||
|
||||
export type BlobText =
|
||||
| { kind: "text"; text: string }
|
||||
| { kind: "binary" }
|
||||
| { kind: "too-large" };
|
||||
|
||||
export function blobURL(apiBase: string, sha: string, name?: string, download?: boolean): string {
|
||||
let u = apiBase + "blob?sha=" + encodeURIComponent(sha);
|
||||
if (name) u += "&name=" + encodeURIComponent(name);
|
||||
if (download) u += "&download=1";
|
||||
return u;
|
||||
}
|
||||
|
||||
async function fetchBlobText(url: string): Promise<BlobText> {
|
||||
const r = await getResponse(url);
|
||||
// Cheap out before reading the body when the server tells us the size.
|
||||
const len = Number(r.headers.get("Content-Length"));
|
||||
if (len > MAX_BYTES) return { kind: "too-large" };
|
||||
const buf = new Uint8Array(await r.arrayBuffer());
|
||||
if (buf.byteLength > MAX_BYTES) return { kind: "too-large" };
|
||||
// Decide on the bytes, never the extension — agents write plenty of
|
||||
// extensionless files.
|
||||
if (buf.subarray(0, SNIFF).includes(0)) return { kind: "binary" };
|
||||
try {
|
||||
return { kind: "text", text: new TextDecoder("utf-8", { fatal: true }).decode(buf) };
|
||||
} catch {
|
||||
return { kind: "binary" };
|
||||
}
|
||||
}
|
||||
|
||||
export function useBlobText(apiBase: string, sha: string | undefined, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ["blob", apiBase, sha],
|
||||
queryFn: () => fetchBlobText(blobURL(apiBase, sha!)),
|
||||
enabled: enabled && !!sha,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Run with `npm test` (node's built-in runner; node ≥ 23 strips the types).
|
||||
// Excluded from tsconfig's include — it imports node: builtins, which the
|
||||
// app's DOM-only lib set does not know about.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { splitLines, lcsDiff, diffText } from "./diff.ts";
|
||||
|
||||
const ops = (ls: ReturnType<typeof lcsDiff>) => ls.map((l) => l.op + l.line);
|
||||
|
||||
test("splitLines: trailing newline ends the last line", () => {
|
||||
assert.deepEqual(splitLines("a\nb\n"), ["a", "b"]);
|
||||
assert.deepEqual(splitLines("a\nb"), ["a", "b"]);
|
||||
assert.deepEqual(splitLines(""), []);
|
||||
assert.deepEqual(splitLines("\n"), [""]);
|
||||
});
|
||||
|
||||
test("identical files produce only context", () => {
|
||||
const d = diffText("a\nb\nc\n", "a\nb\nc\n");
|
||||
assert.deepEqual(ops(d.lines), ["=a", "=b", "=c"]);
|
||||
assert.equal(d.add, 0);
|
||||
assert.equal(d.del, 0);
|
||||
});
|
||||
|
||||
test("pure insertion", () => {
|
||||
const d = diffText("a\nc\n", "a\nb\nc\n");
|
||||
assert.deepEqual(ops(d.lines), ["=a", "+b", "=c"]);
|
||||
assert.equal(d.add, 1);
|
||||
assert.equal(d.del, 0);
|
||||
});
|
||||
|
||||
test("pure deletion", () => {
|
||||
const d = diffText("a\nb\nc\n", "a\nc\n");
|
||||
assert.deepEqual(ops(d.lines), ["=a", "-b", "=c"]);
|
||||
assert.equal(d.add, 0);
|
||||
assert.equal(d.del, 1);
|
||||
});
|
||||
|
||||
test("replacement — the seeded guide.md case", () => {
|
||||
const d = diffText(
|
||||
"# Guide\n\nFirst version of the guide.\n",
|
||||
"# Guide\n\nSecond version of the guide, with more detail.\n",
|
||||
);
|
||||
assert.deepEqual(ops(d.lines), [
|
||||
"=# Guide",
|
||||
"=",
|
||||
"-First version of the guide.",
|
||||
"+Second version of the guide, with more detail.",
|
||||
]);
|
||||
assert.equal(d.add, 1);
|
||||
assert.equal(d.del, 1);
|
||||
});
|
||||
|
||||
test("empty file on either side", () => {
|
||||
assert.deepEqual(ops(diffText("", "a\nb\n").lines), ["+a", "+b"]);
|
||||
assert.deepEqual(ops(diffText("a\nb\n", "").lines), ["-a", "-b"]);
|
||||
assert.deepEqual(ops(diffText("", "").lines), []);
|
||||
});
|
||||
|
||||
test("no trailing newline is a change of its own", () => {
|
||||
// "a\nb" vs "a\nb\n" both split to ["a","b"], so the line diff is empty —
|
||||
// documented behaviour, not an accident: this is a line differ.
|
||||
assert.deepEqual(ops(diffText("a\nb", "a\nb\n").lines), ["=a", "=b"]);
|
||||
// A real edit on a file with no trailing newline still diffs.
|
||||
assert.deepEqual(ops(diffText("a\nb", "a\nc").lines), ["=a", "-b", "+c"]);
|
||||
});
|
||||
|
||||
test("line numbers track both sides", () => {
|
||||
const d = diffText("a\nb\nc\n", "a\nx\nc\n");
|
||||
assert.deepEqual(
|
||||
d.lines.map((l) => [l.op, l.an, l.bn]),
|
||||
[
|
||||
["=", 1, 1],
|
||||
["-", 2, undefined],
|
||||
["+", undefined, 2],
|
||||
["=", 3, 3],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("a one-line edit in a long file stays cheap and local", () => {
|
||||
const big = Array.from({ length: 5000 }, (_, i) => "line " + i);
|
||||
const b = big.slice();
|
||||
b[2500] = "changed";
|
||||
const d = lcsDiff(big, b);
|
||||
assert.equal(d.length, 5001); // 5000 context + 1 added, 1 removed, minus the replaced line
|
||||
assert.deepEqual(
|
||||
d.filter((l) => l.op !== "=").map((l) => l.op + l.line),
|
||||
["-line 2500", "+changed"],
|
||||
);
|
||||
});
|
||||
|
||||
test("past the cell budget it degrades to whole-file replacement", () => {
|
||||
const a = Array.from({ length: 2100 }, (_, i) => "a" + i);
|
||||
const b = Array.from({ length: 2100 }, (_, i) => "b" + i);
|
||||
const d = lcsDiff(a, b);
|
||||
assert.equal(d.filter((l) => l.op === "-").length, 2100);
|
||||
assert.equal(d.filter((l) => l.op === "+").length, 2100);
|
||||
assert.equal(d.filter((l) => l.op === "=").length, 0);
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
// Line-level diff between two versions of a file. Pure, no React, no
|
||||
// dependency: an LCS is ~40 lines, which is less code than auditing a diff
|
||||
// package would be. Unit-tested in diff.test.ts (`npm test`).
|
||||
|
||||
export type DiffOp = "=" | "+" | "-";
|
||||
|
||||
export interface DiffLine {
|
||||
op: DiffOp;
|
||||
line: string;
|
||||
an?: number; // 1-based line number on the old side
|
||||
bn?: number; // 1-based line number on the new side
|
||||
}
|
||||
|
||||
// splitLines treats a trailing newline as ending the last line, not as
|
||||
// starting an empty one — so "a\n" and "a" are both one line, and only the
|
||||
// no-trailing-newline case differs from a file that has one.
|
||||
export function splitLines(text: string): string[] {
|
||||
if (text === "") return [];
|
||||
const lines = text.split("\n");
|
||||
if (lines[lines.length - 1] === "") lines.pop();
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Building the LCS table is O(n·m) in time and memory. Beyond this many
|
||||
// cells we stop and report the change coarsely instead.
|
||||
// ponytail: whole-file replacement past the budget; switch to a Myers
|
||||
// diff (O(nd), no table) if real files start hitting it.
|
||||
const CELL_BUDGET = 4_000_000;
|
||||
|
||||
export function lcsDiff(a: string[], b: string[]): DiffLine[] {
|
||||
// Equal prefix and suffix are the common case — a one-line edit in a
|
||||
// 2,000-line file — and trimming them keeps the table tiny.
|
||||
let p = 0;
|
||||
while (p < a.length && p < b.length && a[p] === b[p]) p++;
|
||||
let s = 0;
|
||||
while (s < a.length - p && s < b.length - p && a[a.length - 1 - s] === b[b.length - 1 - s]) s++;
|
||||
|
||||
const out: DiffLine[] = [];
|
||||
for (let i = 0; i < p; i++) out.push({ op: "=", line: a[i], an: i + 1, bn: i + 1 });
|
||||
|
||||
const am = a.slice(p, a.length - s);
|
||||
const bm = b.slice(p, b.length - s);
|
||||
const n = am.length;
|
||||
const m = bm.length;
|
||||
const del = (i: number) => out.push({ op: "-", line: am[i], an: p + i + 1 });
|
||||
const add = (j: number) => out.push({ op: "+", line: bm[j], bn: p + j + 1 });
|
||||
|
||||
if (n * m > CELL_BUDGET) {
|
||||
for (let i = 0; i < n; i++) del(i);
|
||||
for (let j = 0; j < m; j++) add(j);
|
||||
} else {
|
||||
// L[i][j] = length of the LCS of am[i:] and bm[j:].
|
||||
const L: Uint32Array[] = [];
|
||||
for (let i = 0; i <= n; i++) L.push(new Uint32Array(m + 1));
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
L[i][j] =
|
||||
am[i] === bm[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1]);
|
||||
}
|
||||
}
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (am[i] === bm[j]) {
|
||||
out.push({ op: "=", line: am[i], an: p + i + 1, bn: p + j + 1 });
|
||||
i++;
|
||||
j++;
|
||||
} else if (L[i + 1][j] >= L[i][j + 1]) {
|
||||
del(i++);
|
||||
} else {
|
||||
add(j++);
|
||||
}
|
||||
}
|
||||
while (i < n) del(i++);
|
||||
while (j < m) add(j++);
|
||||
}
|
||||
|
||||
for (let k = 0; k < s; k++) {
|
||||
const ai = a.length - s + k;
|
||||
out.push({ op: "=", line: a[ai], an: ai + 1, bn: b.length - s + k + 1 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// diffText is the whole job: two blobs of text in, the rendered lines and
|
||||
// the +N −M counts out.
|
||||
export function diffText(prev: string, next: string): { lines: DiffLine[]; add: number; del: number } {
|
||||
const lines = lcsDiff(splitLines(prev), splitLines(next));
|
||||
return {
|
||||
lines,
|
||||
add: lines.filter((l) => l.op === "+").length,
|
||||
del: lines.filter((l) => l.op === "-").length,
|
||||
};
|
||||
}
|
||||
@@ -618,6 +618,30 @@ a.ai-main:hover { color: var(--accent); }
|
||||
.hnote a { color: var(--accent-bright); text-decoration: none; }
|
||||
.hnote a:hover { text-decoration: underline; }
|
||||
|
||||
/* ---- per-version diff (per-file history) ---- */
|
||||
.hdiff-btn { display: inline-flex; align-items: center; gap: 4px; margin: 6px 0 0 23px; padding: 2px 7px 2px 4px; border: 1px solid var(--border); border-radius: 5px; background: none; color: var(--text-faint); font: inherit; font-size: 12px; cursor: pointer; }
|
||||
.hdiff-btn:hover { color: var(--text); border-color: var(--border-2); background: var(--hover); }
|
||||
.hdiff-btn .ico { width: 12px; height: 12px; }
|
||||
.hdiff-none { margin: 6px 0 0 23px; font-size: 12px; color: var(--text-ghost); }
|
||||
.dv { margin: 8px 0 2px 23px; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
|
||||
.dv-msg { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding: 9px 11px; font-size: 12px; color: var(--text-faint); }
|
||||
.dv-dl { display: flex; gap: 12px; }
|
||||
.dv-msg a { color: var(--accent-bright); text-decoration: none; }
|
||||
.dv-msg a:hover { text-decoration: underline; }
|
||||
.dv-head { display: flex; align-items: center; gap: 10px; padding: 5px 11px; border-bottom: 1px solid var(--border); font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
.dv-add { color: var(--add); font-weight: 600; }
|
||||
.dv-del { color: var(--del); font-weight: 600; }
|
||||
.dv-same { color: var(--text-ghost); }
|
||||
/* The diff scrolls inside its own box; the page body never scrolls sideways. */
|
||||
.dv-body { overflow-x: auto; padding: 4px 0; }
|
||||
.dv-line { display: flex; font-family: var(--mono); font-size: 12px; line-height: 1.55; white-space: pre; }
|
||||
.dv-n { flex: none; width: 34px; padding-right: 8px; text-align: right; color: var(--text-ghost); user-select: none; font-variant-numeric: tabular-nums; }
|
||||
.dv-mark { flex: none; width: 16px; text-align: center; user-select: none; }
|
||||
.dv-text { padding-right: 12px; }
|
||||
.dv-ins { background: rgba(76,195,138,.10); color: var(--add); }
|
||||
.dv-rm { background: rgba(242,109,109,.10); color: #ff8b8b; }
|
||||
.dv-ctx { color: var(--text-dim); }
|
||||
|
||||
/* ---- command palette ---- */
|
||||
#palette { position: fixed; top: 12vh; left: 50%; transform: translateX(-50%); z-index: 151; display: block; width: min(560px, 92vw); background: var(--bg-raise); border: 1px solid var(--border-2); border-radius: var(--r-over); box-shadow: 0 24px 70px -18px rgba(0,0,0,.8); overflow: hidden; outline: none; padding: 0; }
|
||||
#palette-inputwrap { display: flex; align-items: center; gap: 11px; padding: 14px 16px; border-bottom: 1px solid var(--border); }
|
||||
|
||||
@@ -15,5 +15,8 @@
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
// The unit tests run on node (`npm test`), not in the browser: they
|
||||
// import node: builtins that this DOM-only lib set does not know about.
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+16
-15
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>BearDrive</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
|
||||
<script type="module" crossorigin src="/assets/index-BqTFxxiz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CPzZXr-O.css">
|
||||
<script type="module" crossorigin src="/assets/index-Czoh6fKl.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CzGgqyHx.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user