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:
Snow W. Lee (Sungwon)
2026-07-28 07:02:46 +09:00
committed by GitHub
co-authored by Claude Fable 5
parent 63612743df
commit 224b3d6f53
16 changed files with 511 additions and 23 deletions
+12 -3
View File
@@ -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
+3
View File
@@ -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.
+61
View File
@@ -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);
+1
View File
@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"test": "node --test 'src/**/*.test.ts'",
"e2e": "playwright test -c e2e"
},
"dependencies": {
+9
View File
@@ -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);
});
+94
View File
@@ -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,
};
}
+24
View File
@@ -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); }
+4 -1
View File
@@ -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"]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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>