@oh-my-pi/pi-tui 18.2.11 → 18.3.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 +20 -0
- package/THIRD-PARTY-NOTICES.txt +1 -1
- package/dist/types/apps/ps-data.d.ts +1 -1
- package/dist/types/apps/ps-top.d.ts +1 -1
- package/dist/types/chat/chat-transcript-builder.d.ts +1 -1
- package/dist/types/overlays/annotation-overlay.d.ts +30 -0
- package/dist/types/overlays/annotation-types.d.ts +84 -0
- package/dist/types/overlays/copy-selector.d.ts +30 -2
- package/dist/types/overlays/copy-targets.d.ts +2 -0
- package/dist/types/overlays/model-hub.d.ts +2 -2
- package/dist/types/overlays/oauth-selector.d.ts +3 -6
- package/dist/types/overlays/usage-dashboard.d.ts +2 -0
- package/dist/types/prompt/image-source.d.ts +9 -8
- package/dist/types/status-line/context-usage.d.ts +3 -1
- package/dist/types/theme/mermaid-cache.d.ts +4 -6
- package/dist/types/tools/bash.d.ts +7 -0
- package/dist/types/tools/daemon.d.ts +46 -0
- package/dist/types/tools/irc.d.ts +30 -0
- package/dist/types/tools/line-ranges.d.ts +1 -1
- package/dist/types/tools/proc-render.d.ts +34 -0
- package/dist/types/tools/read.d.ts +4 -1
- package/dist/types/tools/wait.d.ts +131 -0
- package/dist/types/tools/write.d.ts +4 -0
- package/dist/types/tools/xd-url.d.ts +8 -0
- package/dist/types/windows-altgr.d.ts +54 -0
- package/package.json +13 -10
- package/src/apps/debug/protocol-probe.ts +1 -1
- package/src/apps/ps-data.ts +1 -1
- package/src/apps/ps-top.ts +1 -1
- package/src/chat/chat-transcript-builder.ts +4 -4
- package/src/chat/tool-execution.ts +8 -8
- package/src/chat/transcript-render-helpers.ts +3 -3
- package/src/components/markdown.ts +1 -1
- package/src/desktop-notify.ts +1 -1
- package/src/overlays/annotation-overlay.ts +1145 -0
- package/src/overlays/annotation-types.ts +78 -0
- package/src/overlays/copy-selector.ts +45 -19
- package/src/overlays/copy-targets.ts +3 -3
- package/src/overlays/model-hub.ts +5 -4
- package/src/overlays/oauth-selector.ts +8 -12
- package/src/overlays/plan-review-overlay.ts +213 -40
- package/src/overlays/usage-dashboard.ts +22 -1
- package/src/prompt/image-source.ts +9 -8
- package/src/setup/scenes/sign-in.ts +1 -1
- package/src/status-line/context-usage.ts +15 -3
- package/src/terminal-capabilities.ts +2 -2
- package/src/terminal.ts +8 -1
- package/src/theme/mermaid-cache.ts +83 -25
- package/src/tools/bash.ts +14 -1
- package/src/tools/daemon.ts +51 -0
- package/src/tools/index.ts +2 -2
- package/src/tools/irc.ts +33 -0
- package/src/tools/line-ranges.ts +13 -3
- package/src/tools/proc-render.ts +294 -0
- package/src/tools/read.ts +22 -1
- package/src/tools/wait.ts +595 -0
- package/src/tools/write.ts +76 -0
- package/src/tools/xd-url.ts +9 -0
- package/src/windows-altgr.ts +227 -0
- package/dist/types/tools/hub.d.ts +0 -342
- package/src/tools/hub.ts +0 -1492
|
@@ -0,0 +1,1145 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Component,
|
|
3
|
+
Editor,
|
|
4
|
+
Ellipsis,
|
|
5
|
+
matchesKey,
|
|
6
|
+
padding,
|
|
7
|
+
replaceTabs,
|
|
8
|
+
ScrollView,
|
|
9
|
+
type TUI,
|
|
10
|
+
truncateToWidth,
|
|
11
|
+
visibleWidth,
|
|
12
|
+
wrapTextWithAnsi,
|
|
13
|
+
} from "../index";
|
|
14
|
+
import { type KeybindingsManager } from "../app-keybindings";
|
|
15
|
+
import { sanitizeText } from "@oh-my-pi/pi-utils";
|
|
16
|
+
import { type Theme } from "../theme/theme";
|
|
17
|
+
import type {
|
|
18
|
+
CodeReviewAnnotation,
|
|
19
|
+
CodeReviewOverlayResult,
|
|
20
|
+
ReviewDiffFile,
|
|
21
|
+
ReviewDiffRow,
|
|
22
|
+
ReviewSourceRow,
|
|
23
|
+
TextReviewAnnotation,
|
|
24
|
+
TextReviewOverlayResult,
|
|
25
|
+
TextReviewSource,
|
|
26
|
+
} from "./annotation-types";
|
|
27
|
+
import {
|
|
28
|
+
bottomBorder,
|
|
29
|
+
divider,
|
|
30
|
+
dividerSplit,
|
|
31
|
+
row,
|
|
32
|
+
splitBodyWidth,
|
|
33
|
+
splitRow,
|
|
34
|
+
topBorder,
|
|
35
|
+
topBorderSplit,
|
|
36
|
+
} from "../chrome/overlay-box";
|
|
37
|
+
import { matchesAppExternalEditor } from "../keybinding-matchers";
|
|
38
|
+
|
|
39
|
+
function fit(text: string, width: number): string {
|
|
40
|
+
if (width <= 0) return "";
|
|
41
|
+
const measured = visibleWidth(text);
|
|
42
|
+
if (measured === width) return text;
|
|
43
|
+
if (measured < width) return text + padding(width - measured);
|
|
44
|
+
const truncated = truncateToWidth(text, width);
|
|
45
|
+
const truncatedWidth = visibleWidth(truncated);
|
|
46
|
+
return truncatedWidth < width ? truncated + padding(width - truncatedWidth) : truncated;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const CONTINUE_CODE_REVIEW_ACTION = "Continue with LLM review";
|
|
50
|
+
export const PASTE_CODE_REVIEW_ACTION = "Paste annotations into prompt";
|
|
51
|
+
|
|
52
|
+
export interface AnnotationOverlayCallbacks {
|
|
53
|
+
onComplete(result: CodeReviewOverlayResult | TextReviewOverlayResult | undefined): void;
|
|
54
|
+
onWarning?(message: string): void;
|
|
55
|
+
/** Open the draft in the host's editor and invoke `commit` with its result. */
|
|
56
|
+
onAnnotationExternalEditor?(draft: string, commit: (text: string | null) => void): void | Promise<void>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface CodeReviewOverlayCallbacks extends AnnotationOverlayCallbacks {
|
|
60
|
+
onComplete(result: CodeReviewOverlayResult | undefined): void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface TextReviewOverlayCallbacks extends AnnotationOverlayCallbacks {
|
|
64
|
+
onComplete(result: TextReviewOverlayResult | undefined): void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type OverlayCallbacks = CodeReviewOverlayCallbacks | TextReviewOverlayCallbacks;
|
|
68
|
+
|
|
69
|
+
type AnnotationScope = CodeReviewAnnotation["scope"] | TextReviewAnnotation["scope"];
|
|
70
|
+
type DiffColor = "toolDiffAdded" | "toolDiffRemoved" | "toolDiffContext";
|
|
71
|
+
|
|
72
|
+
interface AnnotationChooser {
|
|
73
|
+
kind: "diff" | "text";
|
|
74
|
+
entries: number[];
|
|
75
|
+
selected: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface CommittedAnnotation {
|
|
79
|
+
fileIndex: number;
|
|
80
|
+
sourceIndex: number;
|
|
81
|
+
annotation: CodeReviewAnnotation;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface CommittedTextAnnotation {
|
|
85
|
+
sourceIndex: number;
|
|
86
|
+
annotation: TextReviewAnnotation;
|
|
87
|
+
}
|
|
88
|
+
interface RenderedDiffBody {
|
|
89
|
+
lines: string[];
|
|
90
|
+
renderedRowBySource: number[];
|
|
91
|
+
selectedSourceLines: string[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface RenderedBody {
|
|
95
|
+
lines: string[];
|
|
96
|
+
renderedRowBySource: number[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
type FocusRegion = "files" | "diff" | "actions";
|
|
100
|
+
|
|
101
|
+
const SOURCE_SELECTION_GUTTER_WIDTH = 2;
|
|
102
|
+
|
|
103
|
+
const OVERLAY_TITLE = "Code Review";
|
|
104
|
+
const TEXT_OVERLAY_TITLE = "Annotate Text";
|
|
105
|
+
const MIN_BODY_ROWS = 3;
|
|
106
|
+
const SIDEBAR_MIN_TOTAL_WIDTH = 64;
|
|
107
|
+
const SIDEBAR_MIN_BODY_WIDTH = 40;
|
|
108
|
+
const MAX_ANNOTATION_EDITOR_ROWS = 6;
|
|
109
|
+
const CODE_REVIEW_ACTIONS = [CONTINUE_CODE_REVIEW_ACTION, PASTE_CODE_REVIEW_ACTION] as const;
|
|
110
|
+
const TEXT_REVIEW_ACTIONS = [PASTE_CODE_REVIEW_ACTION] as const;
|
|
111
|
+
|
|
112
|
+
function isSourceRow(row: ReviewDiffRow): row is ReviewSourceRow {
|
|
113
|
+
return row.kind === "context" || row.kind === "added" || row.kind === "removed";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function displayFileLabel(file: ReviewDiffFile): string {
|
|
117
|
+
return file.occurrence > 1 ? `${file.path} (${file.occurrence})` : file.path;
|
|
118
|
+
}
|
|
119
|
+
function sanitizeStatusText(text: string): string {
|
|
120
|
+
return sanitizeText(text)
|
|
121
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
|
122
|
+
.replace(/ +/g, " ")
|
|
123
|
+
.trim();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function splitTextLines(text: string): string[] {
|
|
127
|
+
return text.split(/\r\n|\n|\r/);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isTextSource(value: readonly ReviewDiffFile[] | TextReviewSource): value is TextReviewSource {
|
|
131
|
+
return !Array.isArray(value);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A fullscreen, annotated diff picker that only relies on public OMP APIs. */
|
|
135
|
+
export class AnnotationOverlay implements Component {
|
|
136
|
+
#scrollView: ScrollView;
|
|
137
|
+
#editor: Editor;
|
|
138
|
+
#focus: FocusRegion = "files";
|
|
139
|
+
#fileIndex = 0;
|
|
140
|
+
#sourceIndex = 0;
|
|
141
|
+
#actionIndex = 0;
|
|
142
|
+
#bodyHeight = MIN_BODY_ROWS;
|
|
143
|
+
#sidebarShown = false;
|
|
144
|
+
#annotating = false;
|
|
145
|
+
#annotationScope: AnnotationScope = "line";
|
|
146
|
+
#editingAnnotationIndex: number | undefined;
|
|
147
|
+
#annotationChooser: AnnotationChooser | undefined;
|
|
148
|
+
#externalOperation = false;
|
|
149
|
+
#finished = false;
|
|
150
|
+
#annotations: CommittedAnnotation[] = [];
|
|
151
|
+
#textAnnotations: CommittedTextAnnotation[] = [];
|
|
152
|
+
/** Annotation lists before each create/edit/delete, restored by `u`. */
|
|
153
|
+
#undoStack: Array<{ annotations: CommittedAnnotation[]; textAnnotations: CommittedTextAnnotation[] }> = [];
|
|
154
|
+
#actions: readonly string[] = CODE_REVIEW_ACTIONS;
|
|
155
|
+
#textSource: TextReviewSource | undefined;
|
|
156
|
+
#textLines: readonly string[] = [];
|
|
157
|
+
#textViewportDriven = false;
|
|
158
|
+
#textRenderedRowBySource: readonly number[] = [];
|
|
159
|
+
#staticRenderedDiffBodies = new WeakMap<ReviewDiffFile, RenderedDiffBody>();
|
|
160
|
+
#externalEditorLabel: string;
|
|
161
|
+
|
|
162
|
+
readonly #tui: TUI;
|
|
163
|
+
readonly #theme: Theme;
|
|
164
|
+
readonly #keybindings: KeybindingsManager;
|
|
165
|
+
readonly #files: readonly ReviewDiffFile[];
|
|
166
|
+
readonly #mode: string;
|
|
167
|
+
readonly #callbacks: OverlayCallbacks;
|
|
168
|
+
|
|
169
|
+
constructor(
|
|
170
|
+
tui: TUI,
|
|
171
|
+
theme: Theme,
|
|
172
|
+
keybindings: KeybindingsManager,
|
|
173
|
+
files: readonly ReviewDiffFile[],
|
|
174
|
+
mode: string,
|
|
175
|
+
callbacks: CodeReviewOverlayCallbacks,
|
|
176
|
+
);
|
|
177
|
+
constructor(
|
|
178
|
+
tui: TUI,
|
|
179
|
+
theme: Theme,
|
|
180
|
+
keybindings: KeybindingsManager,
|
|
181
|
+
source: TextReviewSource,
|
|
182
|
+
callbacks: TextReviewOverlayCallbacks,
|
|
183
|
+
);
|
|
184
|
+
constructor(
|
|
185
|
+
tui: TUI,
|
|
186
|
+
theme: Theme,
|
|
187
|
+
keybindings: KeybindingsManager,
|
|
188
|
+
filesOrSource: readonly ReviewDiffFile[] | TextReviewSource,
|
|
189
|
+
modeOrCallbacks: string | TextReviewOverlayCallbacks,
|
|
190
|
+
maybeCallbacks?: CodeReviewOverlayCallbacks,
|
|
191
|
+
) {
|
|
192
|
+
this.#tui = tui;
|
|
193
|
+
this.#theme = theme;
|
|
194
|
+
this.#keybindings = keybindings;
|
|
195
|
+
if (isTextSource(filesOrSource)) {
|
|
196
|
+
this.#files = [];
|
|
197
|
+
this.#actions = TEXT_REVIEW_ACTIONS;
|
|
198
|
+
this.#mode = "Annotating text";
|
|
199
|
+
this.#callbacks = modeOrCallbacks as TextReviewOverlayCallbacks;
|
|
200
|
+
this.#textSource = { ...filesOrSource };
|
|
201
|
+
this.#textLines = splitTextLines(filesOrSource.text);
|
|
202
|
+
this.#focus = "diff";
|
|
203
|
+
} else {
|
|
204
|
+
this.#files = filesOrSource;
|
|
205
|
+
this.#mode = modeOrCallbacks as string;
|
|
206
|
+
this.#callbacks = maybeCallbacks!;
|
|
207
|
+
}
|
|
208
|
+
const symbols = {
|
|
209
|
+
cursor: theme.nav.cursor,
|
|
210
|
+
inputCursor: theme.nav.cursor,
|
|
211
|
+
boxRound: {
|
|
212
|
+
topLeft: theme.boxRound.topLeft,
|
|
213
|
+
topRight: theme.boxRound.topRight,
|
|
214
|
+
bottomLeft: theme.boxRound.bottomLeft,
|
|
215
|
+
bottomRight: theme.boxRound.bottomRight,
|
|
216
|
+
horizontal: theme.boxRound.horizontal,
|
|
217
|
+
vertical: theme.boxRound.vertical,
|
|
218
|
+
},
|
|
219
|
+
boxSharp: theme.boxSharp,
|
|
220
|
+
table: theme.boxSharp,
|
|
221
|
+
quoteBorder: theme.md.quoteBorder,
|
|
222
|
+
hrChar: theme.md.hrChar,
|
|
223
|
+
colorSwatch: theme.md.colorSwatch,
|
|
224
|
+
spinnerFrames: theme.spinnerFrames,
|
|
225
|
+
};
|
|
226
|
+
this.#editor = new Editor({
|
|
227
|
+
borderColor: text => theme.fg("border", text),
|
|
228
|
+
selectList: {
|
|
229
|
+
selectedPrefix: text => theme.fg("accent", text),
|
|
230
|
+
selectedText: text => theme.bold(theme.fg("accent", text)),
|
|
231
|
+
description: text => theme.fg("dim", text),
|
|
232
|
+
scrollInfo: text => theme.fg("dim", text),
|
|
233
|
+
noMatch: text => theme.fg("dim", text),
|
|
234
|
+
symbols,
|
|
235
|
+
},
|
|
236
|
+
symbols,
|
|
237
|
+
hintStyle: text => theme.fg("dim", text),
|
|
238
|
+
});
|
|
239
|
+
this.#scrollView = new ScrollView([], {
|
|
240
|
+
height: MIN_BODY_ROWS,
|
|
241
|
+
scrollbar: "auto",
|
|
242
|
+
ellipsis: Ellipsis.Omit,
|
|
243
|
+
theme: {
|
|
244
|
+
track: text => theme.fg("dim", text),
|
|
245
|
+
thumb: text => theme.fg("accent", text),
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
this.#editor.setBorderVisible(false);
|
|
249
|
+
this.#editor.setPromptGutter("> ");
|
|
250
|
+
this.#editor.setUseTerminalCursor(false);
|
|
251
|
+
// Keep the editor's CURSOR_MARKER for terminal cursor placement, but
|
|
252
|
+
// replace its visible end-of-input caret with a stable blank cell.
|
|
253
|
+
this.#editor.cursorOverride = " ";
|
|
254
|
+
this.#editor.cursorOverrideWidth = 1;
|
|
255
|
+
this.#editor.setScrollbarVisible(true);
|
|
256
|
+
this.#editor.onSubmit = value => this.#commitAnnotation(value);
|
|
257
|
+
this.#externalEditorLabel = keybindings.getDisplayString("app.editor.external");
|
|
258
|
+
this.#resetSourceCursor();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
invalidate(): void {
|
|
262
|
+
this.#staticRenderedDiffBodies = new WeakMap();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
dispose(): void {
|
|
266
|
+
this.#finished = true;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
getAnnotations(): CodeReviewAnnotation[] {
|
|
270
|
+
return this.#annotations.map(entry => ({ ...entry.annotation }));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
getTextAnnotations(): TextReviewAnnotation[] {
|
|
274
|
+
return this.#textAnnotations.map(entry => ({ ...entry.annotation }));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
handleInput(data: string): void {
|
|
278
|
+
if (this.#finished || this.#externalOperation) return;
|
|
279
|
+
if (this.#annotationChooser) {
|
|
280
|
+
this.#handleAnnotationChooser(data);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (this.#annotating) {
|
|
284
|
+
if (matchesAppExternalEditor(data)) {
|
|
285
|
+
void this.#openAnnotationEditor();
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (this.#keybindings.matches(data, "tui.select.cancel")) {
|
|
289
|
+
this.#cancelAnnotation();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
this.#editor.handleInput(data);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (this.#keybindings.matches(data, "tui.select.cancel")) {
|
|
296
|
+
this.#finish(undefined);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (data === "A" && (this.#focus === "files" || this.#focus === "diff")) {
|
|
300
|
+
this.#startAnnotation(this.#textSource ? "text" : "file");
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (!this.#textSource && data === "[") {
|
|
304
|
+
this.#selectRelativeFile(-1);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (!this.#textSource && data === "]") {
|
|
308
|
+
this.#selectRelativeFile(1);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (data === "u") {
|
|
312
|
+
this.#undoAnnotation();
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (data === "e") {
|
|
316
|
+
this.#editCurrentAnnotation();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (matchesKey(data, "tab") || data === "\t") {
|
|
320
|
+
this.#cycleFocus(1);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (matchesKey(data, "shift+tab") || data === "\x1b[Z") {
|
|
324
|
+
this.#cycleFocus(-1);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (this.#focus === "files") this.#handleFiles(data);
|
|
328
|
+
else if (this.#focus === "diff") this.#handleDiff(data);
|
|
329
|
+
else this.#handleActions(data);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
#finish(result: CodeReviewOverlayResult | TextReviewOverlayResult | undefined): void {
|
|
333
|
+
if (this.#finished) return;
|
|
334
|
+
this.#finished = true;
|
|
335
|
+
if (this.#textSource) {
|
|
336
|
+
(this.#callbacks as TextReviewOverlayCallbacks).onComplete(result as TextReviewOverlayResult | undefined);
|
|
337
|
+
} else {
|
|
338
|
+
(this.#callbacks as CodeReviewOverlayCallbacks).onComplete(result as CodeReviewOverlayResult | undefined);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
#cycleFocus(direction: number): void {
|
|
343
|
+
const regions: FocusRegion[] = this.#sidebarShown ? ["files", "diff", "actions"] : ["diff", "actions"];
|
|
344
|
+
const current = regions.indexOf(this.#focus);
|
|
345
|
+
const index = current < 0 ? regions.length - 1 : current;
|
|
346
|
+
this.#focus = regions[(index + direction + regions.length) % regions.length]!;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
#handleFiles(data: string): void {
|
|
350
|
+
if (data === "a") {
|
|
351
|
+
this.#startAnnotation("file");
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (this.#keybindings.matches(data, "tui.select.up") || matchesKey(data, "k")) {
|
|
355
|
+
this.#selectFile(Math.max(0, this.#fileIndex - 1));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (this.#keybindings.matches(data, "tui.select.down") || matchesKey(data, "j")) {
|
|
359
|
+
this.#selectFile(Math.min(this.#files.length - 1, this.#fileIndex + 1));
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (matchesKey(data, "right") || matchesKey(data, "l") || this.#keybindings.matches(data, "tui.select.confirm")) {
|
|
363
|
+
this.#focus = "diff";
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
#handleDiff(data: string): void {
|
|
368
|
+
if (data === "a") {
|
|
369
|
+
this.#startAnnotation("line");
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (matchesKey(data, "left") || matchesKey(data, "h")) {
|
|
373
|
+
if (this.#sidebarShown) this.#focus = "files";
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (matchesKey(data, "right") || matchesKey(data, "l") || this.#keybindings.matches(data, "tui.select.confirm")) {
|
|
377
|
+
this.#focus = "actions";
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (matchesKey(data, "shift+up")) {
|
|
381
|
+
this.#moveSourceCursor(-5);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (matchesKey(data, "shift+down")) {
|
|
385
|
+
this.#moveSourceCursor(5);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (this.#keybindings.matches(data, "tui.select.up") || matchesKey(data, "k")) {
|
|
389
|
+
this.#moveSourceCursor(-1);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (this.#keybindings.matches(data, "tui.select.down") || matchesKey(data, "j")) {
|
|
393
|
+
this.#moveSourceCursor(1);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (this.#keybindings.matches(data, "tui.select.pageUp")) {
|
|
397
|
+
if (this.#textSource) {
|
|
398
|
+
this.#scrollView.page(-1);
|
|
399
|
+
this.#textViewportDriven = true;
|
|
400
|
+
this.#syncTextCursorToViewport();
|
|
401
|
+
} else {
|
|
402
|
+
this.#moveSourceCursor(-Math.max(1, this.#bodyHeight - 1));
|
|
403
|
+
}
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (this.#keybindings.matches(data, "tui.select.pageDown")) {
|
|
407
|
+
if (this.#textSource) {
|
|
408
|
+
this.#scrollView.page(1);
|
|
409
|
+
this.#textViewportDriven = true;
|
|
410
|
+
this.#syncTextCursorToViewport();
|
|
411
|
+
} else {
|
|
412
|
+
this.#moveSourceCursor(Math.max(1, this.#bodyHeight - 1));
|
|
413
|
+
}
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (data === "g" || matchesKey(data, "home")) {
|
|
417
|
+
this.#sourceIndex = 0;
|
|
418
|
+
this.#textViewportDriven = false;
|
|
419
|
+
} else if (data === "G" || matchesKey(data, "end")) {
|
|
420
|
+
this.#sourceIndex = Math.max(
|
|
421
|
+
0,
|
|
422
|
+
(this.#textSource ? this.#textLines.length : this.#currentSourceRows().length) - 1,
|
|
423
|
+
);
|
|
424
|
+
this.#textViewportDriven = false;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
#handleActions(data: string): void {
|
|
429
|
+
const actionCount = this.#actions.length;
|
|
430
|
+
if (actionCount === 0) return;
|
|
431
|
+
const hasAnnotations = this.#textSource ? this.#textAnnotations.length > 0 : this.#annotations.length > 0;
|
|
432
|
+
if (this.#keybindings.matches(data, "tui.select.up") || matchesKey(data, "k")) {
|
|
433
|
+
this.#actionIndex = this.#textSource ? (this.#actionIndex - 1 + actionCount) % actionCount : 0;
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (this.#keybindings.matches(data, "tui.select.down") || matchesKey(data, "j")) {
|
|
437
|
+
if (this.#textSource) {
|
|
438
|
+
this.#actionIndex = (this.#actionIndex + 1) % actionCount;
|
|
439
|
+
} else if (hasAnnotations) {
|
|
440
|
+
this.#actionIndex = Math.min(actionCount - 1, this.#actionIndex + 1);
|
|
441
|
+
}
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (this.#keybindings.matches(data, "tui.select.confirm")) {
|
|
445
|
+
const disabled = this.#textSource ? !hasAnnotations : this.#actionIndex === 1 && !hasAnnotations;
|
|
446
|
+
if (disabled) return;
|
|
447
|
+
if (this.#textSource) {
|
|
448
|
+
this.#finish({
|
|
449
|
+
action: "paste",
|
|
450
|
+
annotations: this.getTextAnnotations(),
|
|
451
|
+
});
|
|
452
|
+
} else {
|
|
453
|
+
this.#finish({
|
|
454
|
+
action: this.#actionIndex === 0 ? "review" : "paste",
|
|
455
|
+
annotations: this.getAnnotations(),
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
#selectFile(index: number): void {
|
|
462
|
+
if (this.#files.length === 0) return;
|
|
463
|
+
this.#fileIndex = Math.max(0, Math.min(this.#files.length - 1, index));
|
|
464
|
+
this.#resetSourceCursor();
|
|
465
|
+
this.#scrollView.scrollToTop();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
#selectRelativeFile(delta: number): void {
|
|
469
|
+
if (this.#files.length === 0) return;
|
|
470
|
+
this.#selectFile((this.#fileIndex + delta + this.#files.length) % this.#files.length);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
#resetSourceCursor(): void {
|
|
474
|
+
this.#sourceIndex = 0;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
#currentFile(): ReviewDiffFile | undefined {
|
|
478
|
+
return this.#files[this.#fileIndex];
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
#currentSourceRows(): ReviewSourceRow[] {
|
|
482
|
+
const file = this.#currentFile();
|
|
483
|
+
return file ? file.rows.filter(isSourceRow) : [];
|
|
484
|
+
}
|
|
485
|
+
#syncTextCursorToViewport(): void {
|
|
486
|
+
if (!this.#textSource || this.#textRenderedRowBySource.length === 0) return;
|
|
487
|
+
const viewportTop = this.#scrollView.getScrollOffset();
|
|
488
|
+
const viewportBottom = viewportTop + Math.max(0, this.#bodyHeight - 1);
|
|
489
|
+
let topSourceIndex = 0;
|
|
490
|
+
let lastVisibleSourceIndex = 0;
|
|
491
|
+
for (let index = 1; index < this.#textRenderedRowBySource.length; index++) {
|
|
492
|
+
const renderedRow = this.#textRenderedRowBySource[index];
|
|
493
|
+
if (renderedRow === undefined || renderedRow > viewportBottom) break;
|
|
494
|
+
lastVisibleSourceIndex = index;
|
|
495
|
+
if (renderedRow <= viewportTop) topSourceIndex = index;
|
|
496
|
+
}
|
|
497
|
+
const atBottom =
|
|
498
|
+
this.#scrollView.getMaxScrollOffset() > 0 && viewportTop === this.#scrollView.getMaxScrollOffset();
|
|
499
|
+
this.#sourceIndex = atBottom ? lastVisibleSourceIndex : topSourceIndex;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
#moveSourceCursor(delta: number): void {
|
|
503
|
+
const rowCount = this.#textSource ? this.#textLines.length : this.#currentSourceRows().length;
|
|
504
|
+
if (rowCount === 0) return;
|
|
505
|
+
this.#sourceIndex = Math.max(0, Math.min(rowCount - 1, this.#sourceIndex + delta));
|
|
506
|
+
if (this.#textSource) this.#textViewportDriven = false;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
#startAnnotation(scope: AnnotationScope, existingIndex?: number): void {
|
|
510
|
+
if (this.#textSource) {
|
|
511
|
+
if (scope === "text" || scope === "line") this.#startTextAnnotation(scope, existingIndex);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const file = this.#currentFile();
|
|
515
|
+
const source = this.#currentSourceRows()[this.#sourceIndex];
|
|
516
|
+
if (!file || (scope === "line" && (file.isBinary || !source))) {
|
|
517
|
+
this.#callbacks.onWarning?.("This file has no annotatable diff rows");
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
const existing = existingIndex === undefined ? undefined : this.#annotations[existingIndex];
|
|
521
|
+
if (existing && (existing.fileIndex !== this.#fileIndex || existing.annotation.scope !== scope)) {
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
this.#annotationScope = scope === "file" ? "file" : "line";
|
|
525
|
+
this.#editingAnnotationIndex = existingIndex;
|
|
526
|
+
this.#annotating = true;
|
|
527
|
+
this.#editor.setText(existing?.annotation.note ?? "");
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
#startTextAnnotation(scope: TextReviewAnnotation["scope"], existingIndex?: number): void {
|
|
531
|
+
const existing = existingIndex === undefined ? undefined : this.#textAnnotations[existingIndex];
|
|
532
|
+
if (existing && existing.annotation.scope !== scope) return;
|
|
533
|
+
this.#annotationScope = scope;
|
|
534
|
+
this.#editingAnnotationIndex = existingIndex;
|
|
535
|
+
this.#annotating = true;
|
|
536
|
+
this.#editor.setText(existing?.annotation.note ?? "");
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
#cancelAnnotation(): void {
|
|
540
|
+
this.#annotating = false;
|
|
541
|
+
this.#editingAnnotationIndex = undefined;
|
|
542
|
+
this.#editor.setText("");
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
#commitAnnotation(value: string): void {
|
|
546
|
+
if (this.#textSource) {
|
|
547
|
+
this.#commitTextAnnotation(value);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
const note = value.trim();
|
|
551
|
+
const file = this.#currentFile();
|
|
552
|
+
const source = this.#currentSourceRows()[this.#sourceIndex];
|
|
553
|
+
const editingIndex = this.#editingAnnotationIndex;
|
|
554
|
+
const scope: CodeReviewAnnotation["scope"] = this.#annotationScope === "file" ? "file" : "line";
|
|
555
|
+
this.#annotating = false;
|
|
556
|
+
this.#editingAnnotationIndex = undefined;
|
|
557
|
+
this.#editor.setText("");
|
|
558
|
+
|
|
559
|
+
if (!note) {
|
|
560
|
+
if (editingIndex !== undefined) {
|
|
561
|
+
this.#pushUndo();
|
|
562
|
+
this.#annotations.splice(editingIndex, 1);
|
|
563
|
+
}
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (editingIndex !== undefined) {
|
|
567
|
+
const existing = this.#annotations[editingIndex];
|
|
568
|
+
if (existing) {
|
|
569
|
+
this.#pushUndo();
|
|
570
|
+
this.#annotations[editingIndex] = {
|
|
571
|
+
...existing,
|
|
572
|
+
annotation: { ...existing.annotation, note },
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
if (!file || (scope === "line" && !source)) return;
|
|
578
|
+
this.#pushUndo();
|
|
579
|
+
|
|
580
|
+
const common = {
|
|
581
|
+
path: file.path,
|
|
582
|
+
...(file.oldPath === undefined ? {} : { oldPath: file.oldPath }),
|
|
583
|
+
...(file.newPath === undefined ? {} : { newPath: file.newPath }),
|
|
584
|
+
occurrence: file.occurrence,
|
|
585
|
+
note,
|
|
586
|
+
};
|
|
587
|
+
const annotation: CodeReviewAnnotation =
|
|
588
|
+
scope === "file"
|
|
589
|
+
? { ...common, scope: "file" }
|
|
590
|
+
: {
|
|
591
|
+
...common,
|
|
592
|
+
scope: "line",
|
|
593
|
+
hunkHeader: source!.hunkHeader,
|
|
594
|
+
...(source!.oldLine === undefined ? {} : { oldLine: source!.oldLine }),
|
|
595
|
+
...(source!.newLine === undefined ? {} : { newLine: source!.newLine }),
|
|
596
|
+
rawLine: source!.raw,
|
|
597
|
+
};
|
|
598
|
+
this.#annotations.push({
|
|
599
|
+
fileIndex: this.#fileIndex,
|
|
600
|
+
sourceIndex: scope === "line" ? this.#sourceIndex : -1,
|
|
601
|
+
annotation,
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
#commitTextAnnotation(value: string): void {
|
|
606
|
+
const note = value.trim();
|
|
607
|
+
const editingIndex = this.#editingAnnotationIndex;
|
|
608
|
+
const scope: TextReviewAnnotation["scope"] = this.#annotationScope === "text" ? "text" : "line";
|
|
609
|
+
const sourceLine = this.#textLines[this.#sourceIndex] ?? "";
|
|
610
|
+
this.#annotating = false;
|
|
611
|
+
this.#editingAnnotationIndex = undefined;
|
|
612
|
+
this.#editor.setText("");
|
|
613
|
+
if (!note) {
|
|
614
|
+
if (editingIndex !== undefined) {
|
|
615
|
+
this.#pushUndo();
|
|
616
|
+
this.#textAnnotations.splice(editingIndex, 1);
|
|
617
|
+
}
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
if (editingIndex !== undefined) {
|
|
621
|
+
const existing = this.#textAnnotations[editingIndex];
|
|
622
|
+
if (existing) {
|
|
623
|
+
this.#pushUndo();
|
|
624
|
+
this.#textAnnotations[editingIndex] = {
|
|
625
|
+
...existing,
|
|
626
|
+
annotation: { ...existing.annotation, note },
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
this.#pushUndo();
|
|
632
|
+
const annotation: TextReviewAnnotation =
|
|
633
|
+
scope === "text"
|
|
634
|
+
? { scope: "text", note }
|
|
635
|
+
: {
|
|
636
|
+
scope: "line",
|
|
637
|
+
line: this.#sourceIndex + 1,
|
|
638
|
+
quote: sourceLine,
|
|
639
|
+
note,
|
|
640
|
+
};
|
|
641
|
+
this.#textAnnotations.push({
|
|
642
|
+
sourceIndex: scope === "line" ? this.#sourceIndex : -1,
|
|
643
|
+
annotation,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
#annotationCandidates(): number[] {
|
|
648
|
+
if (this.#focus !== "files" && this.#focus !== "diff") return [];
|
|
649
|
+
if (this.#textSource) {
|
|
650
|
+
if (this.#focus !== "diff") return [];
|
|
651
|
+
const lineEntries: number[] = [];
|
|
652
|
+
const textEntries: number[] = [];
|
|
653
|
+
for (const [index, entry] of this.#textAnnotations.entries()) {
|
|
654
|
+
if (entry.annotation.scope === "text") textEntries.push(index);
|
|
655
|
+
else if (entry.sourceIndex === this.#sourceIndex) lineEntries.push(index);
|
|
656
|
+
}
|
|
657
|
+
return [...lineEntries, ...textEntries];
|
|
658
|
+
}
|
|
659
|
+
const lineEntries: number[] = [];
|
|
660
|
+
const fileEntries: number[] = [];
|
|
661
|
+
for (const [index, entry] of this.#annotations.entries()) {
|
|
662
|
+
if (entry.fileIndex !== this.#fileIndex) continue;
|
|
663
|
+
if (entry.annotation.scope === "file") {
|
|
664
|
+
fileEntries.push(index);
|
|
665
|
+
} else if (this.#focus === "diff" && entry.sourceIndex === this.#sourceIndex) {
|
|
666
|
+
lineEntries.push(index);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return this.#focus === "files" ? fileEntries : [...lineEntries, ...fileEntries];
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
#editCurrentAnnotation(): void {
|
|
673
|
+
const entries = this.#annotationCandidates();
|
|
674
|
+
if (entries.length === 0) return;
|
|
675
|
+
if (entries.length === 1) {
|
|
676
|
+
const index = entries[0]!;
|
|
677
|
+
if (this.#textSource) {
|
|
678
|
+
this.#startTextAnnotation(this.#textAnnotations[index]!.annotation.scope, index);
|
|
679
|
+
} else {
|
|
680
|
+
this.#startAnnotation(this.#annotations[index]!.annotation.scope, index);
|
|
681
|
+
}
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
this.#annotationChooser = {
|
|
685
|
+
kind: this.#textSource ? "text" : "diff",
|
|
686
|
+
entries,
|
|
687
|
+
selected: 0,
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
#handleAnnotationChooser(data: string): void {
|
|
692
|
+
const chooser = this.#annotationChooser;
|
|
693
|
+
if (!chooser) return;
|
|
694
|
+
if (this.#keybindings.matches(data, "tui.select.cancel")) {
|
|
695
|
+
this.#annotationChooser = undefined;
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
if (this.#keybindings.matches(data, "tui.select.up") || matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
699
|
+
chooser.selected = (chooser.selected - 1 + chooser.entries.length) % chooser.entries.length;
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
if (this.#keybindings.matches(data, "tui.select.down") || matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
703
|
+
chooser.selected = (chooser.selected + 1) % chooser.entries.length;
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
if (!this.#keybindings.matches(data, "tui.select.confirm")) return;
|
|
707
|
+
const index = chooser.entries[chooser.selected];
|
|
708
|
+
this.#annotationChooser = undefined;
|
|
709
|
+
if (index === undefined) return;
|
|
710
|
+
if (chooser.kind === "text") {
|
|
711
|
+
const entry = this.#textAnnotations[index];
|
|
712
|
+
if (entry) this.#startTextAnnotation(entry.annotation.scope, index);
|
|
713
|
+
} else {
|
|
714
|
+
const entry = this.#annotations[index];
|
|
715
|
+
if (entry) this.#startAnnotation(entry.annotation.scope, index);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
#annotationLocation(entry: CommittedAnnotation): string {
|
|
720
|
+
if (entry.annotation.scope === "file") return `${displayFileLabel(this.#files[entry.fileIndex]!)} · file`;
|
|
721
|
+
return `${displayFileLabel(this.#files[entry.fileIndex]!)} · ${entry.annotation.oldLine ?? "-"}/${entry.annotation.newLine ?? "-"}`;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
#textAnnotationLocation(entry: CommittedTextAnnotation): string {
|
|
725
|
+
if (entry.annotation.scope === "text") return `${sanitizeStatusText(this.#textSource?.label ?? "text")} · text`;
|
|
726
|
+
return `${sanitizeStatusText(this.#textSource?.label ?? "text")} · line ${entry.annotation.line}`;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
#pushUndo(): void {
|
|
730
|
+
this.#undoStack.push({ annotations: [...this.#annotations], textAnnotations: [...this.#textAnnotations] });
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
#undoAnnotation(): void {
|
|
734
|
+
const snapshot = this.#undoStack.pop();
|
|
735
|
+
if (!snapshot) return;
|
|
736
|
+
this.#annotations = snapshot.annotations;
|
|
737
|
+
this.#textAnnotations = snapshot.textAnnotations;
|
|
738
|
+
if (this.#annotations.length === 0 && this.#actionIndex === 1) this.#actionIndex = 0;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
async #openAnnotationEditor(): Promise<void> {
|
|
742
|
+
if (this.#externalOperation || !this.#annotating) return;
|
|
743
|
+
const openEditor = this.#callbacks.onAnnotationExternalEditor;
|
|
744
|
+
if (!openEditor) {
|
|
745
|
+
this.#callbacks.onWarning?.("External editor is unavailable in this UI host.");
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
const draft = this.#editor.getExpandedText();
|
|
749
|
+
this.#externalOperation = true;
|
|
750
|
+
try {
|
|
751
|
+
await openEditor(draft, text => {
|
|
752
|
+
if (text !== null) this.#editor.setText(text);
|
|
753
|
+
});
|
|
754
|
+
} catch (error) {
|
|
755
|
+
this.#callbacks.onWarning?.(
|
|
756
|
+
`Failed to open external editor: ${error instanceof Error ? error.message : String(error)}`,
|
|
757
|
+
);
|
|
758
|
+
} finally {
|
|
759
|
+
this.#externalOperation = false;
|
|
760
|
+
this.#tui.requestRender(true);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
#annotationCount(fileIndex: number): number {
|
|
765
|
+
let count = 0;
|
|
766
|
+
for (const entry of this.#annotations) if (entry.fileIndex === fileIndex) count++;
|
|
767
|
+
return count;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
#renderBody(contentWidth: number): RenderedBody {
|
|
771
|
+
if (this.#textSource) return this.#renderTextBody(contentWidth);
|
|
772
|
+
const file = this.#currentFile();
|
|
773
|
+
if (!file)
|
|
774
|
+
return {
|
|
775
|
+
lines: [this.#theme.fg("dim", "No reviewable files")],
|
|
776
|
+
renderedRowBySource: [],
|
|
777
|
+
};
|
|
778
|
+
const staticBody = this.#getStaticRenderedBody(file);
|
|
779
|
+
const lines: string[] = [];
|
|
780
|
+
const renderedRowBySource: number[] = [];
|
|
781
|
+
|
|
782
|
+
// File notes describe the whole change, so keep them above headers and
|
|
783
|
+
// status rows (including binary and rename-only files).
|
|
784
|
+
for (const entry of this.#annotations) {
|
|
785
|
+
if (entry.fileIndex === this.#fileIndex && entry.annotation.scope === "file") {
|
|
786
|
+
this.#appendAnnotationCallout(lines, entry.annotation.note, contentWidth, "file note");
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
let sourceIndex = 0;
|
|
791
|
+
for (let staticRow = 0; staticRow < staticBody.lines.length; staticRow++) {
|
|
792
|
+
const currentSourceIndex =
|
|
793
|
+
staticBody.renderedRowBySource[sourceIndex] === staticRow ? sourceIndex++ : undefined;
|
|
794
|
+
let renderedLine = staticBody.lines[staticRow] ?? "";
|
|
795
|
+
if (currentSourceIndex !== undefined) {
|
|
796
|
+
// Line notes belong immediately before their source row. This keeps
|
|
797
|
+
// the selected row and all notes in a stable, readable order.
|
|
798
|
+
for (const entry of this.#annotations) {
|
|
799
|
+
if (
|
|
800
|
+
entry.fileIndex === this.#fileIndex &&
|
|
801
|
+
entry.sourceIndex === currentSourceIndex &&
|
|
802
|
+
entry.annotation.scope === "line"
|
|
803
|
+
) {
|
|
804
|
+
this.#appendAnnotationCallout(lines, entry.annotation.note, contentWidth);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
renderedRowBySource[currentSourceIndex] = lines.length;
|
|
808
|
+
if (this.#focus === "diff" && currentSourceIndex === this.#sourceIndex) {
|
|
809
|
+
renderedLine = this.#theme.bg(
|
|
810
|
+
"selectedBg",
|
|
811
|
+
fit(staticBody.selectedSourceLines[currentSourceIndex] ?? "", contentWidth),
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
lines.push(truncateToWidth(renderedLine, contentWidth));
|
|
816
|
+
}
|
|
817
|
+
return { lines, renderedRowBySource };
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
#renderTextBody(contentWidth: number): RenderedBody {
|
|
821
|
+
const lines: string[] = [];
|
|
822
|
+
const renderedRowBySource: number[] = [];
|
|
823
|
+
const textWidth = Math.max(1, contentWidth - SOURCE_SELECTION_GUTTER_WIDTH);
|
|
824
|
+
|
|
825
|
+
for (const entry of this.#textAnnotations) {
|
|
826
|
+
if (entry.annotation.scope === "text") {
|
|
827
|
+
this.#appendAnnotationCallout(lines, entry.annotation.note, contentWidth, "text note");
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
for (const [sourceIndex, sourceLine] of this.#textLines.entries()) {
|
|
832
|
+
for (const entry of this.#textAnnotations) {
|
|
833
|
+
if (entry.annotation.scope === "line" && entry.sourceIndex === sourceIndex) {
|
|
834
|
+
this.#appendAnnotationCallout(lines, entry.annotation.note, contentWidth);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
renderedRowBySource[sourceIndex] = lines.length;
|
|
838
|
+
const displayLine = replaceTabs(sanitizeText(sourceLine));
|
|
839
|
+
const wrapped = wrapTextWithAnsi(displayLine, textWidth);
|
|
840
|
+
const visualRows = wrapped.length > 0 ? wrapped : [""];
|
|
841
|
+
for (const [rowIndex, visualRow] of visualRows.entries()) {
|
|
842
|
+
const selected = this.#focus === "diff" && sourceIndex === this.#sourceIndex;
|
|
843
|
+
const gutter =
|
|
844
|
+
selected && rowIndex === 0
|
|
845
|
+
? fit(`${this.#theme.nav.cursor} `, SOURCE_SELECTION_GUTTER_WIDTH)
|
|
846
|
+
: " ".repeat(SOURCE_SELECTION_GUTTER_WIDTH);
|
|
847
|
+
const renderedLine = fit(`${gutter}${visualRow}`, contentWidth);
|
|
848
|
+
lines.push(selected ? this.#theme.bg("selectedBg", renderedLine) : renderedLine);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
return { lines, renderedRowBySource };
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
#getStaticRenderedBody(file: ReviewDiffFile): RenderedDiffBody {
|
|
855
|
+
const cached = this.#staticRenderedDiffBodies.get(file);
|
|
856
|
+
if (cached) return cached;
|
|
857
|
+
const lines: string[] = [];
|
|
858
|
+
const renderedRowBySource: number[] = [];
|
|
859
|
+
const selectedSourceLines: string[] = [];
|
|
860
|
+
if (file.isBinary) {
|
|
861
|
+
lines.push(this.#theme.fg("dim", "Binary diff; no annotatable source rows"));
|
|
862
|
+
} else if (file.rows.length === 0) {
|
|
863
|
+
lines.push(this.#theme.fg("dim", "No diff hunks; this may be a rename-only change"));
|
|
864
|
+
} else {
|
|
865
|
+
let sourceIndex = 0;
|
|
866
|
+
for (const diffRow of file.rows) {
|
|
867
|
+
if (!isSourceRow(diffRow)) {
|
|
868
|
+
lines.push(
|
|
869
|
+
this.#theme.fg(diffRow.kind === "hunk" ? "accent" : "dim", replaceTabs(sanitizeText(diffRow.raw))),
|
|
870
|
+
);
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
renderedRowBySource[sourceIndex] = lines.length;
|
|
874
|
+
const marker = diffRow.kind === "added" ? "+" : diffRow.kind === "removed" ? "-" : " ";
|
|
875
|
+
const lineNumber = diffRow.kind === "removed" ? diffRow.oldLine : (diffRow.newLine ?? diffRow.oldLine);
|
|
876
|
+
const suffix = `${marker}${String(lineNumber ?? "").padStart(4)} ${replaceTabs(sanitizeText(diffRow.content))}`;
|
|
877
|
+
const color: DiffColor =
|
|
878
|
+
diffRow.kind === "added"
|
|
879
|
+
? "toolDiffAdded"
|
|
880
|
+
: diffRow.kind === "removed"
|
|
881
|
+
? "toolDiffRemoved"
|
|
882
|
+
: "toolDiffContext";
|
|
883
|
+
const coloredSuffix = this.#theme.fg(color, suffix);
|
|
884
|
+
const gutter = " ".repeat(SOURCE_SELECTION_GUTTER_WIDTH);
|
|
885
|
+
selectedSourceLines[sourceIndex] = `${fit(
|
|
886
|
+
`${this.#theme.nav.cursor} `,
|
|
887
|
+
SOURCE_SELECTION_GUTTER_WIDTH,
|
|
888
|
+
)}${coloredSuffix}`;
|
|
889
|
+
lines.push(`${gutter}${coloredSuffix}`);
|
|
890
|
+
sourceIndex++;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
const body = { lines, renderedRowBySource, selectedSourceLines };
|
|
894
|
+
this.#staticRenderedDiffBodies.set(file, body);
|
|
895
|
+
return body;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
#appendAnnotationCallout(lines: string[], note: string, width: number, label = "note"): void {
|
|
899
|
+
const gutter = this.#theme.fg("warning", "▎ ");
|
|
900
|
+
const labelText = this.#theme.fg("dim", `${label}: `);
|
|
901
|
+
const continuation = `${gutter}${" ".repeat(visibleWidth(labelText))}`;
|
|
902
|
+
for (const [index, noteLine] of note.split(/\r?\n/).entries()) {
|
|
903
|
+
const prefix = index === 0 ? `${gutter}${labelText}` : continuation;
|
|
904
|
+
const available = Math.max(0, width - visibleWidth(prefix));
|
|
905
|
+
const content = truncateToWidth(replaceTabs(sanitizeText(noteLine)), available, Ellipsis.Unicode);
|
|
906
|
+
lines.push(truncateToWidth(`${prefix}${this.#theme.fg("accent", content)}`, width));
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
#renderCurrentFileHeader(width: number): string {
|
|
911
|
+
if (this.#textSource) {
|
|
912
|
+
const count = this.#textAnnotations.length;
|
|
913
|
+
const suffix = count ? this.#theme.fg("dim", ` ✎${count}`) : "";
|
|
914
|
+
return truncateToWidth(
|
|
915
|
+
`${this.#theme.bold(sanitizeStatusText(this.#textSource.label))}${suffix}`,
|
|
916
|
+
width,
|
|
917
|
+
Ellipsis.Unicode,
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
const file = this.#currentFile();
|
|
921
|
+
if (!file) return this.#theme.fg("dim", "No reviewable files");
|
|
922
|
+
const count = this.#annotationCount(this.#fileIndex);
|
|
923
|
+
const suffix = ` +${file.linesAdded}/-${file.linesRemoved}${count ? ` ✎${count}` : ""}`;
|
|
924
|
+
return truncateToWidth(
|
|
925
|
+
`${this.#theme.bold(sanitizeStatusText(displayFileLabel(file)))}${this.#theme.fg("dim", suffix)}`,
|
|
926
|
+
width,
|
|
927
|
+
Ellipsis.Unicode,
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
#renderActions(): string[] {
|
|
932
|
+
const hasAnnotations = this.#textSource ? this.#textAnnotations.length > 0 : this.#annotations.length > 0;
|
|
933
|
+
return this.#actions.map((label, index) => {
|
|
934
|
+
const disabled = this.#textSource ? !hasAnnotations : index === 1 && !hasAnnotations;
|
|
935
|
+
const selected = index === this.#actionIndex;
|
|
936
|
+
const cursor = selected ? `${this.#theme.nav.cursor} ` : " ";
|
|
937
|
+
const text = disabled
|
|
938
|
+
? this.#theme.fg("dim", label)
|
|
939
|
+
: selected && this.#focus === "actions"
|
|
940
|
+
? this.#theme.bold(this.#theme.fg("accent", label))
|
|
941
|
+
: this.#theme.fg("text", label);
|
|
942
|
+
return cursor + text;
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
#renderAnnotationChooser(width: number, maxRows = Number.POSITIVE_INFINITY): string[] {
|
|
947
|
+
const chooser = this.#annotationChooser;
|
|
948
|
+
if (!chooser || maxRows <= 0) return [];
|
|
949
|
+
const availableRows = Number.isFinite(maxRows) ? Math.max(0, Math.floor(maxRows)) : Number.POSITIVE_INFINITY;
|
|
950
|
+
if (availableRows === 1) {
|
|
951
|
+
const index = chooser.entries[chooser.selected];
|
|
952
|
+
if (index === undefined) return [];
|
|
953
|
+
return [this.#renderAnnotationChooserEntry(chooser, index, chooser.selected, width)];
|
|
954
|
+
}
|
|
955
|
+
const optionLimit = Number.isFinite(availableRows) ? Math.max(1, availableRows - 2) : chooser.entries.length;
|
|
956
|
+
const start =
|
|
957
|
+
optionLimit >= chooser.entries.length
|
|
958
|
+
? 0
|
|
959
|
+
: Math.max(
|
|
960
|
+
0,
|
|
961
|
+
Math.min(chooser.selected - Math.floor(optionLimit / 2), chooser.entries.length - optionLimit),
|
|
962
|
+
);
|
|
963
|
+
const options = chooser.entries
|
|
964
|
+
.slice(start, start + optionLimit)
|
|
965
|
+
.map((index, windowIndex) => this.#renderAnnotationChooserEntry(chooser, index, start + windowIndex, width));
|
|
966
|
+
const lines =
|
|
967
|
+
availableRows === 2
|
|
968
|
+
? [this.#theme.fg("dim", "Edit annotation · ↑↓ choose · enter edit · esc cancel"), ...options.slice(0, 1)]
|
|
969
|
+
: [
|
|
970
|
+
this.#theme.fg("dim", "Edit annotation · ↑↓ choose · enter edit · esc cancel"),
|
|
971
|
+
...options,
|
|
972
|
+
this.#theme.fg("dim", "↑↓ choose · enter edit · esc cancel"),
|
|
973
|
+
];
|
|
974
|
+
return Number.isFinite(availableRows) ? lines.slice(0, availableRows) : lines;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
#renderAnnotationChooserEntry(
|
|
978
|
+
chooser: AnnotationChooser,
|
|
979
|
+
index: number,
|
|
980
|
+
optionIndex: number,
|
|
981
|
+
width: number,
|
|
982
|
+
): string {
|
|
983
|
+
const marker = optionIndex === chooser.selected ? `${this.#theme.nav.cursor} ` : " ";
|
|
984
|
+
if (chooser.kind === "text") {
|
|
985
|
+
const entry = this.#textAnnotations[index];
|
|
986
|
+
if (!entry) return "";
|
|
987
|
+
const location = this.#textAnnotationLocation(entry);
|
|
988
|
+
const note = sanitizeStatusText(entry.annotation.note.split(/\r?\n/, 1)[0] ?? "");
|
|
989
|
+
return fit(`${marker}${location} · ${note}`, width);
|
|
990
|
+
}
|
|
991
|
+
const entry = this.#annotations[index];
|
|
992
|
+
if (!entry) return "";
|
|
993
|
+
const location = this.#annotationLocation(entry);
|
|
994
|
+
const note = sanitizeStatusText(entry.annotation.note.split(/\r?\n/, 1)[0] ?? "");
|
|
995
|
+
return fit(`${marker}${location} · ${note}`, width);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
#renderFooter(width: number, maxChooserRows = Number.POSITIVE_INFINITY): string[] {
|
|
999
|
+
if (this.#annotationChooser) {
|
|
1000
|
+
return this.#renderAnnotationChooser(width, maxChooserRows);
|
|
1001
|
+
}
|
|
1002
|
+
if (this.#annotating) {
|
|
1003
|
+
let location: string;
|
|
1004
|
+
let action: string;
|
|
1005
|
+
if (this.#textSource) {
|
|
1006
|
+
const label = sanitizeStatusText(this.#textSource.label);
|
|
1007
|
+
location =
|
|
1008
|
+
this.#annotationScope === "text" ? `${label} · text` : `${label} · line ${this.#sourceIndex + 1}`;
|
|
1009
|
+
action =
|
|
1010
|
+
this.#editingAnnotationIndex === undefined
|
|
1011
|
+
? this.#annotationScope === "text"
|
|
1012
|
+
? "Annotate text"
|
|
1013
|
+
: "Annotate line"
|
|
1014
|
+
: "Edit annotation";
|
|
1015
|
+
} else {
|
|
1016
|
+
const file = this.#currentFile();
|
|
1017
|
+
const source = this.#currentSourceRows()[this.#sourceIndex];
|
|
1018
|
+
location =
|
|
1019
|
+
this.#annotationScope === "file"
|
|
1020
|
+
? file
|
|
1021
|
+
? `${displayFileLabel(file)} · file`
|
|
1022
|
+
: "file"
|
|
1023
|
+
: source && file
|
|
1024
|
+
? `${displayFileLabel(file)} · ${source.oldLine ?? "-"}/${source.newLine ?? "-"}`
|
|
1025
|
+
: "diff row";
|
|
1026
|
+
action =
|
|
1027
|
+
this.#editingAnnotationIndex === undefined
|
|
1028
|
+
? this.#annotationScope === "file"
|
|
1029
|
+
? "Annotate file"
|
|
1030
|
+
: "Annotate line"
|
|
1031
|
+
: "Edit annotation";
|
|
1032
|
+
}
|
|
1033
|
+
const caption = truncateToWidth(
|
|
1034
|
+
`${this.#theme.fg("dim", action)} ${this.#theme.fg("accent", sanitizeStatusText(location))}`,
|
|
1035
|
+
width,
|
|
1036
|
+
Ellipsis.Unicode,
|
|
1037
|
+
);
|
|
1038
|
+
const hints = ["enter save", "shift+enter newline", "esc cancel"];
|
|
1039
|
+
if (this.#externalEditorLabel) hints.push(`${this.#externalEditorLabel} editor`);
|
|
1040
|
+
this.#editor.focused = true;
|
|
1041
|
+
return [caption, ...this.#editor.render(width), this.#theme.fg("dim", hints.join(" · "))];
|
|
1042
|
+
}
|
|
1043
|
+
const focusHelp =
|
|
1044
|
+
this.#focus === "files"
|
|
1045
|
+
? "↑↓ file · ⏎ diff · a/A file note · e edit note"
|
|
1046
|
+
: this.#focus === "diff"
|
|
1047
|
+
? this.#textSource
|
|
1048
|
+
? "↑↓ line · ⇧ faster · pgup/pgdn · g/G ends · a line note · A text note · e edit note"
|
|
1049
|
+
: "↑↓ line · ⇧ faster · pgup/pgdn · g/G ends · a line note · A file note · e edit note"
|
|
1050
|
+
: "↑↓ select · ⏎ confirm";
|
|
1051
|
+
return [this.#theme.fg("dim", `${focusHelp} · u undo · tab regions · esc cancel`)];
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
#ensureCursorVisible(renderedRowBySource: readonly number[]): void {
|
|
1055
|
+
if (this.#textSource && this.#textViewportDriven) return;
|
|
1056
|
+
const rowIndex = renderedRowBySource[this.#sourceIndex];
|
|
1057
|
+
if (rowIndex === undefined) return;
|
|
1058
|
+
const offset = this.#scrollView.getScrollOffset();
|
|
1059
|
+
if (rowIndex < offset) this.#scrollView.setScrollOffset(rowIndex);
|
|
1060
|
+
else if (rowIndex >= offset + this.#bodyHeight) this.#scrollView.setScrollOffset(rowIndex - this.#bodyHeight + 1);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
#sidebarWidth(width: number): number {
|
|
1064
|
+
return Math.max(18, Math.min(32, Math.round(width * 0.28)));
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
#canShowSidebar(width: number): boolean {
|
|
1068
|
+
return (
|
|
1069
|
+
width >= SIDEBAR_MIN_TOTAL_WIDTH && splitBodyWidth(width, this.#sidebarWidth(width)) >= SIDEBAR_MIN_BODY_WIDTH
|
|
1070
|
+
);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
#renderSidebar(rows: number, width: number): string[] {
|
|
1074
|
+
const start = Math.max(
|
|
1075
|
+
0,
|
|
1076
|
+
Math.min(this.#fileIndex - Math.floor(rows / 2), Math.max(0, this.#files.length - rows)),
|
|
1077
|
+
);
|
|
1078
|
+
return Array.from({ length: rows }, (_, rowIndex) => {
|
|
1079
|
+
const index = start + rowIndex;
|
|
1080
|
+
const file = this.#files[index];
|
|
1081
|
+
if (!file) return "";
|
|
1082
|
+
const selected = index === this.#fileIndex;
|
|
1083
|
+
const count = this.#annotationCount(index);
|
|
1084
|
+
const badge = `${this.#theme.fg("dim", ` +${file.linesAdded}/-${file.linesRemoved}`)}${count ? this.#theme.fg("warning", ` ✎${count}`) : ""}`;
|
|
1085
|
+
const available = Math.max(0, width - visibleWidth(badge) - 2);
|
|
1086
|
+
const label = truncateToWidth(sanitizeStatusText(displayFileLabel(file)), available, Ellipsis.Unicode);
|
|
1087
|
+
const cursor = selected ? (this.#focus === "files" ? "› " : "▎ ") : " ";
|
|
1088
|
+
const line = fit(`${cursor}${label}${badge}`, width);
|
|
1089
|
+
return selected && this.#focus === "files"
|
|
1090
|
+
? this.#theme.bg("selectedBg", this.#theme.bold(line))
|
|
1091
|
+
: this.#theme.fg(selected ? "accent" : "muted", line);
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
render(width: number): readonly string[] {
|
|
1096
|
+
const terminalHeight = process.stdout.rows || 40;
|
|
1097
|
+
this.#sidebarShown = this.#textSource ? false : this.#canShowSidebar(width);
|
|
1098
|
+
if (!this.#sidebarShown && this.#focus === "files") this.#focus = "diff";
|
|
1099
|
+
const sidebarWidth = this.#sidebarShown ? this.#sidebarWidth(width) : 0;
|
|
1100
|
+
const innerWidth = Math.max(1, width - 4);
|
|
1101
|
+
const bodyWidth = this.#sidebarShown ? splitBodyWidth(width, sidebarWidth) : innerWidth;
|
|
1102
|
+
this.#editor.setMaxHeight(Math.max(1, Math.min(MAX_ANNOTATION_EDITOR_ROWS, terminalHeight - 12)));
|
|
1103
|
+
this.#editor.focused = this.#annotating;
|
|
1104
|
+
const chooserBaseRows = terminalHeight - (this.#actions.length + 7);
|
|
1105
|
+
const chooserFooterRows = this.#annotationChooser
|
|
1106
|
+
? Math.max(0, Math.min(chooserBaseRows, Math.max(3, chooserBaseRows - MIN_BODY_ROWS)))
|
|
1107
|
+
: Number.POSITIVE_INFINITY;
|
|
1108
|
+
const footer = this.#renderFooter(innerWidth, chooserFooterRows);
|
|
1109
|
+
const fixedRows = this.#actions.length + footer.length + 7;
|
|
1110
|
+
const availableBodyRows = terminalHeight - fixedRows;
|
|
1111
|
+
this.#bodyHeight = this.#annotationChooser
|
|
1112
|
+
? Math.max(0, availableBodyRows)
|
|
1113
|
+
: Math.max(MIN_BODY_ROWS, availableBodyRows);
|
|
1114
|
+
const renderedBody = this.#renderBody(this.#textSource ? Math.max(1, bodyWidth - 1) : bodyWidth);
|
|
1115
|
+
this.#scrollView.setLines(renderedBody.lines);
|
|
1116
|
+
this.#scrollView.setHeight(this.#bodyHeight);
|
|
1117
|
+
if (this.#textSource) {
|
|
1118
|
+
this.#textRenderedRowBySource = renderedBody.renderedRowBySource;
|
|
1119
|
+
if (this.#textViewportDriven) this.#syncTextCursorToViewport();
|
|
1120
|
+
}
|
|
1121
|
+
this.#ensureCursorVisible(renderedBody.renderedRowBySource);
|
|
1122
|
+
const body = this.#scrollView.render(bodyWidth);
|
|
1123
|
+
const output: string[] = [];
|
|
1124
|
+
if (this.#sidebarShown) {
|
|
1125
|
+
const sidebar = this.#renderSidebar(this.#bodyHeight + 1, sidebarWidth);
|
|
1126
|
+
output.push(topBorderSplit(width, this.#textSource ? TEXT_OVERLAY_TITLE : OVERLAY_TITLE, sidebarWidth));
|
|
1127
|
+
output.push(splitRow(sidebar[0] ?? "", this.#renderCurrentFileHeader(bodyWidth), width, sidebarWidth));
|
|
1128
|
+
for (let index = 0; index < this.#bodyHeight; index++) {
|
|
1129
|
+
output.push(splitRow(sidebar[index + 1] ?? "", body[index] ?? "", width, sidebarWidth));
|
|
1130
|
+
}
|
|
1131
|
+
output.push(dividerSplit(width, sidebarWidth));
|
|
1132
|
+
} else {
|
|
1133
|
+
output.push(topBorder(width, this.#textSource ? TEXT_OVERLAY_TITLE : OVERLAY_TITLE));
|
|
1134
|
+
output.push(row(this.#renderCurrentFileHeader(innerWidth), width));
|
|
1135
|
+
for (const bodyLine of body) output.push(row(bodyLine, width));
|
|
1136
|
+
output.push(divider(width));
|
|
1137
|
+
}
|
|
1138
|
+
output.push(row(this.#theme.bold(this.#theme.fg("accent", sanitizeStatusText(this.#mode))), width));
|
|
1139
|
+
for (const action of this.#renderActions()) output.push(row(action, width));
|
|
1140
|
+
output.push(divider(width));
|
|
1141
|
+
for (const footerLine of footer) output.push(row(footerLine, width));
|
|
1142
|
+
output.push(bottomBorder(width));
|
|
1143
|
+
return output;
|
|
1144
|
+
}
|
|
1145
|
+
}
|