akm-cli 0.10.261005-beta → 0.10.261007-beta
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 +65 -0
- package/STABILITY.md +9 -0
- package/dist/assets/ui/app.js +91 -0
- package/dist/assets/ui/asset.js +67 -0
- package/dist/assets/ui/bundles.js +80 -0
- package/dist/assets/ui/changes.js +32 -0
- package/dist/assets/ui/charts.js +93 -0
- package/dist/assets/ui/config.js +69 -0
- package/dist/assets/ui/diff-view.js +202 -0
- package/dist/assets/ui/diff.js +261 -0
- package/dist/assets/ui/index.html +30 -0
- package/dist/assets/ui/lib.js +238 -0
- package/dist/assets/ui/markdown.js +103 -0
- package/dist/assets/ui/overview.js +145 -0
- package/dist/assets/ui/proposal-actions.js +273 -0
- package/dist/assets/ui/proposals.js +219 -0
- package/dist/assets/ui/search.js +62 -0
- package/dist/assets/ui/styles.css +1415 -0
- package/dist/cli.js +3 -0
- package/dist/commands/health/checks.js +5 -3
- package/dist/commands/health/egress.js +5 -1
- package/dist/commands/health/engine-usage.js +2 -2
- package/dist/commands/health/llm-usage.js +2 -2
- package/dist/commands/health/windows.js +5 -5
- package/dist/commands/health.js +40 -8
- package/dist/commands/improve/reflect-noise.js +4 -0
- package/dist/commands/improve/reflect.js +14 -5
- package/dist/commands/metrics/metrics-cli.js +3 -3
- package/dist/commands/proposal/propose-cli.js +5 -0
- package/dist/commands/proposal/propose.js +6 -2
- package/dist/commands/proposal/repository.js +34 -29
- package/dist/commands/read/search.js +2 -1
- package/dist/commands/read/show.js +18 -12
- package/dist/commands/sources/source-clone.js +123 -11
- package/dist/commands/ui/api.js +556 -0
- package/dist/commands/ui/git-read.js +101 -0
- package/dist/commands/ui/open-browser.js +30 -0
- package/dist/commands/ui/proposal-actions.js +179 -0
- package/dist/commands/ui/redact.js +88 -0
- package/dist/commands/ui/server.js +352 -0
- package/dist/commands/ui/ui-cli.js +62 -0
- package/dist/core/asset/resolve-ref.js +7 -3
- package/dist/indexer/indexer.js +14 -2
- package/dist/indexer/search/db-search.js +7 -3
- package/dist/output/format-exempt.js +3 -0
- package/dist/storage/repositories/improve-runs-repository.js +6 -0
- package/dist/storage/repositories/index-db.js +10 -2
- package/dist/storage/repositories/metrics-repository.js +17 -0
- package/docs/architecture/0.10-process-rules.md +3 -4
- package/docs/architecture/improvement.md +2 -1
- package/docs/architecture/internals/improve-workflow.md +2 -2
- package/docs/reference/README.md +1 -0
- package/docs/reference/cli.md +68 -0
- package/docs/reference/ui-api.md +576 -0
- package/package.json +1 -1
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// The proposal diff on the proposal page: side by side or inline, built from diff.js's aligned rows.
|
|
2
|
+
//
|
|
3
|
+
// Like every page module this builds markup with `html` (so file text is escaped) and writes it with `mount`. The
|
|
4
|
+
// layout choice lives in localStorage when it can; the buttons and "Show N unchanged lines" controls are wired by
|
|
5
|
+
// addEventListener on the region this render created.
|
|
6
|
+
|
|
7
|
+
import { buildDiff, newFileRows, splitLines } from "./diff.js";
|
|
8
|
+
import { html, mount } from "./lib.js";
|
|
9
|
+
|
|
10
|
+
const MODE_KEY = "akm-ui-diff-mode";
|
|
11
|
+
const NARROW = "(max-width: 799px)";
|
|
12
|
+
|
|
13
|
+
/** The saved layout, else side by side (inline on a narrow screen). */
|
|
14
|
+
export function preferredMode() {
|
|
15
|
+
try {
|
|
16
|
+
const saved = localStorage.getItem(MODE_KEY);
|
|
17
|
+
if (saved === "side" || saved === "inline") return saved;
|
|
18
|
+
} catch {
|
|
19
|
+
// Storage can be blocked; fall through to the default.
|
|
20
|
+
}
|
|
21
|
+
return typeof globalThis.matchMedia === "function" && globalThis.matchMedia(NARROW).matches ? "inline" : "side";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function saveMode(mode) {
|
|
25
|
+
try {
|
|
26
|
+
localStorage.setItem(MODE_KEY, mode);
|
|
27
|
+
} catch {
|
|
28
|
+
// The choice then lasts for this page only.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---- unified text (the fallback) -------------------------------------------
|
|
33
|
+
|
|
34
|
+
function unifiedLines(unified) {
|
|
35
|
+
return unified.split("\n").map((line) => {
|
|
36
|
+
let cls = "ctx";
|
|
37
|
+
if (line.startsWith("+++") || line.startsWith("---")) cls = "meta";
|
|
38
|
+
else if (line.startsWith("@@")) cls = "hunk";
|
|
39
|
+
else if (line.startsWith("+")) cls = "add";
|
|
40
|
+
else if (line.startsWith("-")) cls = "del";
|
|
41
|
+
return html`<span class="dl ${cls}">${line === "" ? " " : line}</span>`;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function unifiedView(unified, note) {
|
|
46
|
+
return html`${note ? html`<p class="muted small">${note}</p>` : ""}<pre class="diff" tabindex="0">${unifiedLines(unified ?? "")}</pre>`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---- rows ------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
function code(cell) {
|
|
52
|
+
return cell.marks
|
|
53
|
+
? cell.marks.map(([text, changed]) => (changed ? html`<span class="w">${text}</span>` : text))
|
|
54
|
+
: cell.text;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const SIGN = { ctx: " ", del: "-", add: "+" };
|
|
58
|
+
|
|
59
|
+
/** Parts to a flat list of `{ row }` and `{ gap, count }`; an expanded gap contributes its rows. */
|
|
60
|
+
function items(parts, expanded) {
|
|
61
|
+
const out = [];
|
|
62
|
+
parts.forEach((part, index) => {
|
|
63
|
+
if (part.type === "gap" && !expanded.has(index)) out.push({ gap: index, count: part.rows.length });
|
|
64
|
+
else for (const row of part.rows) out.push({ row });
|
|
65
|
+
});
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function gapButton(item) {
|
|
70
|
+
return html`<button type="button" class="gap-btn" data-gap="${item.gap}">Show ${item.count} unchanged lines</button>`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sideRow(cell, kind) {
|
|
74
|
+
if (!cell)
|
|
75
|
+
return html`<div class="drow fill" aria-hidden="true"><span class="ln"></span><span class="sign"></span><span class="code"></span></div>`;
|
|
76
|
+
return html`<div class="drow ${kind}"><span class="ln">${cell.n}</span><span class="sign">${SIGN[kind]}</span><span class="code">${code(cell)}</span></div>`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function sidePane(title, list, side) {
|
|
80
|
+
const rows = list.map((item) => {
|
|
81
|
+
if (item.gap !== undefined) {
|
|
82
|
+
return side === "left"
|
|
83
|
+
? html`<div class="drow gap">${gapButton(item)}</div>`
|
|
84
|
+
: html`<div class="drow gap" aria-hidden="true"><span class="gap-label">${item.count} unchanged lines</span></div>`;
|
|
85
|
+
}
|
|
86
|
+
const { row } = item;
|
|
87
|
+
const kind = row.kind === "ctx" ? "ctx" : side === "left" ? "del" : "add";
|
|
88
|
+
return sideRow(row[side], kind);
|
|
89
|
+
});
|
|
90
|
+
return html`<div class="sbs-pane"><div class="sbs-head">${title}</div><div class="dcol" tabindex="0" role="region" aria-label="${title} version, scrolls sideways"><div class="dcol-body">${rows}</div></div></div>`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function sideBySide(parts, expanded) {
|
|
94
|
+
const list = items(parts, expanded);
|
|
95
|
+
return html`<div class="sbs">${sidePane("Current", list, "left")}${sidePane("Proposed", list, "right")}</div>`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Inline order: a changed block lists its removed lines, then its added lines. */
|
|
99
|
+
function inlineList(list) {
|
|
100
|
+
const out = [];
|
|
101
|
+
let pending = [];
|
|
102
|
+
const flush = () => {
|
|
103
|
+
for (const row of pending) if (row.left) out.push({ kind: "del", old: row.left.n, now: null, cell: row.left });
|
|
104
|
+
for (const row of pending) if (row.right) out.push({ kind: "add", old: null, now: row.right.n, cell: row.right });
|
|
105
|
+
pending = [];
|
|
106
|
+
};
|
|
107
|
+
for (const item of list) {
|
|
108
|
+
if (item.gap !== undefined) {
|
|
109
|
+
flush();
|
|
110
|
+
out.push(item);
|
|
111
|
+
} else if (item.row.kind === "ctx") {
|
|
112
|
+
flush();
|
|
113
|
+
out.push({ kind: "ctx", old: item.row.left.n, now: item.row.right.n, cell: item.row.left });
|
|
114
|
+
} else pending.push(item.row);
|
|
115
|
+
}
|
|
116
|
+
flush();
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function inlineView(list) {
|
|
121
|
+
const rows = list.map((line) =>
|
|
122
|
+
line.gap !== undefined
|
|
123
|
+
? html`<div class="drow gap">${gapButton(line)}</div>`
|
|
124
|
+
: html`<div class="drow ${line.kind}"><span class="ln">${line.old}</span><span class="ln">${line.now}</span><span class="sign">${SIGN[line.kind]}</span><span class="code">${code(line.cell)}</span></div>`,
|
|
125
|
+
);
|
|
126
|
+
return html`<div class="dcol inline" tabindex="0" role="region" aria-label="Changes, scrolls sideways"><div class="dcol-body">${rows}</div></div>`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---- the section -----------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
function toolbar(mode, model) {
|
|
132
|
+
const button = (value, label) =>
|
|
133
|
+
html`<button type="button" data-diff-mode="${value}" aria-pressed="${mode === value ? "true" : "false"}">${label}</button>`;
|
|
134
|
+
return html`<div class="diff-bar"><div class="seg" role="group" aria-label="Diff layout">${button("side", "Side by side")}${button("inline", "Inline")}</div>
|
|
135
|
+
<span class="small muted num">${model.added} added, ${model.removed} removed</span></div>`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function aligned(model, before, after, state) {
|
|
139
|
+
if (model.same) {
|
|
140
|
+
return html`<p class="muted">No line differs between the current asset and the proposal${before === after ? "" : ", only line endings or the final newline"}.</p>`;
|
|
141
|
+
}
|
|
142
|
+
const body =
|
|
143
|
+
state.mode === "inline"
|
|
144
|
+
? inlineView(inlineList(items(model.parts, state.expanded)))
|
|
145
|
+
: sideBySide(model.parts, state.expanded);
|
|
146
|
+
return html`${toolbar(state.mode, model)}${body}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The diff block for a proposal's `diff` from GET /api/proposals/:id: `{ markup, wire }`. `wire(root)` (absent when the block has no controls) attaches the
|
|
151
|
+
* layout toggle and the unchanged-run controls once `markup` is mounted.
|
|
152
|
+
*/
|
|
153
|
+
export function diffSection(diff) {
|
|
154
|
+
const none = null;
|
|
155
|
+
if (diff.redacted) return { markup: unifiedView(diff.unified, ""), wire: none };
|
|
156
|
+
const hasSides = typeof diff.after === "string" && !diff.truncated;
|
|
157
|
+
if (!hasSides) {
|
|
158
|
+
const note = diff.truncated ? "This file is too large to line up here, so the plain diff is shown." : "";
|
|
159
|
+
return { markup: unifiedView(diff.unified, note), wire: none };
|
|
160
|
+
}
|
|
161
|
+
if (typeof diff.before !== "string") {
|
|
162
|
+
// A new asset: the whole file, every line added.
|
|
163
|
+
const model = { parts: [{ type: "hunk", rows: newFileRows(diff.after) }] };
|
|
164
|
+
return {
|
|
165
|
+
markup: html`<p class="muted small num">${splitLines(diff.after).length} lines added</p>${inlineView(inlineList(items(model.parts, new Set())))}`,
|
|
166
|
+
wire: none,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const model = buildDiff(diff.before, diff.after);
|
|
170
|
+
if (model === null) {
|
|
171
|
+
return {
|
|
172
|
+
markup: unifiedView(diff.unified, "This file is too large to line up here, so the plain diff is shown."),
|
|
173
|
+
wire: none,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
const state = { mode: preferredMode(), expanded: new Set() };
|
|
177
|
+
const view = () => html`<div class="diffview" data-diffview>${aligned(model, diff.before, diff.after, state)}</div>`;
|
|
178
|
+
return {
|
|
179
|
+
markup: view(),
|
|
180
|
+
wire(root) {
|
|
181
|
+
const region = root.querySelector("[data-diffview]");
|
|
182
|
+
if (!region) return;
|
|
183
|
+
const draw = () => mount(region, aligned(model, diff.before, diff.after, state));
|
|
184
|
+
region.addEventListener("click", (event) => {
|
|
185
|
+
const modeButton = event.target.closest?.("[data-diff-mode]");
|
|
186
|
+
if (modeButton) {
|
|
187
|
+
state.mode = modeButton.dataset.diffMode === "inline" ? "inline" : "side";
|
|
188
|
+
saveMode(state.mode);
|
|
189
|
+
draw();
|
|
190
|
+
region.querySelector(`[data-diff-mode="${state.mode}"]`)?.focus();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const gap = event.target.closest?.("[data-gap]");
|
|
194
|
+
if (gap) {
|
|
195
|
+
state.expanded.add(Number(gap.dataset.gap));
|
|
196
|
+
draw();
|
|
197
|
+
region.querySelector("[tabindex]")?.focus({ preventScroll: true });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// Line and word diffs for the proposal page. Pure functions on strings: no DOM, no markup, so the algorithm can be
|
|
2
|
+
// tested on its own. diff-view.js turns the result into markup.
|
|
3
|
+
//
|
|
4
|
+
// The server's unified diff pairs lines by position, so one inserted line makes every later line look changed. This
|
|
5
|
+
// module aligns the two texts properly (Myers' O(ND) shortest edit script) and returns aligned rows, grouped into
|
|
6
|
+
// hunks with a few lines of context around each change and the long unchanged runs between hunks left as gaps.
|
|
7
|
+
|
|
8
|
+
/** Most lines on either side we are willing to align; above this the caller shows the server's unified text. */
|
|
9
|
+
export const MAX_LINES = 5000;
|
|
10
|
+
/** Most inserted plus deleted lines Myers' search explores before giving up (the search costs memory ~ edits squared). */
|
|
11
|
+
export const MAX_EDITS = 2000;
|
|
12
|
+
/** Unchanged lines kept around each change. */
|
|
13
|
+
export const CONTEXT = 3;
|
|
14
|
+
/** An unchanged run shorter than this is shown rather than collapsed behind a control. */
|
|
15
|
+
export const MIN_COLLAPSE = 4;
|
|
16
|
+
/** Longest line (in tokens) that gets an intra-line word diff. */
|
|
17
|
+
const MAX_WORD_TOKENS = 400;
|
|
18
|
+
/** Below this share of matching characters a changed pair is two unrelated lines: no word highlights. */
|
|
19
|
+
const MIN_SIMILARITY = 0.3;
|
|
20
|
+
|
|
21
|
+
/** Lines of `text`: LF and CRLF both end a line, and the newline ending the file is not a line of its own. */
|
|
22
|
+
export function splitLines(text) {
|
|
23
|
+
if (text === "") return [];
|
|
24
|
+
const lines = text.split(/\r?\n/);
|
|
25
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
26
|
+
return lines;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Shortest edit script between two line arrays as `{ type: "eq" | "del" | "add", a, b }` (0-based indexes), or `null` past {@link MAX_EDITS}. */
|
|
30
|
+
export function lineOps(a, b) {
|
|
31
|
+
let start = 0;
|
|
32
|
+
while (start < a.length && start < b.length && a[start] === b[start]) start++;
|
|
33
|
+
let endA = a.length;
|
|
34
|
+
let endB = b.length;
|
|
35
|
+
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
|
|
36
|
+
endA--;
|
|
37
|
+
endB--;
|
|
38
|
+
}
|
|
39
|
+
const middle = myers(a.slice(start, endA), b.slice(start, endB));
|
|
40
|
+
if (middle === null) return null;
|
|
41
|
+
const ops = [];
|
|
42
|
+
for (let i = 0; i < start; i++) ops.push({ type: "eq", a: i, b: i });
|
|
43
|
+
for (const op of middle)
|
|
44
|
+
ops.push({ type: op.type, a: op.a === null ? null : op.a + start, b: op.b === null ? null : op.b + start });
|
|
45
|
+
for (let i = 0; i < a.length - endA; i++) ops.push({ type: "eq", a: endA + i, b: endB + i });
|
|
46
|
+
return ops;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function myers(a, b) {
|
|
50
|
+
const n = a.length;
|
|
51
|
+
const m = b.length;
|
|
52
|
+
if (n === 0 || m === 0) {
|
|
53
|
+
return [...a.map((_, i) => ({ type: "del", a: i, b: null })), ...b.map((_, j) => ({ type: "add", a: null, b: j }))];
|
|
54
|
+
}
|
|
55
|
+
const max = Math.min(n + m, MAX_EDITS);
|
|
56
|
+
const offset = max + 1;
|
|
57
|
+
const v = new Int32Array(2 * max + 3);
|
|
58
|
+
// trace[d] is v as it was before round d, restricted to the diagonals round d can read: -d-1 .. d+1.
|
|
59
|
+
const trace = [];
|
|
60
|
+
for (let d = 0; d <= max; d++) {
|
|
61
|
+
trace.push(v.slice(offset - d - 1, offset + d + 2));
|
|
62
|
+
for (let k = -d; k <= d; k += 2) {
|
|
63
|
+
let x =
|
|
64
|
+
k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1]) ? v[offset + k + 1] : v[offset + k - 1] + 1;
|
|
65
|
+
let y = x - k;
|
|
66
|
+
while (x < n && y < m && a[x] === b[y]) {
|
|
67
|
+
x++;
|
|
68
|
+
y++;
|
|
69
|
+
}
|
|
70
|
+
v[offset + k] = x;
|
|
71
|
+
if (x >= n && y >= m) return backtrack(trace, a, b);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function backtrack(trace, a, b) {
|
|
78
|
+
const ops = [];
|
|
79
|
+
let x = a.length;
|
|
80
|
+
let y = b.length;
|
|
81
|
+
for (let d = trace.length - 1; d >= 0; d--) {
|
|
82
|
+
const at = (k) => trace[d][k + d + 1];
|
|
83
|
+
const k = x - y;
|
|
84
|
+
const down = k === -d || (k !== d && at(k - 1) < at(k + 1));
|
|
85
|
+
const prevK = down ? k + 1 : k - 1;
|
|
86
|
+
const prevX = d === 0 ? 0 : at(prevK);
|
|
87
|
+
const prevY = d === 0 ? 0 : prevX - prevK;
|
|
88
|
+
while (x > prevX && y > prevY) {
|
|
89
|
+
x--;
|
|
90
|
+
y--;
|
|
91
|
+
ops.push({ type: "eq", a: x, b: y });
|
|
92
|
+
}
|
|
93
|
+
if (d > 0) {
|
|
94
|
+
if (down) ops.push({ type: "add", a: null, b: prevY });
|
|
95
|
+
else ops.push({ type: "del", a: prevX, b: null });
|
|
96
|
+
}
|
|
97
|
+
x = prevX;
|
|
98
|
+
y = prevY;
|
|
99
|
+
}
|
|
100
|
+
return ops.reverse();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---- words -----------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
const TOKEN = /\s+|[\p{L}\p{N}_]+|[^\s\p{L}\p{N}_]/gu;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Which parts of two changed lines differ, as `[leftMarks, rightMarks]`, each a list of `[text, changed]`. `null` when
|
|
109
|
+
* the lines have too little in common for highlights to help (the whole-line tint says enough) or are too long.
|
|
110
|
+
*/
|
|
111
|
+
export function wordMarks(left, right) {
|
|
112
|
+
const a = left.match(TOKEN) ?? [];
|
|
113
|
+
const b = right.match(TOKEN) ?? [];
|
|
114
|
+
if (a.length > MAX_WORD_TOKENS || b.length > MAX_WORD_TOKENS) return null;
|
|
115
|
+
const w = b.length + 1;
|
|
116
|
+
const table = new Uint16Array((a.length + 1) * w);
|
|
117
|
+
for (let i = a.length - 1; i >= 0; i--) {
|
|
118
|
+
for (let j = b.length - 1; j >= 0; j--) {
|
|
119
|
+
table[i * w + j] =
|
|
120
|
+
a[i] === b[j] ? table[(i + 1) * w + j + 1] + 1 : Math.max(table[(i + 1) * w + j], table[i * w + j + 1]);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const keepA = new Array(a.length).fill(false);
|
|
124
|
+
const keepB = new Array(b.length).fill(false);
|
|
125
|
+
let common = 0;
|
|
126
|
+
for (let i = 0, j = 0; i < a.length && j < b.length; ) {
|
|
127
|
+
if (a[i] === b[j]) {
|
|
128
|
+
keepA[i] = keepB[j] = true;
|
|
129
|
+
common += a[i].length;
|
|
130
|
+
i++;
|
|
131
|
+
j++;
|
|
132
|
+
} else if (table[(i + 1) * w + j] >= table[i * w + j + 1]) i++;
|
|
133
|
+
else j++;
|
|
134
|
+
}
|
|
135
|
+
if (left.length + right.length === 0 || (2 * common) / (left.length + right.length) < MIN_SIMILARITY) return null;
|
|
136
|
+
return [marks(a, keepA), marks(b, keepB)];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Adjacent tokens with the same status become one run; whitespace between two changed runs joins them. */
|
|
140
|
+
function marks(tokens, keep) {
|
|
141
|
+
const runs = [];
|
|
142
|
+
tokens.forEach((token, i) => {
|
|
143
|
+
const changed = !keep[i];
|
|
144
|
+
const last = runs[runs.length - 1];
|
|
145
|
+
if (last && last[1] === changed) last[0] += token;
|
|
146
|
+
else runs.push([token, changed]);
|
|
147
|
+
});
|
|
148
|
+
for (let i = 1; i < runs.length - 1; i++) {
|
|
149
|
+
const run = runs[i];
|
|
150
|
+
if (!run[1] && /^\s+$/.test(run[0]) && runs[i - 1][1] && runs[i + 1][1]) run[1] = true;
|
|
151
|
+
}
|
|
152
|
+
const merged = [];
|
|
153
|
+
for (const run of runs) {
|
|
154
|
+
const last = merged[merged.length - 1];
|
|
155
|
+
if (last && last[1] === run[1]) last[0] += run[0];
|
|
156
|
+
else merged.push([run[0], run[1]]);
|
|
157
|
+
}
|
|
158
|
+
return merged;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---- rows and hunks --------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
function cell(n, text, marked) {
|
|
164
|
+
return { n, text, marks: marked };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Align `before` and `after` (strings; `null` is an asset that does not exist yet) into rows. Each row is
|
|
169
|
+
* `{ kind, block, left, right }` where `kind` is `ctx`, `del`, `add` or `mod` (a deleted and an added line paired up),
|
|
170
|
+
* `left`/`right` are `{ n, text, marks }` (1-based line number, text, word marks or `null`) or `null` for the empty
|
|
171
|
+
* side, and `block` numbers each run of changed rows.
|
|
172
|
+
*
|
|
173
|
+
* Returns `null` when the texts are too large to align; the caller falls back to the server's unified diff.
|
|
174
|
+
* Otherwise `{ rows, parts, added, removed, same }`: `parts` is the rows in order as `{ type: "hunk" | "gap", rows }`
|
|
175
|
+
* (a hunk is changed rows with {@link CONTEXT} lines either side; a gap is a long unchanged run), `added`/`removed`
|
|
176
|
+
* count lines, and `same` says the lines are equal (the texts may still differ in line endings or the last newline).
|
|
177
|
+
*/
|
|
178
|
+
export function buildDiff(before, after) {
|
|
179
|
+
const a = splitLines(before ?? "");
|
|
180
|
+
const b = splitLines(after ?? "");
|
|
181
|
+
if (a.length > MAX_LINES || b.length > MAX_LINES) return null;
|
|
182
|
+
const ops = lineOps(a, b);
|
|
183
|
+
if (ops === null) return null;
|
|
184
|
+
|
|
185
|
+
const rows = [];
|
|
186
|
+
let block = 0;
|
|
187
|
+
let added = 0;
|
|
188
|
+
let removed = 0;
|
|
189
|
+
for (let i = 0; i < ops.length; ) {
|
|
190
|
+
const op = ops[i];
|
|
191
|
+
if (op.type === "eq") {
|
|
192
|
+
rows.push({
|
|
193
|
+
kind: "ctx",
|
|
194
|
+
block: null,
|
|
195
|
+
left: cell(op.a + 1, a[op.a], null),
|
|
196
|
+
right: cell(op.b + 1, b[op.b], null),
|
|
197
|
+
});
|
|
198
|
+
i++;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const dels = [];
|
|
202
|
+
const adds = [];
|
|
203
|
+
while (i < ops.length && ops[i].type !== "eq") {
|
|
204
|
+
(ops[i].type === "del" ? dels : adds).push(ops[i]);
|
|
205
|
+
i++;
|
|
206
|
+
}
|
|
207
|
+
block++;
|
|
208
|
+
removed += dels.length;
|
|
209
|
+
added += adds.length;
|
|
210
|
+
for (let r = 0; r < Math.max(dels.length, adds.length); r++) {
|
|
211
|
+
const d = dels[r];
|
|
212
|
+
const p = adds[r];
|
|
213
|
+
const pair = d && p ? wordMarks(a[d.a], b[p.b]) : null;
|
|
214
|
+
rows.push({
|
|
215
|
+
kind: d && p ? "mod" : d ? "del" : "add",
|
|
216
|
+
block,
|
|
217
|
+
left: d ? cell(d.a + 1, a[d.a], pair?.[0] ?? null) : null,
|
|
218
|
+
right: p ? cell(p.b + 1, b[p.b], pair?.[1] ?? null) : null,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return { rows, parts: group(rows), added, removed, same: added === 0 && removed === 0 };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Split rows into hunks (changes plus context) and gaps (unchanged runs worth collapsing). */
|
|
226
|
+
function group(rows) {
|
|
227
|
+
const visible = new Array(rows.length).fill(false);
|
|
228
|
+
rows.forEach((row, i) => {
|
|
229
|
+
if (row.kind === "ctx") return;
|
|
230
|
+
for (let k = Math.max(0, i - CONTEXT); k <= Math.min(rows.length - 1, i + CONTEXT); k++) visible[k] = true;
|
|
231
|
+
});
|
|
232
|
+
// A hidden run too short to be worth a control stays visible.
|
|
233
|
+
for (let i = 0; i < rows.length; ) {
|
|
234
|
+
if (visible[i]) {
|
|
235
|
+
i++;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
let end = i;
|
|
239
|
+
while (end < rows.length && !visible[end]) end++;
|
|
240
|
+
if (end - i < MIN_COLLAPSE && visible.includes(true)) for (let k = i; k < end; k++) visible[k] = true;
|
|
241
|
+
i = end;
|
|
242
|
+
}
|
|
243
|
+
const parts = [];
|
|
244
|
+
for (let i = 0; i < rows.length; ) {
|
|
245
|
+
let end = i;
|
|
246
|
+
while (end < rows.length && visible[end] === visible[i]) end++;
|
|
247
|
+
parts.push({ type: visible[i] ? "hunk" : "gap", rows: rows.slice(i, end) });
|
|
248
|
+
i = end;
|
|
249
|
+
}
|
|
250
|
+
return parts;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The rows of a file with no earlier version: every line added. */
|
|
254
|
+
export function newFileRows(after) {
|
|
255
|
+
return splitLines(after ?? "").map((text, i) => ({
|
|
256
|
+
kind: "add",
|
|
257
|
+
block: 1,
|
|
258
|
+
left: null,
|
|
259
|
+
right: cell(i + 1, text, null),
|
|
260
|
+
}));
|
|
261
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="color-scheme" content="light dark">
|
|
7
|
+
<title>akm ui</title>
|
|
8
|
+
<link rel="stylesheet" href="styles.css">
|
|
9
|
+
<script type="module" src="app.js"></script>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div class="shell">
|
|
13
|
+
<nav class="sidebar" aria-label="Main">
|
|
14
|
+
<a class="brand" href="#/overview">akm <span>ui</span></a>
|
|
15
|
+
<ul class="nav-links">
|
|
16
|
+
<li><a data-route="overview" href="#/overview">Overview</a></li>
|
|
17
|
+
<li><a data-route="bundles" href="#/bundles">Bundles</a></li>
|
|
18
|
+
<li><a data-route="config" href="#/config">Config</a></li>
|
|
19
|
+
<li><a data-route="proposals" href="#/proposals">Proposals</a></li>
|
|
20
|
+
<li><a data-route="changes" href="#/changes">Changes</a></li>
|
|
21
|
+
<li><a data-route="search" href="#/search">Search</a></li>
|
|
22
|
+
</ul>
|
|
23
|
+
<div class="nav-meta" id="nav-meta"></div>
|
|
24
|
+
</nav>
|
|
25
|
+
<main id="app" aria-live="polite">
|
|
26
|
+
<noscript><p>akm ui needs JavaScript. It makes no network requests beyond its own local server.</p></noscript>
|
|
27
|
+
</main>
|
|
28
|
+
</div>
|
|
29
|
+
</body>
|
|
30
|
+
</html>
|