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.
@@ -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 { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { Key, matchesKey, truncateToWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
- import { diffRows, lineDiff, type ChangeTracker, type DiffRow, type FileChange } from "./changes.ts";
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
- const ops = change.diff === null ? undefined : (change.diff ?? lineDiff(change.before, change.after));
23
- if (!ops) return [fg("warning", `Too large for an inline diff: +${change.added} −${change.removed} lines.`)];
24
- const rows: DiffRow[] = diffRows(ops);
25
- if (!rows.length) return [fg("dim", "No textual changes.")];
26
- const gutter = Math.max(3, String(Math.max(...rows.map((row) => Math.max(row.oldLine ?? 0, row.newLine ?? 0)))).length);
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
- return rows.map((row) => {
29
- if (row.kind === "hunk") return fg("accent", row.text);
30
- const text = truncateToWidth(safeLine(row.text), Math.max(1, width - gutter * 2 - 4));
31
- const prefix = fg("dim", `${num(row.oldLine)} ${num(row.newLine)} `);
32
- if (row.kind === "add") return prefix + fg("success", "+" + text);
33
- if (row.kind === "remove") return prefix + fg("error", "-" + text);
34
- return prefix + fg("muted", " " + text);
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
- selected = Math.min(selected, Math.max(0, changes.length - 1));
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 select = (index: number) => { selected = Math.max(0, Math.min(changes.length - 1, index)); scroll = 0; };
70
- const scrollBody = (delta: number) => { scroll = Math.max(0, Math.min(Math.max(0, (cache?.lines.length ?? 0) - bodyRows), scroll + delta)); };
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
- const current = changes[selected];
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 === "a" && current) run(`accepted ${current.rel}`, () => tracker.accept(current.path));
84
- else if (data === "A") run(`accepted ${changes.length} file(s)`, () => tracker.acceptAll());
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(Math.max(1, bodyRows - 2));
90
- else if (matchesKey(data, Key.pageUp)) scrollBody(-Math.max(1, bodyRows - 2));
91
- else if (data === "g") scrollBody(-Infinity);
92
- else if (data === "G") scrollBody(Infinity);
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 < changes.length) { select(listScroll + row); tui.requestRender(); return { handled: true, focus: true }; }
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(6 + ACTIONS.length, 6);
343
+ bodyRows = contentRows(7 + ACTIONS.length, 6);
112
344
  const listWidth = sidebarWidth(width, 20, 36);
113
- const bodyWidth = listWidth ? width - listWidth - 6 : width - 4;
114
- const current = changes[selected];
115
- if (!current) return [];
116
- const key = `${selected}:${bodyWidth}:${current.after.length}`;
117
- if (cache?.key !== key) cache = { key, lines: renderDiff(current, bodyWidth, fg) };
118
- const header = listWidth ? [] : [fg("accent", `‹ ${selected + 1}/${changes.length} ${current.rel} ›`), ""];
119
- const content = [...header, ...cache.lines];
120
- scroll = Math.max(0, Math.min(scroll, Math.max(0, content.length - bodyRows)));
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 = changes.slice(listScroll, listScroll + bodyRows).map((change, index) => {
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 = changes.reduce((sum, change) => [sum[0]! + change.added, sum[1]! + change.removed], [0, 0]);
130
- const title = `± CHANGES · ${changes.length} file${changes.length === 1 ? "" : "s"} · +${totals[0]} −${totals[1]}`;
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
- message ? fg(armed ? "warning" : "dim", message) : fg("dim", "↑↓ file · PgUp/PgDn scroll · press a key or click an action · Esc close")
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(scroll, scroll + bodyRows), footer, bodyRows, listWidth);
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
  }