atom-agent 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -2
- package/README.md +17 -16
- package/dist/App.js +1010 -77
- package/dist/adapters.js +108 -8
- package/dist/agent/gates.js +14 -1
- package/dist/agent/loop-guard.js +182 -0
- package/dist/agent/loop.js +781 -329
- package/dist/agent/normalize.js +151 -0
- package/dist/cli.js +16 -2
- package/dist/compact.js +128 -2
- package/dist/env-block.js +43 -5
- package/dist/scheduler.js +101 -21
- package/dist/sessions.js +524 -0
- package/dist/system.js +89 -12
- package/dist/telemetry-dashboard.js +19 -1
- package/dist/telemetry.js +55 -0
- package/dist/tools/dir-cache.js +214 -0
- package/dist/tools/filesystem.js +43 -3
- package/dist/tools/read-cache.js +160 -0
- package/dist/tools/registry.js +80 -0
- package/dist/tools/ripgrep.js +256 -0
- package/dist/tools/search.js +147 -80
- package/dist/tools/shared.js +39 -0
- package/dist/tools/shell.js +26 -5
- package/dist/tools/todo.js +1 -1
- package/dist/tools/web.js +6 -6
- package/dist/tools.js +3 -0
- package/dist/ui/diff-panel.js +55 -0
- package/dist/ui/diff-view.js +117 -0
- package/dist/ui/diff.js +422 -0
- package/dist/ui/highlight.js +120 -0
- package/dist/ui/live-host.js +18 -0
- package/dist/ui/live-tail.js +9 -3
- package/dist/ui/markdown.js +26 -2
- package/dist/ui/modals.js +22 -5
- package/dist/ui/palette.js +12 -2
- package/dist/ui/side-by-side.js +144 -0
- package/dist/ui/status-bar.js +20 -4
- package/dist/ui/status-host.js +22 -0
- package/dist/ui/stream-store.js +48 -0
- package/dist/ui/theme.js +6 -0
- package/dist/ui/todo-panel.js +10 -2
- package/dist/ui/tool-inspector.js +7 -1
- package/dist/ui/transcript.js +105 -39
- package/dist/zen.js +97 -20
- package/package.json +1 -1
package/dist/tools/web.js
CHANGED
|
@@ -5,7 +5,7 @@ import * as net from "node:net";
|
|
|
5
5
|
import { loadAtomConfig } from "../config.js";
|
|
6
6
|
import { classifyIp, defaultNetworkPolicy, isRedirectStatus, zoneAllows, zoneForAddresses, } from "../policy.js";
|
|
7
7
|
import { appendOverflow } from "./overflow.js";
|
|
8
|
-
import { err, READ_CHAR_CAP } from "./shared.js";
|
|
8
|
+
import { err, READ_CHAR_CAP, truncateHead } from "./shared.js";
|
|
9
9
|
// ---- Web tools (webfetch retrieval / websearch discovery) ----
|
|
10
10
|
const WEB_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";
|
|
11
11
|
const WEBFETCH_DOWNLOAD_CAP = 1024 * 1024; // ~1MB download cap
|
|
@@ -318,12 +318,12 @@ export async function webfetchTool(args, opts) {
|
|
|
318
318
|
notes.push("[truncated: download exceeded ~1MB]");
|
|
319
319
|
if (text.length > READ_CHAR_CAP) {
|
|
320
320
|
const full = text;
|
|
321
|
-
const
|
|
322
|
-
text = head;
|
|
323
|
-
notes.push(
|
|
321
|
+
const t = truncateHead(full, READ_CHAR_CAP, "\n[truncated: output exceeded 64KB]");
|
|
322
|
+
text = t.head;
|
|
323
|
+
notes.push(t.note.replace(/^\n/, ""));
|
|
324
324
|
// Single spill: recover the pointer line from the composed tail.
|
|
325
|
-
const tailed = appendOverflow(head,
|
|
326
|
-
const overflowLine = tailed.slice((head +
|
|
325
|
+
const tailed = appendOverflow(t.head, t.note, "converted page text", full);
|
|
326
|
+
const overflowLine = tailed.slice((t.head + t.note + "\n").length);
|
|
327
327
|
if (overflowLine.startsWith("[overflow:"))
|
|
328
328
|
notes.push(overflowLine);
|
|
329
329
|
}
|
package/dist/tools.js
CHANGED
|
@@ -10,8 +10,11 @@
|
|
|
10
10
|
// `"../src/tools.js"` import keeps working untouched.
|
|
11
11
|
export * from "./tools/filesystem.js";
|
|
12
12
|
export * from "./tools/fingerprints.js";
|
|
13
|
+
export * from "./tools/dir-cache.js";
|
|
13
14
|
export * from "./tools/overflow.js";
|
|
15
|
+
export * from "./tools/read-cache.js";
|
|
14
16
|
export * from "./tools/registry.js";
|
|
17
|
+
export * from "./tools/ripgrep.js";
|
|
15
18
|
export * from "./tools/search.js";
|
|
16
19
|
export * from "./tools/shared.js";
|
|
17
20
|
export * from "./tools/shell.js";
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { computeDiff } from "./diff.js";
|
|
4
|
+
import { SideBySideDiffView } from "./side-by-side.js";
|
|
5
|
+
import { theme } from "./theme.js";
|
|
6
|
+
import { LIST_WINDOW, windowedList } from "./tool-inspector.js";
|
|
7
|
+
// Cap for the panel detail (hunk headers excluded): full-file writes can
|
|
8
|
+
// be large — the head reviews, the trailer names the remainder, the file
|
|
9
|
+
// on disk is the whole truth.
|
|
10
|
+
export const DIFF_PANEL_MAX_LINES = 200;
|
|
11
|
+
// Group committed session previews by file: latest preview per path
|
|
12
|
+
// wins (a later edit supersedes the earlier view of the same file),
|
|
13
|
+
// first-seen path order kept. Previews without a path are ungroupable
|
|
14
|
+
// (they still render inline in the transcript) and skipped here.
|
|
15
|
+
// Pure — shared by the App opener and unit tests.
|
|
16
|
+
export function groupSessionDiffs(turns) {
|
|
17
|
+
const byPath = new Map();
|
|
18
|
+
for (const t of turns) {
|
|
19
|
+
const d = t.diff;
|
|
20
|
+
if (!d || !d.path)
|
|
21
|
+
continue;
|
|
22
|
+
byPath.set(d.path, d);
|
|
23
|
+
}
|
|
24
|
+
const files = [];
|
|
25
|
+
for (const [p, d] of byPath) {
|
|
26
|
+
let adds = 0;
|
|
27
|
+
let dels = 0;
|
|
28
|
+
try {
|
|
29
|
+
const r = computeDiff(d.oldText, d.newText);
|
|
30
|
+
adds = r.adds;
|
|
31
|
+
dels = r.dels;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// A preview that fails to diff still lists (counts stay 0) —
|
|
35
|
+
// review must never crash on data it already rendered inline.
|
|
36
|
+
}
|
|
37
|
+
files.push({ path: p, oldText: d.oldText, newText: d.newText, lang: d.lang, adds, dels });
|
|
38
|
+
}
|
|
39
|
+
return files;
|
|
40
|
+
}
|
|
41
|
+
export function DiffPanel({ files, index, expanded }) {
|
|
42
|
+
const sel = Math.max(0, Math.min(index, files.length - 1));
|
|
43
|
+
const rec = files[sel];
|
|
44
|
+
if (!rec)
|
|
45
|
+
return null;
|
|
46
|
+
if (!expanded) {
|
|
47
|
+
const win = windowedList(files, sel, LIST_WINDOW);
|
|
48
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Session changes \u2014 select a file to review (Enter expands, Esc closes):" }), win.above > 0 ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.moreAbove, " ", win.above, " more"] })) : null, win.slice.map((r, k) => {
|
|
49
|
+
const i = win.start + k;
|
|
50
|
+
const hi = i === sel;
|
|
51
|
+
return (_jsxs(Text, { color: hi ? theme.color.selection : undefined, children: [hi ? `${theme.symbol.select} ` : theme.spacing.rowIndent, "+", r.adds, " \u2212", r.dels, " ", r.path] }, r.path));
|
|
52
|
+
}), win.below > 0 ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.moreBelow, " ", win.below, " more"] })) : null, _jsxs(Text, { dimColor: true, children: [theme.symbol.moreAbove, "/", theme.symbol.moreBelow, " move \u00B7 Enter expands \u00B7 Esc closes"] })] }));
|
|
53
|
+
}
|
|
54
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, children: [rec.path, _jsxs(Text, { dimColor: true, children: [" ", theme.symbol.separator, " +", rec.adds, " \u2212", rec.dels] })] }), _jsx(Text, { dimColor: true, children: theme.symbol.rule.repeat(32) }), _jsx(SideBySideDiffView, { oldText: rec.oldText, newText: rec.newText, lang: rec.lang, maxRows: DIFF_PANEL_MAX_LINES }), _jsx(Text, { dimColor: true, children: theme.symbol.rule.repeat(32) }), _jsxs(Text, { dimColor: true, children: [theme.symbol.moreAbove, "/", theme.symbol.moreBelow, " prev/next file \u00B7 Enter collapses \u00B7 Esc closes"] })] }));
|
|
55
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Approval-time + transcript diff view: the unified, word-highlighted,
|
|
3
|
+
// syntax-colored preview for write/edit calls.
|
|
4
|
+
//
|
|
5
|
+
// Claude-Code parity, ATOM-ified: hunk headers + `-`/`+` lines +
|
|
6
|
+
// changed-word backgrounds + syntax foregrounds (ui/highlight, zero-dep),
|
|
7
|
+
// one <Text> per run (no per-character nodes), word diff + highlighting
|
|
8
|
+
// memoized/cached so the 1s busy tick never recomputes. Long lines are
|
|
9
|
+
// never truncated (Ink wraps; copy/paste stays intact) — only hunk COUNT
|
|
10
|
+
// is capped via maxLines so the modal stays compact. All paint comes from
|
|
11
|
+
// ui/theme tokens (Ink supports color + backgroundColor on Text — verified
|
|
12
|
+
// against the installed Ink 7 typings).
|
|
13
|
+
import React from "react";
|
|
14
|
+
import { Box, Text } from "ink";
|
|
15
|
+
import { computeDiff } from "./diff.js";
|
|
16
|
+
import { highlightLine } from "./highlight.js";
|
|
17
|
+
import { theme } from "./theme.js";
|
|
18
|
+
function syntaxColor(kind) {
|
|
19
|
+
if (kind === "keyword")
|
|
20
|
+
return theme.color.synKeyword;
|
|
21
|
+
if (kind === "string")
|
|
22
|
+
return theme.color.synString;
|
|
23
|
+
if (kind === "number")
|
|
24
|
+
return theme.color.synNumber;
|
|
25
|
+
return undefined; // comment → dim, plain → line paint
|
|
26
|
+
}
|
|
27
|
+
// One diff line body: word-diff runs sub-split by syntax spans. Both
|
|
28
|
+
// segmentations tile the same line text, so walking them together with
|
|
29
|
+
// an offset cursor paints every char exactly once (text integrity is
|
|
30
|
+
// pinned by tests — highlighting must never alter content). Exported:
|
|
31
|
+
// the side-by-side view (ui/side-by-side) reuses it per pane cell.
|
|
32
|
+
//
|
|
33
|
+
// Memoized: props are (lineText, runs, base, lang) — `runs` keeps identity
|
|
34
|
+
// from the parent's useMemo'd diff, and highlightLine is itself line-cached,
|
|
35
|
+
// so unrelated parent renders (ticks, keystrokes, appends elsewhere) skip
|
|
36
|
+
// both the walk and the tokenize lookup.
|
|
37
|
+
export const LineBody = React.memo(function LineBody({ lineText, runs, base, lang, }) {
|
|
38
|
+
const baseColor = base === "add" ? theme.color.success : theme.color.toolError;
|
|
39
|
+
const hlBg = base === "add" ? "green" : "red";
|
|
40
|
+
const langKnown = lang === "c" || lang === "py" || lang === "sh" || lang === "data";
|
|
41
|
+
const syn = langKnown ? highlightLine(lineText, lang) : [];
|
|
42
|
+
const nodes = [];
|
|
43
|
+
let offset = 0;
|
|
44
|
+
let synIdx = 0;
|
|
45
|
+
runs.forEach((r, k) => {
|
|
46
|
+
const runStart = offset;
|
|
47
|
+
const runEnd = offset + r.text.length;
|
|
48
|
+
offset = runEnd;
|
|
49
|
+
if (r.changed) {
|
|
50
|
+
// Changed words keep the high-contrast background treatment —
|
|
51
|
+
// syntax hues would muddy the signal.
|
|
52
|
+
nodes.push(_jsx(Text, { backgroundColor: hlBg, color: "black", bold: true, children: r.text }, k));
|
|
53
|
+
// Advance the syntax cursor past this run so later runs align.
|
|
54
|
+
while (synIdx < syn.length && syn[synIdx].end <= runEnd)
|
|
55
|
+
synIdx += 1;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
// Unchanged text: paint syntax spans; plain spans inherit the line
|
|
59
|
+
// paint (base tint for unknown files, terminal default when the
|
|
60
|
+
// language is known — the +/- prefix carries line identity there).
|
|
61
|
+
// Skip syntax runs that end before this run starts (can happen only
|
|
62
|
+
// if segmentations disagree — defensive, never expected).
|
|
63
|
+
while (synIdx < syn.length && syn[synIdx].end <= runStart)
|
|
64
|
+
synIdx += 1;
|
|
65
|
+
let si = synIdx;
|
|
66
|
+
const parts = [];
|
|
67
|
+
let pk = 0;
|
|
68
|
+
while (si < syn.length && syn[si].start < runEnd) {
|
|
69
|
+
const s = syn[si];
|
|
70
|
+
const a = Math.max(s.start, runStart);
|
|
71
|
+
const b = Math.min(s.end, runEnd);
|
|
72
|
+
if (b > a) {
|
|
73
|
+
const piece = r.text.slice(a - runStart, b - runStart);
|
|
74
|
+
const fg = syntaxColor(s.kind) ?? (langKnown ? undefined : baseColor);
|
|
75
|
+
parts.push(s.kind === "comment" ? (_jsx(Text, { dimColor: true, children: piece }, pk++)) : (_jsx(Text, { color: fg, children: piece }, pk++)));
|
|
76
|
+
}
|
|
77
|
+
if (s.end <= runEnd)
|
|
78
|
+
si += 1;
|
|
79
|
+
else
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
if (parts.length === 0) {
|
|
83
|
+
parts.push(_jsx(Text, { color: langKnown ? undefined : baseColor, children: r.text }, pk++));
|
|
84
|
+
}
|
|
85
|
+
nodes.push(_jsx(Text, { children: parts }, k));
|
|
86
|
+
});
|
|
87
|
+
return _jsx(Text, { color: langKnown ? undefined : baseColor, children: nodes });
|
|
88
|
+
});
|
|
89
|
+
function DiffViewInner({ oldText, newText, lang = null, maxLines = Infinity }) {
|
|
90
|
+
const diff = React.useMemo(() => computeDiff(oldText, newText), [oldText, newText]);
|
|
91
|
+
if (diff.skipped) {
|
|
92
|
+
return _jsx(Text, { dimColor: true, children: diff.skipped });
|
|
93
|
+
}
|
|
94
|
+
if (diff.hunks.length === 0) {
|
|
95
|
+
return _jsx(Text, { dimColor: true, children: "(no visible changes)" });
|
|
96
|
+
}
|
|
97
|
+
let bodyLines = 0;
|
|
98
|
+
const overflow = (() => {
|
|
99
|
+
let total = 0;
|
|
100
|
+
for (const h of diff.hunks)
|
|
101
|
+
total += h.lines.length;
|
|
102
|
+
return Math.max(0, total - maxLines);
|
|
103
|
+
})();
|
|
104
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [diff.isNewFile ? "new file " : "", "+", diff.adds, " \u2212", diff.dels] }), diff.hunks.map((h, hi) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["@@ -", h.oldStart, ",", h.oldLines, " +", h.newStart, ",", h.newLines, " @@"] }), h.lines.map((ln, k) => {
|
|
105
|
+
bodyLines += 1;
|
|
106
|
+
if (bodyLines > maxLines)
|
|
107
|
+
return null;
|
|
108
|
+
if (ln.kind === "context") {
|
|
109
|
+
return (_jsxs(Text, { dimColor: true, children: [" ", ln.text.length > 0 ? ln.text : " "] }, k));
|
|
110
|
+
}
|
|
111
|
+
if (ln.kind === "del") {
|
|
112
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: theme.color.toolError, children: "- " }), _jsx(LineBody, { lineText: ln.text, runs: ln.runs, base: "del", lang: lang }), ln.text.length === 0 ? " " : null] }, k));
|
|
113
|
+
}
|
|
114
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: theme.color.success, children: "+ " }), _jsx(LineBody, { lineText: ln.text, runs: ln.runs, base: "add", lang: lang }), ln.text.length === 0 ? " " : null] }, k));
|
|
115
|
+
})] }, hi))), overflow > 0 ? _jsxs(Text, { dimColor: true, children: ["\u2026 ", overflow, " more line", overflow === 1 ? "" : "s"] }) : null, diff.truncated ? _jsx(Text, { dimColor: true, children: "(diff truncated at 400 changed lines)" }) : null] }));
|
|
116
|
+
}
|
|
117
|
+
export const DiffView = React.memo(DiffViewInner);
|
package/dist/ui/diff.js
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
// Mirrors Claude's StructuredDiffFallback threshold: when the share of
|
|
2
|
+
// changed words in a paired line exceeds this, word highlighting would
|
|
3
|
+
// be noise — render the whole line flat instead.
|
|
4
|
+
export const CHANGE_THRESHOLD = 0.4;
|
|
5
|
+
// Mirrors Claude's DiffDetailView guards.
|
|
6
|
+
export const MAX_FILE_BYTES = 1_000_000;
|
|
7
|
+
export const MAX_CHANGED_LINES = 400;
|
|
8
|
+
export const DIFF_CONTEXT = 3;
|
|
9
|
+
// Worst-case guards for the O(ND) Myers pass + O(w1*w2) word pass.
|
|
10
|
+
const MAX_MYERS_LINES = 1000;
|
|
11
|
+
const MAX_WORD_TOKENS = 200;
|
|
12
|
+
function splitLines(s) {
|
|
13
|
+
if (s === "")
|
|
14
|
+
return [];
|
|
15
|
+
return s.replace(/\r\n?/g, "\n").split("\n");
|
|
16
|
+
}
|
|
17
|
+
function isBinary(s) {
|
|
18
|
+
return s.includes("\0");
|
|
19
|
+
}
|
|
20
|
+
// Tokenize into words + whitespace runs (whitespace kept so runs
|
|
21
|
+
// rejoin byte-identical to the source line).
|
|
22
|
+
function tokenize(line) {
|
|
23
|
+
const out = line.split(/(\s+)/g).filter((t) => t.length > 0);
|
|
24
|
+
return out.length > 0 ? out : [line];
|
|
25
|
+
}
|
|
26
|
+
function isWord(tok) {
|
|
27
|
+
return /\S/.test(tok);
|
|
28
|
+
}
|
|
29
|
+
// LCS table for small sequences (lines or words). Returns the matched
|
|
30
|
+
// index pairs in order.
|
|
31
|
+
function lcsPairs(a, b, eq) {
|
|
32
|
+
const n = a.length;
|
|
33
|
+
const m = b.length;
|
|
34
|
+
if (n === 0 || m === 0)
|
|
35
|
+
return [];
|
|
36
|
+
// Uint16 caps at 65535 — sequences here are small (words) or
|
|
37
|
+
// Myers-capped (lines); guard anyway.
|
|
38
|
+
const use32 = n > 6000 || m > 6000;
|
|
39
|
+
const w = m + 1;
|
|
40
|
+
const dp = use32 ? new Uint32Array((n + 1) * w) : new Uint16Array((n + 1) * w);
|
|
41
|
+
for (let i = 1; i <= n; i++) {
|
|
42
|
+
for (let j = 1; j <= m; j++) {
|
|
43
|
+
const v = eq(a[i - 1], b[j - 1])
|
|
44
|
+
? dp[(i - 1) * w + (j - 1)] + 1
|
|
45
|
+
: Math.max(dp[(i - 1) * w + j], dp[i * w + (j - 1)]);
|
|
46
|
+
dp[i * w + j] = v > 65535 && !use32 ? 65535 : v;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const pairs = [];
|
|
50
|
+
let i = n;
|
|
51
|
+
let j = m;
|
|
52
|
+
while (i > 0 && j > 0) {
|
|
53
|
+
if (eq(a[i - 1], b[j - 1])) {
|
|
54
|
+
pairs.push([i - 1, j - 1]);
|
|
55
|
+
i -= 1;
|
|
56
|
+
j -= 1;
|
|
57
|
+
}
|
|
58
|
+
else if (dp[(i - 1) * w + j] >= dp[i * w + (j - 1)]) {
|
|
59
|
+
i -= 1;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
j -= 1;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
pairs.reverse();
|
|
66
|
+
return pairs;
|
|
67
|
+
}
|
|
68
|
+
// Myers O(ND) line diff with a hard cell budget — falls back to a
|
|
69
|
+
// single del/add block when the files are too big to trace exactly.
|
|
70
|
+
// Returns ops over old lines (a) and new lines (b).
|
|
71
|
+
function myersOps(a, b) {
|
|
72
|
+
const n = a.length;
|
|
73
|
+
const m = b.length;
|
|
74
|
+
if (n === 0)
|
|
75
|
+
return b.map((_, j) => ({ kind: "add", b: j }));
|
|
76
|
+
if (m === 0)
|
|
77
|
+
return a.map((_, i) => ({ kind: "del", a: i }));
|
|
78
|
+
if (n + m > MAX_MYERS_LINES) {
|
|
79
|
+
// Linear fallback: common prefix/suffix, middle is one block.
|
|
80
|
+
let pre = 0;
|
|
81
|
+
while (pre < n && pre < m && a[pre] === b[pre])
|
|
82
|
+
pre += 1;
|
|
83
|
+
let suf = 0;
|
|
84
|
+
while (suf < n - pre && suf < m - pre && a[n - 1 - suf] === b[m - 1 - suf])
|
|
85
|
+
suf += 1;
|
|
86
|
+
const ops = [];
|
|
87
|
+
for (let i = 0; i < pre; i++)
|
|
88
|
+
ops.push({ kind: "same", a: i, b: i });
|
|
89
|
+
for (let i = pre; i < n - suf; i++)
|
|
90
|
+
ops.push({ kind: "del", a: i });
|
|
91
|
+
for (let j = pre; j < m - suf; j++)
|
|
92
|
+
ops.push({ kind: "add", b: j });
|
|
93
|
+
for (let k = 0; k < suf; k++)
|
|
94
|
+
ops.push({ kind: "same", a: n - suf + k, b: m - suf + k });
|
|
95
|
+
return ops;
|
|
96
|
+
}
|
|
97
|
+
// Exact Myers when small: LCS pairs, then expand to ops.
|
|
98
|
+
const pairs = lcsPairs(a, b, (x, y) => x === y);
|
|
99
|
+
const ops = [];
|
|
100
|
+
let i = 0;
|
|
101
|
+
let j = 0;
|
|
102
|
+
for (const [pi, pj] of pairs) {
|
|
103
|
+
while (i < pi)
|
|
104
|
+
ops.push({ kind: "del", a: i++ });
|
|
105
|
+
while (j < pj)
|
|
106
|
+
ops.push({ kind: "add", b: j++ });
|
|
107
|
+
ops.push({ kind: "same", a: i++, b: j++ });
|
|
108
|
+
}
|
|
109
|
+
while (i < n)
|
|
110
|
+
ops.push({ kind: "del", a: i++ });
|
|
111
|
+
while (j < m)
|
|
112
|
+
ops.push({ kind: "add", b: j++ });
|
|
113
|
+
return ops;
|
|
114
|
+
}
|
|
115
|
+
function flatRuns(text) {
|
|
116
|
+
return text === "" ? [] : [{ text, changed: false }];
|
|
117
|
+
}
|
|
118
|
+
// Word-level pairing for one del/add line pair. Returns runs for both
|
|
119
|
+
// sides; falls back to flat (line-level) when the pair is too big or
|
|
120
|
+
// too changed (CHANGE_THRESHOLD). Exported: the side-by-side builder
|
|
121
|
+
// below and the cell renderer (ui/diff-view) share it.
|
|
122
|
+
export function wordRuns(delText, addText) {
|
|
123
|
+
const dt = tokenize(delText);
|
|
124
|
+
const at = tokenize(addText);
|
|
125
|
+
if (dt.length > MAX_WORD_TOKENS || at.length > MAX_WORD_TOKENS) {
|
|
126
|
+
return { del: flatRuns(delText), add: flatRuns(addText) };
|
|
127
|
+
}
|
|
128
|
+
const pairs = lcsPairs(dt, at, (x, y) => x === y);
|
|
129
|
+
const delKeep = new Set(pairs.map(([i]) => i));
|
|
130
|
+
const addKeep = new Set(pairs.map(([, j]) => j));
|
|
131
|
+
const dw = dt.filter((t, i) => !delKeep.has(i) && isWord(t)).length;
|
|
132
|
+
const aw = at.filter((t, j) => !addKeep.has(j) && isWord(t)).length;
|
|
133
|
+
// Share of changed words over ALL tokens (whitespace included — it
|
|
134
|
+
// almost always matches, so a single changed word in a normal line
|
|
135
|
+
// stays well under the threshold while near-total rewrites exceed
|
|
136
|
+
// it and fall back to line-level).
|
|
137
|
+
const denom = Math.max(dt.length, at.length, 1);
|
|
138
|
+
if ((dw + aw) / denom > CHANGE_THRESHOLD) {
|
|
139
|
+
return { del: flatRuns(delText), add: flatRuns(addText) };
|
|
140
|
+
}
|
|
141
|
+
const build = (toks, keep) => {
|
|
142
|
+
const runs = [];
|
|
143
|
+
for (let k = 0; k < toks.length; k++) {
|
|
144
|
+
const changed = !keep.has(k) && isWord(toks[k]);
|
|
145
|
+
const prev = runs[runs.length - 1];
|
|
146
|
+
if (prev && prev.changed === changed)
|
|
147
|
+
prev.text += toks[k];
|
|
148
|
+
else
|
|
149
|
+
runs.push({ text: toks[k], changed });
|
|
150
|
+
}
|
|
151
|
+
return runs;
|
|
152
|
+
};
|
|
153
|
+
return { del: build(dt, delKeep), add: build(at, addKeep) };
|
|
154
|
+
}
|
|
155
|
+
export function computeDiff(oldText, newText) {
|
|
156
|
+
const empty = {
|
|
157
|
+
hunks: [],
|
|
158
|
+
adds: 0,
|
|
159
|
+
dels: 0,
|
|
160
|
+
truncated: false,
|
|
161
|
+
skipped: null,
|
|
162
|
+
isNewFile: oldText === null,
|
|
163
|
+
};
|
|
164
|
+
if (isBinary(newText) || (oldText !== null && isBinary(oldText))) {
|
|
165
|
+
return { ...empty, skipped: "binary file — diff skipped" };
|
|
166
|
+
}
|
|
167
|
+
if (newText.length > MAX_FILE_BYTES || (oldText !== null && oldText.length > MAX_FILE_BYTES)) {
|
|
168
|
+
return { ...empty, skipped: "file over 1MB — diff skipped" };
|
|
169
|
+
}
|
|
170
|
+
const oldLines = oldText === null ? [] : splitLines(oldText);
|
|
171
|
+
const newLines = splitLines(newText);
|
|
172
|
+
if (oldText !== null && oldText === newText)
|
|
173
|
+
return empty;
|
|
174
|
+
const ops = myersOps(oldLines, newLines);
|
|
175
|
+
const raw = ops.map((op) => op.kind === "same"
|
|
176
|
+
? { kind: "same", text: oldLines[op.a] }
|
|
177
|
+
: op.kind === "del"
|
|
178
|
+
? { kind: "del", text: oldLines[op.a] }
|
|
179
|
+
: { kind: "add", text: newLines[op.b] });
|
|
180
|
+
const changeIdx = raw.map((r, i) => (r.kind === "same" ? -1 : i)).filter((i) => i >= 0);
|
|
181
|
+
if (changeIdx.length === 0)
|
|
182
|
+
return empty;
|
|
183
|
+
const hunks = [];
|
|
184
|
+
let adds = 0;
|
|
185
|
+
let dels = 0;
|
|
186
|
+
let truncated = false;
|
|
187
|
+
let hunkStart = Math.max(0, changeIdx[0] - DIFF_CONTEXT);
|
|
188
|
+
let hunkEnd = Math.min(raw.length, changeIdx[0] + DIFF_CONTEXT + 1);
|
|
189
|
+
const flush = (s, e) => {
|
|
190
|
+
const slice = raw.slice(s, e);
|
|
191
|
+
// Old/new line numbers at hunk start.
|
|
192
|
+
let o = 0;
|
|
193
|
+
let nn = 0;
|
|
194
|
+
for (let k = 0; k < s; k++) {
|
|
195
|
+
if (raw[k].kind !== "add")
|
|
196
|
+
o += 1;
|
|
197
|
+
if (raw[k].kind !== "del")
|
|
198
|
+
nn += 1;
|
|
199
|
+
}
|
|
200
|
+
const oldStart = o + 1;
|
|
201
|
+
const newStart = nn + 1;
|
|
202
|
+
let oCount = 0;
|
|
203
|
+
let nCount = 0;
|
|
204
|
+
// Pair consecutive del/add runs for word highlighting.
|
|
205
|
+
const lines = [];
|
|
206
|
+
let k = 0;
|
|
207
|
+
while (k < slice.length) {
|
|
208
|
+
const r = slice[k];
|
|
209
|
+
if (r.kind === "same") {
|
|
210
|
+
lines.push({ kind: "context", text: r.text });
|
|
211
|
+
oCount += 1;
|
|
212
|
+
nCount += 1;
|
|
213
|
+
k += 1;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const delRun = [];
|
|
217
|
+
const addRun = [];
|
|
218
|
+
while (k < slice.length && slice[k].kind === "del") {
|
|
219
|
+
delRun.push(slice[k].text);
|
|
220
|
+
k += 1;
|
|
221
|
+
}
|
|
222
|
+
while (k < slice.length && slice[k].kind === "add") {
|
|
223
|
+
addRun.push(slice[k].text);
|
|
224
|
+
k += 1;
|
|
225
|
+
}
|
|
226
|
+
const paired = Math.min(delRun.length, addRun.length);
|
|
227
|
+
for (let p = 0; p < paired; p++) {
|
|
228
|
+
const { del, add } = wordRuns(delRun[p], addRun[p]);
|
|
229
|
+
lines.push({ kind: "del", text: delRun[p], runs: del });
|
|
230
|
+
lines.push({ kind: "add", text: addRun[p], runs: add });
|
|
231
|
+
}
|
|
232
|
+
for (let p = paired; p < delRun.length; p++) {
|
|
233
|
+
lines.push({ kind: "del", text: delRun[p], runs: flatRuns(delRun[p]) });
|
|
234
|
+
}
|
|
235
|
+
for (let p = paired; p < addRun.length; p++) {
|
|
236
|
+
lines.push({ kind: "add", text: addRun[p], runs: flatRuns(addRun[p]) });
|
|
237
|
+
}
|
|
238
|
+
dels += delRun.length;
|
|
239
|
+
adds += addRun.length;
|
|
240
|
+
oCount += delRun.length;
|
|
241
|
+
nCount += addRun.length;
|
|
242
|
+
}
|
|
243
|
+
hunks.push({ oldStart, oldLines: oCount, newStart, newLines: nCount, lines });
|
|
244
|
+
};
|
|
245
|
+
let changed = 0;
|
|
246
|
+
for (let c = 1; c < changeIdx.length; c++) {
|
|
247
|
+
const prev = changeIdx[c - 1];
|
|
248
|
+
const cur = changeIdx[c];
|
|
249
|
+
if (cur - prev <= DIFF_CONTEXT * 2 + 1) {
|
|
250
|
+
hunkEnd = Math.min(raw.length, cur + DIFF_CONTEXT + 1);
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
flush(hunkStart, hunkEnd);
|
|
254
|
+
changed = hunks.reduce((t, h) => t + h.lines.filter((l) => l.kind !== "context").length, 0);
|
|
255
|
+
if (changed >= MAX_CHANGED_LINES) {
|
|
256
|
+
truncated = true;
|
|
257
|
+
return { hunks, adds, dels, truncated, skipped: null, isNewFile: oldText === null };
|
|
258
|
+
}
|
|
259
|
+
hunkStart = Math.max(0, cur - DIFF_CONTEXT);
|
|
260
|
+
hunkEnd = Math.min(raw.length, cur + DIFF_CONTEXT + 1);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
flush(hunkStart, hunkEnd);
|
|
264
|
+
changed = hunks.reduce((t, h) => t + h.lines.filter((l) => l.kind !== "context").length, 0);
|
|
265
|
+
if (changed > MAX_CHANGED_LINES) {
|
|
266
|
+
// Trim trailing hunks past the budget (keep the head — the user
|
|
267
|
+
// reviews top-down; the notice names the remainder).
|
|
268
|
+
let kept = 0;
|
|
269
|
+
const out = [];
|
|
270
|
+
for (const h of hunks) {
|
|
271
|
+
const n = h.lines.filter((l) => l.kind !== "context").length;
|
|
272
|
+
if (kept + n > MAX_CHANGED_LINES)
|
|
273
|
+
break;
|
|
274
|
+
out.push(h);
|
|
275
|
+
kept += n;
|
|
276
|
+
}
|
|
277
|
+
truncated = true;
|
|
278
|
+
return { hunks: out, adds, dels, truncated, skipped: null, isNewFile: oldText === null };
|
|
279
|
+
}
|
|
280
|
+
return { hunks, adds, dels, truncated, skipped: null, isNewFile: oldText === null };
|
|
281
|
+
}
|
|
282
|
+
export function computeSideBySide(oldText, newText) {
|
|
283
|
+
if (isBinary(newText) || (oldText !== null && isBinary(oldText))) {
|
|
284
|
+
return { kind: "binary" };
|
|
285
|
+
}
|
|
286
|
+
if (newText.length > MAX_FILE_BYTES || (oldText !== null && oldText.length > MAX_FILE_BYTES)) {
|
|
287
|
+
return { kind: "skipped", reason: "file over 1MB — diff skipped" };
|
|
288
|
+
}
|
|
289
|
+
if (oldText !== null && oldText === newText)
|
|
290
|
+
return { kind: "same" };
|
|
291
|
+
const oldLines = oldText === null ? [] : splitLines(oldText);
|
|
292
|
+
const newLines = splitLines(newText);
|
|
293
|
+
const ops = myersOps(oldLines, newLines);
|
|
294
|
+
// Numbered raw lines (1-based per side).
|
|
295
|
+
const raw = [];
|
|
296
|
+
let o = 0;
|
|
297
|
+
let nn = 0;
|
|
298
|
+
for (const op of ops) {
|
|
299
|
+
if (op.kind === "same") {
|
|
300
|
+
o += 1;
|
|
301
|
+
nn += 1;
|
|
302
|
+
raw.push({ kind: "same", text: oldLines[op.a], oldNo: o, newNo: nn });
|
|
303
|
+
}
|
|
304
|
+
else if (op.kind === "del") {
|
|
305
|
+
o += 1;
|
|
306
|
+
raw.push({ kind: "del", text: oldLines[op.a], oldNo: o, newNo: null });
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
nn += 1;
|
|
310
|
+
raw.push({ kind: "add", text: newLines[op.b], oldNo: null, newNo: nn });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const changeIdx = raw.map((r, i) => (r.kind === "same" ? -1 : i)).filter((i) => i >= 0);
|
|
314
|
+
if (changeIdx.length === 0)
|
|
315
|
+
return { kind: "same" };
|
|
316
|
+
const rows = [];
|
|
317
|
+
let adds = 0;
|
|
318
|
+
let dels = 0;
|
|
319
|
+
let truncated = false;
|
|
320
|
+
const changedSoFar = () => dels + adds;
|
|
321
|
+
// Returns false when the change budget is exhausted (stop windowing).
|
|
322
|
+
const flushSlice = (s, e) => {
|
|
323
|
+
const slice = raw.slice(s, e);
|
|
324
|
+
let k = 0;
|
|
325
|
+
while (k < slice.length) {
|
|
326
|
+
const r = slice[k];
|
|
327
|
+
if (r.kind === "same") {
|
|
328
|
+
rows.push({ kind: "context", oldNo: r.oldNo, newNo: r.newNo, text: r.text });
|
|
329
|
+
k += 1;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const delRun = [];
|
|
333
|
+
const addRun = [];
|
|
334
|
+
while (k < slice.length && slice[k].kind === "del") {
|
|
335
|
+
delRun.push({ text: slice[k].text, no: slice[k].oldNo });
|
|
336
|
+
k += 1;
|
|
337
|
+
}
|
|
338
|
+
while (k < slice.length && slice[k].kind === "add") {
|
|
339
|
+
addRun.push({ text: slice[k].text, no: slice[k].newNo });
|
|
340
|
+
k += 1;
|
|
341
|
+
}
|
|
342
|
+
const paired = Math.min(delRun.length, addRun.length);
|
|
343
|
+
for (let p = 0; p < paired; p++) {
|
|
344
|
+
const { del, add } = wordRuns(delRun[p].text, addRun[p].text);
|
|
345
|
+
rows.push({
|
|
346
|
+
kind: "change",
|
|
347
|
+
oldNo: delRun[p].no,
|
|
348
|
+
oldText: delRun[p].text,
|
|
349
|
+
oldRuns: del,
|
|
350
|
+
newNo: addRun[p].no,
|
|
351
|
+
newText: addRun[p].text,
|
|
352
|
+
newRuns: add,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
for (let p = paired; p < delRun.length; p++) {
|
|
356
|
+
rows.push({
|
|
357
|
+
kind: "change",
|
|
358
|
+
oldNo: delRun[p].no,
|
|
359
|
+
oldText: delRun[p].text,
|
|
360
|
+
oldRuns: flatRuns(delRun[p].text),
|
|
361
|
+
newNo: null,
|
|
362
|
+
newText: null,
|
|
363
|
+
newRuns: [],
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
for (let p = paired; p < addRun.length; p++) {
|
|
367
|
+
rows.push({
|
|
368
|
+
kind: "change",
|
|
369
|
+
oldNo: null,
|
|
370
|
+
oldText: null,
|
|
371
|
+
oldRuns: [],
|
|
372
|
+
newNo: addRun[p].no,
|
|
373
|
+
newText: addRun[p].text,
|
|
374
|
+
newRuns: flatRuns(addRun[p].text),
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
dels += delRun.length;
|
|
378
|
+
adds += addRun.length;
|
|
379
|
+
if (changedSoFar() >= MAX_CHANGED_LINES) {
|
|
380
|
+
truncated = true;
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return true;
|
|
385
|
+
};
|
|
386
|
+
let hunkStart = Math.max(0, changeIdx[0] - DIFF_CONTEXT);
|
|
387
|
+
let hunkEnd = Math.min(raw.length, changeIdx[0] + DIFF_CONTEXT + 1);
|
|
388
|
+
for (let c = 1; c < changeIdx.length; c++) {
|
|
389
|
+
const prev = changeIdx[c - 1];
|
|
390
|
+
const cur = changeIdx[c];
|
|
391
|
+
if (cur - prev <= DIFF_CONTEXT * 2 + 1) {
|
|
392
|
+
hunkEnd = Math.min(raw.length, cur + DIFF_CONTEXT + 1);
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
if (!flushSlice(hunkStart, hunkEnd)) {
|
|
396
|
+
return { kind: "diff", rows, adds, dels, truncated, isNewFile: oldText === null };
|
|
397
|
+
}
|
|
398
|
+
hunkStart = Math.max(0, cur - DIFF_CONTEXT);
|
|
399
|
+
hunkEnd = Math.min(raw.length, cur + DIFF_CONTEXT + 1);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
flushSlice(hunkStart, hunkEnd);
|
|
403
|
+
if (dels + adds > MAX_CHANGED_LINES) {
|
|
404
|
+
// Single-hunk overflow: the slice already pushed past the budget —
|
|
405
|
+
// trim trailing rows past 400 changes (keep the head; the notice
|
|
406
|
+
// names the remainder). Mirrors computeDiff's trailing-hunk trim.
|
|
407
|
+
let kept = 0;
|
|
408
|
+
let cut = rows.length;
|
|
409
|
+
for (let i = 0; i < rows.length; i++) {
|
|
410
|
+
if (rows[i].kind === "change") {
|
|
411
|
+
kept += 1;
|
|
412
|
+
if (kept > MAX_CHANGED_LINES) {
|
|
413
|
+
cut = i;
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
rows.length = cut;
|
|
419
|
+
truncated = true;
|
|
420
|
+
}
|
|
421
|
+
return { kind: "diff", rows, adds, dels, truncated, isNewFile: oldText === null };
|
|
422
|
+
}
|