pi-jar 0.1.6 → 0.1.8
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/README.md +19 -7
- package/docs/DIFF-REVIEW.md +14 -0
- package/docs/SUBAGENT-TOOLS.md +24 -0
- package/docs/WORKFLOWS.md +2 -2
- package/extensions/index.ts +21 -23
- package/package.json +1 -1
- package/src/advisor.ts +17 -8
- package/src/async-process.ts +144 -0
- package/src/changes.ts +235 -28
- package/src/commit.ts +5 -6
- package/src/delegate-worktree.ts +135 -58
- package/src/delegate.ts +291 -88
- package/src/diff-inline.ts +80 -0
- package/src/diff-view.ts +292 -44
- package/src/discussion.ts +59 -21
- package/src/quota.ts +12 -1
- package/src/shell-notifications.ts +104 -0
- package/src/shells.ts +130 -26
- package/src/subagent-tools.ts +45 -0
- package/src/working.ts +3 -5
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** Word-level changes inside one replaced line, for intraline emphasis in /diff. */
|
|
2
|
+
|
|
3
|
+
export type Span = readonly [start: number, end: number];
|
|
4
|
+
|
|
5
|
+
/** Token LCS stays tiny: long or wholly rewritten lines are shown without intraline emphasis. */
|
|
6
|
+
const MAX_INLINE_CELLS = 40_000;
|
|
7
|
+
const TOKEN = /\w+|\s+|[^\w\s]/gu;
|
|
8
|
+
|
|
9
|
+
const tokens = (text: string) => Array.from(text.matchAll(TOKEN), match => ({ text: match[0], at: match.index! }));
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Changed `[start, end)` code-unit spans of `before` and `after`. Undefined when the lines are equal,
|
|
13
|
+
* too long to compare cheaply, or share no non-whitespace token (emphasizing everything adds nothing).
|
|
14
|
+
*/
|
|
15
|
+
export function inlineChanges(before: string, after: string): { before: Span[]; after: Span[] } | undefined {
|
|
16
|
+
if (before === after) return undefined;
|
|
17
|
+
const a = tokens(before), b = tokens(after);
|
|
18
|
+
let start = 0;
|
|
19
|
+
while (start < a.length && start < b.length && a[start]!.text === b[start]!.text) start++;
|
|
20
|
+
let endA = a.length, endB = b.length;
|
|
21
|
+
while (endA > start && endB > start && a[endA - 1]!.text === b[endB - 1]!.text) { endA--; endB--; }
|
|
22
|
+
const n = endA - start, m = endB - start;
|
|
23
|
+
if (n * m > MAX_INLINE_CELLS) return undefined;
|
|
24
|
+
// lcs[i][j] = LCS length of a[start+i..endA) and b[start+j..endB), flattened.
|
|
25
|
+
const lcs = new Uint16Array((n + 1) * (m + 1));
|
|
26
|
+
for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) {
|
|
27
|
+
lcs[i * (m + 1) + j] = a[start + i]!.text === b[start + j]!.text ? lcs[(i + 1) * (m + 1) + j + 1]! + 1
|
|
28
|
+
: Math.max(lcs[(i + 1) * (m + 1) + j]!, lcs[i * (m + 1) + j + 1]!);
|
|
29
|
+
}
|
|
30
|
+
const changedA = new Uint8Array(n), changedB = new Uint8Array(m);
|
|
31
|
+
let common = /\S/.test(a.slice(0, start).map(token => token.text).join("") + a.slice(endA).map(token => token.text).join(""));
|
|
32
|
+
for (let i = 0, j = 0; i < n || j < m;) {
|
|
33
|
+
if (i < n && j < m && a[start + i]!.text === b[start + j]!.text) {
|
|
34
|
+
if (/\S/.test(a[start + i]!.text)) common = true;
|
|
35
|
+
i++; j++;
|
|
36
|
+
} else if (j < m && (i >= n || lcs[i * (m + 1) + j + 1]! >= lcs[(i + 1) * (m + 1) + j]!)) changedB[j++] = 1;
|
|
37
|
+
else changedA[i++] = 1;
|
|
38
|
+
}
|
|
39
|
+
if (!common) return undefined;
|
|
40
|
+
return { before: spans(a, start, changedA), after: spans(b, start, changedB) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Merge consecutive changed tokens into spans; whitespace between two changes joins them. */
|
|
44
|
+
function spans(list: readonly { text: string; at: number }[], offset: number, changed: Uint8Array): Span[] {
|
|
45
|
+
const result: [number, number][] = [];
|
|
46
|
+
let previous = -1;
|
|
47
|
+
for (let index = 0; index < changed.length; index++) {
|
|
48
|
+
if (!changed[index]) continue;
|
|
49
|
+
const token = list[offset + index]!;
|
|
50
|
+
let joined = previous >= 0;
|
|
51
|
+
for (let gap = previous + 1; joined && gap < index; gap++) if (/\S/.test(list[offset + gap]!.text)) joined = false;
|
|
52
|
+
if (joined) result.at(-1)![1] = token.at + token.text.length;
|
|
53
|
+
else result.push([token.at, token.at + token.text.length]);
|
|
54
|
+
previous = index;
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const SGR = /\x1b\[[0-?]*[ -/]*[@-~]/y;
|
|
60
|
+
const INVERSE_ON = "\x1b[7m";
|
|
61
|
+
const INVERSE_OFF = "\x1b[27m";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Insert inverse-video markers around `spans` of the visible text inside an SGR-styled string.
|
|
65
|
+
* Offsets count visible code units, so styling (syntax colours) never shifts the emphasis.
|
|
66
|
+
*/
|
|
67
|
+
export function emphasize(styled: string, spans: readonly Span[]): string {
|
|
68
|
+
if (!spans.length) return styled;
|
|
69
|
+
let out = "", visible = 0, span = 0, open = false;
|
|
70
|
+
for (let at = 0; at < styled.length;) {
|
|
71
|
+
SGR.lastIndex = at;
|
|
72
|
+
const escape = SGR.exec(styled);
|
|
73
|
+
if (escape) { out += escape[0]; at += escape[0].length; continue; }
|
|
74
|
+
while (span < spans.length && open && visible >= spans[span]![1]) { out += INVERSE_OFF; open = false; span++; }
|
|
75
|
+
if (span < spans.length && !open && visible >= spans[span]![0]) { out += INVERSE_ON; open = true; }
|
|
76
|
+
out += styled[at++];
|
|
77
|
+
visible++;
|
|
78
|
+
}
|
|
79
|
+
return open ? out + INVERSE_OFF : out;
|
|
80
|
+
}
|
package/src/diff-view.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import { Key, matchesKey, truncateToWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
-
import {
|
|
1
|
+
import { getLanguageFromPath, highlightCode, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
+
import { coarseLineDiff, diffHunkOffsets, diffLineCount, diffRowCount, diffRowsWindow, diffSplitHunkOffsets, diffSplitRowCount, diffSplitRowsWindow,
|
|
4
|
+
lineDiff, MAX_REVIEW_DIFF_LINES, type ChangeTracker, type DiffOp, type FileChange } from "./changes.ts";
|
|
5
|
+
import { emphasize, inlineChanges } from "./diff-inline.ts";
|
|
4
6
|
import { CHILD_BASELINE_ENV, writeChildBaseline } from "./child-baselines.ts";
|
|
5
7
|
import { contentRows, optionList, sidebarWidth, splitFrame } from "./split-view.ts";
|
|
6
8
|
|
|
@@ -12,27 +14,154 @@ const ACTIONS = [
|
|
|
12
14
|
] as const;
|
|
13
15
|
|
|
14
16
|
const STATUS_MARK = { added: "A", modified: "M", deleted: "D" } as const;
|
|
17
|
+
const PREVIEW_WINDOW = 1500;
|
|
18
|
+
const SUMMARY_AFTER_BYTES = 256 * 1024;
|
|
19
|
+
const SEARCH_HINT = "/ filter · Enter apply · Esc clear";
|
|
20
|
+
|
|
21
|
+
function wrapPath(path: string, width: number): string[] {
|
|
22
|
+
const points = Array.from(path);
|
|
23
|
+
const lines: string[] = [];
|
|
24
|
+
let line = "";
|
|
25
|
+
for (const point of points) {
|
|
26
|
+
if (line && visibleWidth(line + point) > width) { lines.push(line); line = ""; }
|
|
27
|
+
line += point;
|
|
28
|
+
}
|
|
29
|
+
if (line || !lines.length) lines.push(line);
|
|
30
|
+
return lines;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface DiffSearchResult { files: FileChange[]; selected: number }
|
|
34
|
+
export function filterChanges(changes: readonly FileChange[], query: string): DiffSearchResult {
|
|
35
|
+
const needle = query.trim().toLocaleLowerCase();
|
|
36
|
+
if (!needle) return { files: [...changes], selected: 0 };
|
|
37
|
+
return { files: changes.filter(change => `${change.path}\n${change.rel}\n${change.status}`.toLocaleLowerCase().includes(needle)), selected: 0 };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function fullReviewRequired(change: FileChange): boolean {
|
|
41
|
+
return change.diff === null || Buffer.byteLength(change.before) + Buffer.byteLength(change.after) > SUMMARY_AFTER_BYTES
|
|
42
|
+
|| diffLineCount(change.before) + diffLineCount(change.after) > PREVIEW_WINDOW;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function fullReviewSupported(change: FileChange): boolean {
|
|
46
|
+
return diffLineCount(change.before) + diffLineCount(change.after) <= MAX_REVIEW_DIFF_LINES;
|
|
47
|
+
}
|
|
15
48
|
|
|
16
49
|
/** Keep indentation but drop escape sequences and control characters that would break layout. */
|
|
17
50
|
export const safeLine = (text: string) => text.slice(0, 4000).replace(/\t/g, " ")
|
|
18
51
|
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]|\x1b./g, "").replace(/[\x00-\x1f\x7f-\x9f]/g, "·");
|
|
19
52
|
|
|
53
|
+
/** Optional styling hooks; tests and plain output omit them. */
|
|
54
|
+
export interface DiffPaint {
|
|
55
|
+
/** Syntax colours (foreground SGR only) for one display line, or undefined to use diff colours. */
|
|
56
|
+
syntax?: (text: string) => string | undefined;
|
|
57
|
+
/** SGR background sequence that tints a changed line's text. */
|
|
58
|
+
tint?: (kind: "add" | "remove") => string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Context sizes cycled by ←/→ (and [/]); Infinity shows whole files. */
|
|
62
|
+
export const CONTEXT_STEPS = [0, 1, 3, 5, 10, 25, Infinity] as const;
|
|
63
|
+
/** Split columns narrower than this fall back to unified rows. */
|
|
64
|
+
export const MIN_SPLIT_WIDTH = 70;
|
|
65
|
+
|
|
66
|
+
export interface DiffRenderOptions {
|
|
67
|
+
fullReview?: boolean; scroll?: number; rows?: number; unified?: boolean; ops?: readonly DiffOp[]; coarse?: boolean;
|
|
68
|
+
context?: number; wrap?: boolean; paint?: DiffPaint;
|
|
69
|
+
}
|
|
70
|
+
/** Rendered page plus how many logical rows it consumed (wrapping can show fewer than `rows`). */
|
|
71
|
+
export interface DiffWindow { lines: string[]; shown: number; total: number }
|
|
72
|
+
|
|
73
|
+
type Kind = "add" | "remove" | "context";
|
|
74
|
+
const RESET = "\x1b[0m";
|
|
75
|
+
const KIND_COLOR = { add: "success", remove: "error", context: "muted" } as const;
|
|
76
|
+
const SIGN = { add: "+", remove: "-", context: " " } as const;
|
|
77
|
+
|
|
20
78
|
/** Styled diff rows for one file, with a gutter of old/new line numbers. */
|
|
21
|
-
export function renderDiff(change: FileChange, width: number, fg: (color: string, text: string) => string): string[] {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
79
|
+
export function renderDiff(change: FileChange, width: number, fg: (color: string, text: string) => string, options: DiffRenderOptions = {}): string[] {
|
|
80
|
+
return renderDiffWindow(change, width, fg, options).lines;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function renderDiffWindow(change: FileChange, width: number, fg: (color: string, text: string) => string, options: DiffRenderOptions = {}): DiffWindow {
|
|
84
|
+
const large = fullReviewRequired(change);
|
|
85
|
+
if (large && (!options.fullReview || !fullReviewSupported(change))) return { shown: 0, total: 0, lines: [
|
|
86
|
+
fg("warning", `Summary preview · +${change.added} −${change.removed} · ${(Buffer.byteLength(change.before) + Buffer.byteLength(change.after)) / 1024 | 0} KiB`),
|
|
87
|
+
fg("dim", fullReviewSupported(change) ? "Press v for a windowed full review, or keep this summary." : "Full review unavailable: preview is bounded to 100,000 combined lines."),
|
|
88
|
+
fg("dim", "Older/binary/over-1 MiB files are not tracked for safe revert.")
|
|
89
|
+
] };
|
|
90
|
+
const exact = options.ops ? change.diff : (change.diff === null ? undefined : (change.diff ?? lineDiff(change.before, change.after)));
|
|
91
|
+
const ops = options.ops ?? exact ?? coarseLineDiff(change.before, change.after);
|
|
92
|
+
const rows = Math.max(1, Math.min(options.rows ?? PREVIEW_WINDOW, PREVIEW_WINDOW));
|
|
93
|
+
const scroll = Math.max(0, options.scroll ?? 0);
|
|
94
|
+
const context = options.context ?? 3;
|
|
95
|
+
const unified = options.unified !== false;
|
|
96
|
+
const wrap = options.wrap === true;
|
|
97
|
+
const paint = options.paint ?? {};
|
|
98
|
+
const total = unified ? diffRowCount(ops, context) : diffSplitRowCount(ops, context);
|
|
99
|
+
const unifiedRows = unified ? diffRowsWindow(ops, context, scroll, rows) : [];
|
|
100
|
+
const splitRows = unified ? [] : diffSplitRowsWindow(ops, context, scroll, rows);
|
|
101
|
+
if (!unifiedRows.length && !splitRows.length) return { lines: [fg("dim", scroll ? "End of diff." : "No textual changes.")], shown: 0, total };
|
|
102
|
+
const bounded = options.coarse ?? (change.diff === null || (!options.ops && exact === undefined));
|
|
103
|
+
let widest = 0;
|
|
104
|
+
for (const row of unifiedRows) widest = Math.max(widest, row.oldLine ?? 0, row.newLine ?? 0);
|
|
105
|
+
for (const row of splitRows) if (row.kind === "pair") widest = Math.max(widest, row.left?.line ?? 0, row.right?.line ?? 0);
|
|
106
|
+
const gutter = Math.max(3, String(widest).length);
|
|
27
107
|
const num = (value?: number) => (value ? String(value) : "").padStart(gutter);
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
|
|
108
|
+
|
|
109
|
+
// Coarse replacement groups pair unrelated lines, so intraline emphasis would mislead there.
|
|
110
|
+
const style = (text: string, kind: Kind, pair: string | undefined) => {
|
|
111
|
+
const display = safeLine(text);
|
|
112
|
+
const styled = paint.syntax?.(display) ?? fg(KIND_COLOR[kind], display);
|
|
113
|
+
if (bounded || kind === "context" || pair === undefined) return styled;
|
|
114
|
+
const changes = kind === "remove" ? inlineChanges(display, safeLine(pair)) : inlineChanges(safeLine(pair), display);
|
|
115
|
+
return changes ? emphasize(styled, kind === "remove" ? changes.before : changes.after) : styled;
|
|
116
|
+
};
|
|
117
|
+
// Rail, numbers and sign, then text truncated or wrapped into the cell; continuation lines keep
|
|
118
|
+
// the rail only. Truncation emits full resets, so a tint is re-applied after each one.
|
|
119
|
+
const cell = (kind: Kind, lead: string, text: string, width: number, pair?: string): string[] => {
|
|
120
|
+
const leadWidth = visibleWidth(lead);
|
|
121
|
+
const textWidth = Math.max(1, width - leadWidth - 2);
|
|
122
|
+
const rail = kind === "context" ? " " : fg(KIND_COLOR[kind], "▌");
|
|
123
|
+
const tint = kind === "context" ? undefined : paint.tint?.(kind);
|
|
124
|
+
const styled = style(text, kind, pair);
|
|
125
|
+
return (wrap ? wrapTextWithAnsi(styled, textWidth) : [truncateToWidth(styled, textWidth, "…")]).map((body, index) => {
|
|
126
|
+
const prefix = index ? " ".repeat(leadWidth + 1) : fg("dim", lead) + fg(KIND_COLOR[kind], SIGN[kind]);
|
|
127
|
+
const content = body + " ".repeat(Math.max(0, textWidth - visibleWidth(body)));
|
|
128
|
+
return rail + prefix + (tint ? tint + content.replaceAll(RESET, RESET + tint) : content) + RESET;
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
const hunkLine = (text: string, hidden: number | undefined) => truncateToWidth(
|
|
132
|
+
(hidden ? fg("dim", `┄ ${hidden} unchanged line${hidden === 1 ? "" : "s"} ┄ `) : "") + fg("accent", text), width, "…");
|
|
133
|
+
|
|
134
|
+
const blocks: string[][] = unified
|
|
135
|
+
? unifiedRows.map(row => row.kind === "hunk" ? [hunkLine(row.text, row.hidden)]
|
|
136
|
+
: cell(row.kind === "add" ? "add" : row.kind === "remove" ? "remove" : "context", `${num(row.oldLine)} ${num(row.newLine)} `, row.text, width, row.pair))
|
|
137
|
+
: splitRows.map(row => {
|
|
138
|
+
if (row.kind === "hunk") return [hunkLine(row.text, row.hidden)];
|
|
139
|
+
const column = Math.max(8, Math.floor((width - 3) / 2));
|
|
140
|
+
const replaced = row.left?.changed && row.right?.changed;
|
|
141
|
+
const left = row.left ? cell(row.left.changed ? "remove" : "context", num(row.left.line) + " ", row.left.text, column, replaced ? row.right!.text : undefined) : [];
|
|
142
|
+
const right = row.right ? cell(row.right.changed ? "add" : "context", num(row.right.line) + " ", row.right.text, column, replaced ? row.left!.text : undefined) : [];
|
|
143
|
+
return Array.from({ length: Math.max(left.length, right.length) }, (_, index) =>
|
|
144
|
+
(left[index] ?? " ".repeat(column)) + fg("dim", " │ ") + (right[index] ?? ""));
|
|
145
|
+
});
|
|
146
|
+
const lines: string[] = [];
|
|
147
|
+
if (bounded) lines.push(fg("warning", unified ? "Coarse preview: LCS budget exceeded; full review preserves file order but replacement groups are approximate."
|
|
148
|
+
: "Coarse preview: replacement groups are approximate."));
|
|
149
|
+
if (!unified) {
|
|
150
|
+
const column = Math.max(8, Math.floor((width - 3) / 2));
|
|
151
|
+
lines.push(truncateToWidth(fg("accent", "Original"), column, "…", true) + fg("dim", " │ ") + fg("accent", "Updated"));
|
|
152
|
+
}
|
|
153
|
+
// Fill the viewport with whole logical rows; a first row taller than the viewport is clipped.
|
|
154
|
+
let used = 0, shown = 0;
|
|
155
|
+
for (const block of blocks) {
|
|
156
|
+
if (shown && used + block.length > rows) break;
|
|
157
|
+
const visible = block.slice(0, rows - used);
|
|
158
|
+
lines.push(...visible);
|
|
159
|
+
used += visible.length;
|
|
160
|
+
shown++;
|
|
161
|
+
if (used >= rows) break;
|
|
162
|
+
}
|
|
163
|
+
if (scroll > 0 || scroll + shown < total) lines.push(fg("dim", `Windowed preview · rows ${scroll + 1}–${scroll + shown} of ${total} · PgUp/PgDn`));
|
|
164
|
+
return { lines, shown, total };
|
|
36
165
|
}
|
|
37
166
|
|
|
38
167
|
/**
|
|
@@ -47,17 +176,63 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
|
|
|
47
176
|
let selected = 0;
|
|
48
177
|
let scroll = 0;
|
|
49
178
|
let listScroll = 0;
|
|
179
|
+
let query = "";
|
|
180
|
+
let searchMode = false;
|
|
181
|
+
let fullReview = false;
|
|
182
|
+
let unified = true;
|
|
183
|
+
let contextIndex = CONTEXT_STEPS.indexOf(3);
|
|
184
|
+
let wrap = false;
|
|
50
185
|
let bodyRows = 0;
|
|
186
|
+
let bodyWidth = 80;
|
|
187
|
+
let viewportRows = 1;
|
|
51
188
|
let width = 80;
|
|
52
189
|
let armed: "one" | "all" | undefined;
|
|
53
190
|
let message = "";
|
|
54
191
|
let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
|
|
55
|
-
let cache: { key: string; lines: string[] } | undefined;
|
|
192
|
+
let cache: { key: string; lines: string[]; shown: number } | undefined;
|
|
193
|
+
let opCache: { key: string; ops: readonly DiffOp[]; coarse: boolean } | undefined;
|
|
194
|
+
let paintCache: { path: string; paint: DiffPaint } | undefined;
|
|
56
195
|
const fg = (color: string, text: string) => theme.fg(color as never, text);
|
|
196
|
+
const context = () => CONTEXT_STEPS[contextIndex]!;
|
|
197
|
+
const contextLabel = () => Number.isFinite(context()) ? `${context()} line${context() === 1 ? "" : "s"}` : "whole file";
|
|
198
|
+
// Split columns need room; narrower panes keep the unified layout instead of unreadable halves.
|
|
199
|
+
const splitMode = () => !unified && bodyWidth >= MIN_SPLIT_WIDTH;
|
|
200
|
+
const rowLayout = () => {
|
|
201
|
+
if (!opCache) return { hunks: [] as number[], rows: 0 };
|
|
202
|
+
return splitMode()
|
|
203
|
+
? { hunks: diffSplitHunkOffsets(opCache.ops, context()), rows: diffSplitRowCount(opCache.ops, context()) }
|
|
204
|
+
: { hunks: diffHunkOffsets(opCache.ops, context()), rows: diffRowCount(opCache.ops, context()) };
|
|
205
|
+
};
|
|
206
|
+
// Row offsets change with context and split mode; keep the hunk at the top of the viewport.
|
|
207
|
+
const relayout = (change: () => void) => {
|
|
208
|
+
const before = rowLayout().hunks;
|
|
209
|
+
let hunk = -1;
|
|
210
|
+
for (let index = 0; index < before.length && before[index]! <= scroll; index++) hunk = index;
|
|
211
|
+
change();
|
|
212
|
+
scroll = hunk >= 0 ? rowLayout().hunks[hunk] ?? 0 : 0;
|
|
213
|
+
cache = undefined;
|
|
214
|
+
};
|
|
215
|
+
const paintFor = (change: FileChange): DiffPaint => {
|
|
216
|
+
if (paintCache?.path !== change.path) {
|
|
217
|
+
const language = getLanguageFromPath(change.path);
|
|
218
|
+
const bg = (theme as { getBgAnsi?: (color: "toolSuccessBg" | "toolErrorBg") => string }).getBgAnsi?.bind(theme);
|
|
219
|
+
paintCache = { path: change.path, paint: {
|
|
220
|
+
// Per-line highlighting is bounded; very long lines, or a host without Pi's highlighter
|
|
221
|
+
// theme initialised, keep plain diff colours instead of failing the review.
|
|
222
|
+
...(language ? { syntax: (text: string) => {
|
|
223
|
+
if (text.length > 1000) return undefined;
|
|
224
|
+
try { return highlightCode(text, language)[0]; } catch { return undefined; }
|
|
225
|
+
} } : {}),
|
|
226
|
+
...(bg ? { tint: (kind: "add" | "remove") => bg(kind === "add" ? "toolSuccessBg" : "toolErrorBg") } : {})
|
|
227
|
+
} };
|
|
228
|
+
}
|
|
229
|
+
return paintCache.paint;
|
|
230
|
+
};
|
|
57
231
|
const refresh = () => {
|
|
58
232
|
changes = tracker.changes();
|
|
59
233
|
cache = undefined;
|
|
60
|
-
|
|
234
|
+
opCache = undefined;
|
|
235
|
+
selected = Math.min(selected, Math.max(0, visibleChanges().length - 1));
|
|
61
236
|
scroll = 0;
|
|
62
237
|
if (!changes.length) done();
|
|
63
238
|
};
|
|
@@ -66,12 +241,40 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
|
|
|
66
241
|
catch (error) { message = "failed: " + (error instanceof Error ? error.message : String(error)); ctx.ui.notify("pi-jar: " + message, "error"); }
|
|
67
242
|
refresh();
|
|
68
243
|
};
|
|
69
|
-
const
|
|
70
|
-
const
|
|
244
|
+
const visibleChanges = () => filterChanges(changes, query).files;
|
|
245
|
+
const currentChange = () => visibleChanges()[selected];
|
|
246
|
+
const select = (index: number) => { selected = Math.max(0, Math.min(visibleChanges().length - 1, index)); scroll = 0; fullReview = false; cache = undefined; opCache = undefined; };
|
|
247
|
+
const windowAt = (change: FileChange, at: number) => renderDiffWindow(change, bodyWidth, fg, { fullReview, scroll: at, rows: viewportRows,
|
|
248
|
+
ops: opCache?.ops, coarse: opCache?.coarse, unified: !splitMode(), context: context(), wrap, paint: paintFor(change) });
|
|
249
|
+
// Wrapped rows vary in height: the last page starts at the first row whose tail fits the
|
|
250
|
+
// viewport. Measured only when scrolling past the cheap bound, at most one viewport of renders.
|
|
251
|
+
const lastScroll = () => {
|
|
252
|
+
const total = rowLayout().rows;
|
|
253
|
+
let at = Math.max(0, total - viewportRows);
|
|
254
|
+
const current = currentChange();
|
|
255
|
+
if (!wrap || !current) return at;
|
|
256
|
+
while (at < total - 1 && at + windowAt(current, at).shown < total) at++;
|
|
257
|
+
return at;
|
|
258
|
+
};
|
|
259
|
+
const scrollBody = (delta: number) => {
|
|
260
|
+
const target = scroll + delta;
|
|
261
|
+
const cheap = Math.max(0, rowLayout().rows - viewportRows);
|
|
262
|
+
scroll = Math.max(0, target <= cheap ? target : Math.min(target, lastScroll())); cache = undefined;
|
|
263
|
+
};
|
|
264
|
+
const page = () => Math.max(1, (wrap ? cache?.shown ?? 1 : viewportRows) - 1);
|
|
71
265
|
const component = {
|
|
72
266
|
invalidate() { cache = undefined; },
|
|
73
267
|
handleInput(data: string) {
|
|
74
|
-
|
|
268
|
+
if (searchMode) {
|
|
269
|
+
if (matchesKey(data, Key.escape)) { searchMode = false; query = ""; selected = 0; listScroll = 0; }
|
|
270
|
+
else if (matchesKey(data, Key.ctrl("u"))) query = "";
|
|
271
|
+
else if (matchesKey(data, Key.enter)) searchMode = false;
|
|
272
|
+
else if (matchesKey(data, Key.backspace)) query = query.slice(0, -1);
|
|
273
|
+
else if (data.length === 1 && data >= " " && data <= "~") query += data;
|
|
274
|
+
selected = Math.min(selected, Math.max(0, visibleChanges().length - 1));
|
|
275
|
+
listScroll = 0; scroll = 0; cache = undefined; opCache = undefined; tui.requestRender(); return;
|
|
276
|
+
}
|
|
277
|
+
const current = currentChange();
|
|
75
278
|
if (armed && (data === "y" || (armed === "one" && data === "r") || (armed === "all" && data === "R"))) {
|
|
76
279
|
const scope = armed; armed = undefined;
|
|
77
280
|
if (scope === "all") run(`reverted ${changes.length} file(s)`, () => { for (const change of changes) tracker.revert(change.path); });
|
|
@@ -79,17 +282,46 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
|
|
|
79
282
|
tui.requestRender(); return;
|
|
80
283
|
}
|
|
81
284
|
armed = undefined;
|
|
285
|
+
if (matchesKey(data, Key.escape) && query) {
|
|
286
|
+
query = ""; selected = 0; listScroll = 0; scroll = 0; fullReview = false; cache = undefined; opCache = undefined;
|
|
287
|
+
tui.requestRender(); return;
|
|
288
|
+
}
|
|
82
289
|
if (matchesKey(data, Key.escape) || data === "q") return done();
|
|
83
|
-
if (data === "
|
|
84
|
-
else if (data === "
|
|
290
|
+
if (data === "/") { searchMode = true; query = ""; }
|
|
291
|
+
else if (data === "n" && query) select(selected + 1);
|
|
292
|
+
else if (data === "N" && query) select(selected - 1);
|
|
293
|
+
else if (data === "t") {
|
|
294
|
+
relayout(() => { unified = !unified; });
|
|
295
|
+
message = unified ? "Unified diff" : bodyWidth >= MIN_SPLIT_WIDTH ? "Side-by-side diff" : `Side-by-side needs ${MIN_SPLIT_WIDTH} columns; showing unified`;
|
|
296
|
+
}
|
|
297
|
+
else if (matchesKey(data, Key.left) || data === "[") {
|
|
298
|
+
relayout(() => { contextIndex = Math.max(0, contextIndex - 1); }); message = `Context: ${contextLabel()}`;
|
|
299
|
+
}
|
|
300
|
+
else if (matchesKey(data, Key.right) || data === "]") {
|
|
301
|
+
relayout(() => { contextIndex = Math.min(CONTEXT_STEPS.length - 1, contextIndex + 1); }); message = `Context: ${contextLabel()}`;
|
|
302
|
+
}
|
|
303
|
+
else if (data === "w") { wrap = !wrap; cache = undefined; message = wrap ? "Wrapping long lines" : "Truncating long lines"; }
|
|
304
|
+
else if (data === "v" && current && fullReviewRequired(current) && fullReviewSupported(current)) {
|
|
305
|
+
fullReview = !fullReview; scroll = 0; cache = undefined; opCache = undefined; message = fullReview ? "Full windowed review · LCS budget may label a coarse replacement" : "Summary preview";
|
|
306
|
+
}
|
|
307
|
+
else if (data === "a" && current) run(`accepted ${current.rel}`, () => tracker.accept(current.path));
|
|
308
|
+
else if (data === "A" && current) run(`accepted ${changes.length} file(s)`, () => tracker.acceptAll());
|
|
85
309
|
else if (data === "r" && current) { armed = "one"; message = `revert ${current.rel}? press r or y to confirm`; }
|
|
86
|
-
else if (data === "R") { armed = "all"; message = `revert all ${changes.length} files? press R or y to confirm`; }
|
|
310
|
+
else if (data === "R" && current) { armed = "all"; message = `revert all ${changes.length} files? press R or y to confirm`; }
|
|
87
311
|
else if (matchesKey(data, Key.up) || data === "k") select(selected - 1);
|
|
88
312
|
else if (matchesKey(data, Key.down) || data === "j") select(selected + 1);
|
|
89
|
-
else if (matchesKey(data, Key.pageDown) || data === " ") scrollBody(
|
|
90
|
-
else if (matchesKey(data, Key.pageUp)) scrollBody(-
|
|
91
|
-
else if (data === "
|
|
92
|
-
|
|
313
|
+
else if (matchesKey(data, Key.pageDown) || data === " ") scrollBody(page());
|
|
314
|
+
else if (matchesKey(data, Key.pageUp)) scrollBody(-page());
|
|
315
|
+
else if (data === "n" && !query && rowLayout().hunks.length) {
|
|
316
|
+
const hunks = rowLayout().hunks;
|
|
317
|
+
scroll = hunks.find(offset => offset > scroll) ?? hunks[0]!; cache = undefined;
|
|
318
|
+
}
|
|
319
|
+
else if (data === "p" && rowLayout().hunks.length) {
|
|
320
|
+
const hunks = rowLayout().hunks;
|
|
321
|
+
scroll = [...hunks].reverse().find(offset => offset < scroll) ?? hunks.at(-1)!; cache = undefined;
|
|
322
|
+
}
|
|
323
|
+
else if (data === "g" || matchesKey(data, Key.home)) scrollBody(-Infinity);
|
|
324
|
+
else if (data === "G" || matchesKey(data, Key.end)) scrollBody(Infinity);
|
|
93
325
|
tui.requestRender();
|
|
94
326
|
},
|
|
95
327
|
handleMouse(event: TuiMouseEvent) {
|
|
@@ -102,37 +334,53 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
|
|
|
102
334
|
}
|
|
103
335
|
if (event.type !== "click" || event.button !== "left") return;
|
|
104
336
|
if (event.y === 0 && event.x >= width - 3) { done(); return { handled: true }; }
|
|
105
|
-
if (inContent && inList && listScroll + row <
|
|
337
|
+
if (inContent && inList && listScroll + row < visibleChanges().length) { select(listScroll + row); tui.requestRender(); return { handled: true, focus: true }; }
|
|
106
338
|
const action = ACTIONS[event.y - layout.footerTop];
|
|
107
339
|
if (action) { component.handleInput(action.key); return { handled: true }; }
|
|
108
340
|
},
|
|
109
341
|
render(available: number): string[] {
|
|
110
342
|
width = Math.max(24, available);
|
|
111
|
-
bodyRows = contentRows(
|
|
343
|
+
bodyRows = contentRows(7 + ACTIONS.length, 6);
|
|
112
344
|
const listWidth = sidebarWidth(width, 20, 36);
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
345
|
+
bodyWidth = listWidth ? width - listWidth - 6 : width - 4;
|
|
346
|
+
const filtered = visibleChanges();
|
|
347
|
+
const current = filtered[selected];
|
|
348
|
+
if (!current) return [fg("dim", `No files match /${safeLine(query)}`), fg("dim", "Press / to edit the filter or Esc to clear it.")];
|
|
349
|
+
const opKey = `${current.path}:${current.before.length}:${current.after.length}`;
|
|
350
|
+
if (opCache?.key !== opKey && (!fullReviewRequired(current) || fullReview)) {
|
|
351
|
+
const exact = current.diff === null ? undefined : (current.diff ?? lineDiff(current.before, current.after));
|
|
352
|
+
opCache = { key: opKey, ops: exact ?? coarseLineDiff(current.before, current.after), coarse: exact === undefined };
|
|
353
|
+
}
|
|
354
|
+
const pathLines = wrapPath(current.path.replace(/[\x00-\x1f\x7f-\x9f]/g, char => `\\x${char.charCodeAt(0).toString(16).padStart(2, "0")}`), bodyWidth);
|
|
355
|
+
// Reserve wrapped path, metadata, split/coarse headers and the optional window footer.
|
|
356
|
+
viewportRows = Math.max(1, bodyRows - pathLines.length - 1 - (splitMode() ? 1 : 0) - (opCache?.coarse ? 1 : 0) - 1);
|
|
357
|
+
scroll = Math.min(scroll, Math.max(0, rowLayout().rows - (wrap ? 1 : viewportRows)));
|
|
358
|
+
const key = `${current.path}:${bodyWidth}:${bodyRows}:${viewportRows}:${splitMode()}:${fullReview}:${scroll}:${contextIndex}:${wrap}`;
|
|
359
|
+
if (cache?.key !== key) {
|
|
360
|
+
const page = windowAt(current, scroll);
|
|
361
|
+
cache = { key, lines: page.lines, shown: page.shown };
|
|
362
|
+
}
|
|
363
|
+
// The absolute path is intentionally the first row of the right pane, even in split mode.
|
|
364
|
+
const content = [...pathLines.map(path => fg("accent", path)),
|
|
365
|
+
fg("dim", `${selected + 1}/${filtered.length} · ${current.status} · +${current.added} −${current.removed}`), ...cache.lines];
|
|
121
366
|
if (selected < listScroll) listScroll = selected;
|
|
122
367
|
if (selected >= listScroll + bodyRows) listScroll = selected - bodyRows + 1;
|
|
123
|
-
const list =
|
|
368
|
+
const list = filtered.slice(listScroll, listScroll + bodyRows).map((change, index) => {
|
|
124
369
|
const active = listScroll + index === selected;
|
|
125
370
|
const counts = fg("success", `+${change.added}`) + fg("error", ` −${change.removed}`);
|
|
126
371
|
const name = truncateToWidth((active ? "▌" : " ") + STATUS_MARK[change.status] + " " + change.rel, Math.max(4, listWidth - String(change.added).length - String(change.removed).length - 4));
|
|
127
372
|
return fg(active ? "accent" : "muted", name) + " " + counts;
|
|
128
373
|
});
|
|
129
|
-
const totals =
|
|
130
|
-
const title = `± CHANGES · ${
|
|
374
|
+
const totals = filtered.reduce((sum, change) => [sum[0]! + change.added, sum[1]! + change.removed], [0, 0]);
|
|
375
|
+
const title = `± CHANGES · ${filtered.length}/${changes.length} files · +${totals[0]} −${totals[1]}`;
|
|
131
376
|
const footer = [
|
|
132
377
|
...optionList(theme, ACTIONS.map((item) => item.label), -1, ACTIONS.map((item) => item.key)),
|
|
133
|
-
|
|
378
|
+
fg(searchMode ? "accent" : "dim", searchMode ? `/${query} · Enter apply · Esc clear`
|
|
379
|
+
: query ? `/${query} · n/N next match · / new search`
|
|
380
|
+
: `${SEARCH_HINT} · n/p hunk · t split · ←/→ context (${contextLabel()}) · w wrap${wrap ? " on" : ""} · v full`),
|
|
381
|
+
message ? fg(armed ? "warning" : "dim", message) : fg("dim", "↑↓ file · PgUp/PgDn/Home/End scroll · a accept · r revert · Esc close")
|
|
134
382
|
];
|
|
135
|
-
const split = splitFrame(theme, width, title, list, content.slice(
|
|
383
|
+
const split = splitFrame(theme, width, title, list, content.slice(0, bodyRows), footer, bodyRows, listWidth);
|
|
136
384
|
layout = split.layout;
|
|
137
385
|
return split.lines;
|
|
138
386
|
}
|