Deletes the vanilla shell and mounts App.tsx on #root. main.ts and the 23 views it drove are gone; App.tsx composes the ported components — sidebar, multi-view, command bar, spawn modal, help and restart overlays, daemon link, drawer — around the catalog and layout stores. The pure parts of four deleted modules moved next to the components that still use them: the spawn-form model and spawnSimilar into components/spawn, the transcript's markdown/format helpers and the diff into components/structured, the help overlay's grouping into components/dialogs. Document-level event names now live in uiEvents.ts. Deduplicated two pairs the migration left side by side: the sidebar uses dialogs/KillDialog and common/ContextMenu. It also listens for the command bar's kill/rename events, which nothing had picked up since catalog.ts went away. styles.css stays as the token and base layer, with `#app` renamed to `#root` and the pane-body rules re-keyed off the classes the React panes actually carry. Closes #253
168 lines
5.8 KiB
TypeScript
168 lines
5.8 KiB
TypeScript
// Line-level diff for a `ToolDiff` row's `Diff` content block (#140). ACP
|
|
// hands over full `oldText`/`newText`, not a unified-diff patch, so the
|
|
// coloured hunks the design calls for have to be computed here.
|
|
|
|
export type DiffLineKind = "add" | "del" | "ctx" | "hunk" | "eof";
|
|
|
|
export interface DiffLine {
|
|
kind: DiffLineKind;
|
|
/** The line's text (a `hunk` line is a human summary, not source text). */
|
|
text: string;
|
|
}
|
|
|
|
/** Interior unchanged runs longer than this collapse to a summary line
|
|
* flanked by this many lines of real context on each side. */
|
|
const CONTEXT = 3;
|
|
|
|
/** Bounds the O(n·m) LCS table: above this cell count, the changed middle is
|
|
* shown as one coarse del-then-add block instead of a fine-grained diff —
|
|
* still correct, just not minimal. */
|
|
const LCS_CELL_LIMIT = 250_000;
|
|
|
|
interface SourceLine {
|
|
text: string;
|
|
terminated: boolean;
|
|
}
|
|
|
|
function splitLines(text: string): SourceLine[] {
|
|
if (text === "") return [];
|
|
const parts = text.split("\n");
|
|
const hasFinalNewline = text.endsWith("\n");
|
|
if (hasFinalNewline) parts.pop();
|
|
return parts.map((line, index) => ({
|
|
text: line,
|
|
terminated: index < parts.length - 1 || hasFinalNewline,
|
|
}));
|
|
}
|
|
|
|
type Op = { kind: "add" | "del" | "ctx"; line: SourceLine };
|
|
|
|
function sameLine(a: SourceLine, b: SourceLine): boolean {
|
|
return a.text === b.text && a.terminated === b.terminated;
|
|
}
|
|
|
|
/** Longest-common-subsequence diff of two line arrays, already trimmed of any
|
|
* shared prefix/suffix by the caller. */
|
|
function lcsDiff(a: SourceLine[], b: SourceLine[]): Op[] {
|
|
const n = a.length;
|
|
const m = b.length;
|
|
if (n === 0) return b.map((line) => ({ kind: "add", line }));
|
|
if (m === 0) return a.map((line) => ({ kind: "del", line }));
|
|
if (n * m > LCS_CELL_LIMIT) {
|
|
return [...a.map((line) => ({ kind: "del" as const, line })), ...b.map((line) => ({ kind: "add" as const, line }))];
|
|
}
|
|
// dp[i][j] = length of the LCS of a[i:] and b[j:].
|
|
const columns = m + 1;
|
|
const dp = new Uint32Array((n + 1) * columns);
|
|
for (let i = n - 1; i >= 0; i--) {
|
|
for (let j = m - 1; j >= 0; j--) {
|
|
dp[i * columns + j] = sameLine(a[i], b[j])
|
|
? dp[(i + 1) * columns + j + 1] + 1
|
|
: Math.max(dp[(i + 1) * columns + j], dp[i * columns + j + 1]);
|
|
}
|
|
}
|
|
const ops: Op[] = [];
|
|
let i = 0;
|
|
let j = 0;
|
|
while (i < n && j < m) {
|
|
if (sameLine(a[i], b[j])) {
|
|
ops.push({ kind: "ctx", line: a[i] });
|
|
i++;
|
|
j++;
|
|
} else if (dp[(i + 1) * columns + j] >= dp[i * columns + j + 1]) {
|
|
ops.push({ kind: "del", line: a[i] });
|
|
i++;
|
|
} else {
|
|
ops.push({ kind: "add", line: b[j] });
|
|
j++;
|
|
}
|
|
}
|
|
while (i < n) ops.push({ kind: "del", line: a[i++] });
|
|
while (j < m) ops.push({ kind: "add", line: b[j++] });
|
|
return ops;
|
|
}
|
|
|
|
function diffLines(a: SourceLine[], b: SourceLine[]): Op[] {
|
|
let pre = 0;
|
|
while (pre < a.length && pre < b.length && sameLine(a[pre], b[pre])) pre++;
|
|
let suf = 0;
|
|
while (
|
|
suf < a.length - pre &&
|
|
suf < b.length - pre &&
|
|
sameLine(a[a.length - 1 - suf], b[b.length - 1 - suf])
|
|
) {
|
|
suf++;
|
|
}
|
|
const prefixCtx: Op[] = a.slice(0, pre).map((line) => ({ kind: "ctx", line }));
|
|
const suffixCtx: Op[] = a.slice(a.length - suf).map((line) => ({ kind: "ctx", line }));
|
|
const middle = lcsDiff(a.slice(pre, a.length - suf), b.slice(pre, b.length - suf));
|
|
return [...prefixCtx, ...middle, ...suffixCtx];
|
|
}
|
|
|
|
/** Collapse a long run of unchanged lines to a summary, keeping up to
|
|
* {@link CONTEXT} real lines adjacent to each neighbouring change — same
|
|
* shape a unified diff gives a reader, without claiming line numbers this
|
|
* module never tracked. A run touching the start/end of the whole diff has
|
|
* no change to be adjacent to on that side, so it keeps no context there
|
|
* (unless it is the *only* run, i.e. nothing changed at all). */
|
|
function hunk(ops: Op[]): DiffLine[] {
|
|
const out: DiffLine[] = [];
|
|
const push = (op: Op) => {
|
|
out.push({ kind: op.kind, text: op.line.text });
|
|
if (!op.line.terminated) {
|
|
out.push({ kind: "eof", text: "\\ No newline at end of file" });
|
|
}
|
|
};
|
|
let i = 0;
|
|
while (i < ops.length) {
|
|
if (ops[i].kind !== "ctx") {
|
|
push(ops[i]);
|
|
i++;
|
|
continue;
|
|
}
|
|
let j = i;
|
|
while (j < ops.length && ops[j].kind === "ctx") j++;
|
|
const run = ops.slice(i, j);
|
|
const wholeDiff = i === 0 && j === ops.length;
|
|
const keepHead = wholeDiff || i > 0 ? CONTEXT : 0;
|
|
const keepTail = wholeDiff || j < ops.length ? CONTEXT : 0;
|
|
if (run.length <= keepHead + keepTail) {
|
|
run.forEach(push);
|
|
} else {
|
|
if (keepHead) run.slice(0, keepHead).forEach(push);
|
|
const collapsed = run.slice(keepHead, run.length - keepTail);
|
|
out.push({
|
|
kind: "hunk",
|
|
text: `⋯ ${collapsed.length} unchanged lines ⋯`,
|
|
});
|
|
if (collapsed.some((op) => !op.line.terminated)) {
|
|
out.push({ kind: "eof", text: "\\ No newline at end of file" });
|
|
}
|
|
if (keepTail) run.slice(run.length - keepTail).forEach(push);
|
|
}
|
|
i = j;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Coloured, hunked diff lines for a `Diff` content block. `oldText` absent
|
|
* (a new file) renders every line of `newText` as added. */
|
|
export function computeDiffLines(oldText: string | null | undefined, newText: string): DiffLine[] {
|
|
const newLines = splitLines(newText);
|
|
if (oldText == null) return hunk(newLines.map((line) => ({ kind: "add", line })));
|
|
const ops = diffLines(splitLines(oldText), newLines);
|
|
return hunk(ops);
|
|
}
|
|
|
|
/** `+added -removed` counts straight off the diff — real derived data, not a
|
|
* fabricated duration or line count. */
|
|
export function diffStats(lines: DiffLine[]): { added: number; removed: number } {
|
|
let added = 0;
|
|
let removed = 0;
|
|
for (const line of lines) {
|
|
if (line.kind === "add") added++;
|
|
else if (line.kind === "del") removed++;
|
|
}
|
|
return { added, removed };
|
|
}
|