nugit-cli 0.1.0 → 0.1.1

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.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/src/api-client.js +0 -12
  3. package/src/github-rest.js +35 -0
  4. package/src/nugit-config.js +84 -0
  5. package/src/nugit-stack.js +29 -266
  6. package/src/nugit.js +103 -661
  7. package/src/review-hub/review-hub-ink.js +6 -3
  8. package/src/review-hub/run-review-hub.js +34 -91
  9. package/src/services/repo-branches.js +151 -0
  10. package/src/services/stack-inference.js +90 -0
  11. package/src/split-view/run-split.js +14 -89
  12. package/src/stack-view/infer-chains-to-pick-stacks.js +10 -0
  13. package/src/stack-view/ink-app.js +3 -2118
  14. package/src/stack-view/loader.js +19 -93
  15. package/src/stack-view/loading-ink.js +2 -44
  16. package/src/stack-view/merge-alternate-pick-stacks.js +23 -1
  17. package/src/stack-view/remote-infer-doc.js +28 -45
  18. package/src/stack-view/run-stack-view.js +249 -526
  19. package/src/stack-view/run-view-entry.js +14 -18
  20. package/src/stack-view/stack-pick-ink.js +169 -131
  21. package/src/stack-view/stack-picker-graph-pane.js +118 -0
  22. package/src/stack-view/terminal-fullscreen.js +7 -45
  23. package/src/tui/pages/home.js +122 -0
  24. package/src/tui/pages/repo-actions.js +81 -0
  25. package/src/tui/pages/repo-branches.js +259 -0
  26. package/src/tui/pages/viewer.js +2129 -0
  27. package/src/tui/router.js +40 -0
  28. package/src/tui/run-tui.js +281 -0
  29. package/src/utilities/loading.js +37 -0
  30. package/src/utilities/terminal.js +31 -0
  31. package/src/cli-output.js +0 -228
  32. package/src/nugit-start.js +0 -211
  33. package/src/stack-discover.js +0 -292
  34. package/src/stack-discovery-config.js +0 -91
  35. package/src/stack-extra-commands.js +0 -353
  36. package/src/stack-graph.js +0 -214
  37. package/src/stack-helpers.js +0 -58
  38. package/src/stack-propagate.js +0 -422
@@ -1,2118 +1,3 @@
1
- import React, { useEffect, useMemo, useRef, useState } from "react";
2
- import { Box, Text, useApp, useInput, useStdin, useStdout } from "ink";
3
- import chalk from "chalk";
4
- import { openUrl } from "./open-url.js";
5
- import { githubGetGitTree, githubGetBlobText } from "../github-rest.js";
6
- import { buildAsciiTreeRows } from "./tree-ascii.js";
7
- import { buildMergedPreviewFromPatch } from "./patch-preview-merge.js";
8
- import { buildStackBranchGraphLines } from "./stack-branch-graph.js";
9
- import { markdownToPlainLines } from "./view-md-plain.js";
10
- import {
11
- parseSgrMouse,
12
- enableSgrMouse,
13
- disableSgrMouse,
14
- isWheelUp,
15
- isWheelDown
16
- } from "./sgr-mouse.js";
17
-
18
- /** @returns {{ next: null | Record<string, unknown> }} */
19
- export function createExitPayload() {
20
- return { next: null };
21
- }
22
-
23
- const TAB_COUNT = 5;
24
- const MAX_PREVIEW_CHARS = 800_000;
25
- const MAX_PREVIEW_LINES = 12_000;
26
- const SPIN_FRAMES = ["|", "/", "-", "\\"];
27
-
28
- /** Shell: single full-screen workspace tabs (lazyql-style) */
29
- const SHELL_TAB_LABELS = ["Tree", "Description", "Conversation", "Review"];
30
- const SHELL_TAB_COUNT = 4;
31
-
32
- /**
33
- * Line change totals for a stack row (GitHub pull or sum of file stats).
34
- * @param {{ pull?: unknown, files?: unknown[] }} r
35
- */
36
- function prAddDelTotals(r) {
37
- const pull =
38
- r?.pull && typeof r.pull === "object"
39
- ? /** @type {Record<string, unknown>} */ (r.pull)
40
- : null;
41
- let add = pull != null ? Number(pull.additions) : NaN;
42
- let del = pull != null ? Number(pull.deletions) : NaN;
43
- if (!Number.isFinite(add)) add = 0;
44
- if (!Number.isFinite(del)) del = 0;
45
- if (add === 0 && del === 0 && Array.isArray(r?.files) && r.files.length > 0) {
46
- for (const f of r.files) {
47
- if (!f || typeof f !== "object") continue;
48
- const o = /** @type {Record<string, unknown>} */ (f);
49
- add += Number(o.additions) || 0;
50
- del += Number(o.deletions) || 0;
51
- }
52
- }
53
- return { add, del };
54
- }
55
-
56
- /**
57
- * Tree tab layout: never show patch + file preview at once — preview carries diff styling.
58
- * @param {number} bodyMax
59
- * @param {boolean} previewOpen
60
- * @param {boolean} showPatchPane unified diff below tree (only when preview closed)
61
- */
62
- function computeShellTreePaneHeights(bodyMax, previewOpen, showPatchPane) {
63
- if (previewOpen) {
64
- const treeBlockH = Math.max(3, Math.floor(bodyMax * 0.34));
65
- const previewH = Math.max(4, bodyMax - treeBlockH - 3);
66
- return { treeBlockH, patchH: 0, previewH };
67
- }
68
- if (showPatchPane) {
69
- const treeBlockH = Math.max(3, Math.floor(bodyMax * 0.36));
70
- const patchH = Math.max(4, bodyMax - treeBlockH - 3);
71
- return { treeBlockH, patchH, previewH: 0 };
72
- }
73
- return { treeBlockH: Math.max(2, bodyMax - 1), patchH: 0, previewH: 0 };
74
- }
75
-
76
- /**
77
- * @param {string} s
78
- * @param {number} w
79
- */
80
- function truncVis(s, w) {
81
- if (w < 4) return "";
82
- if (s.length <= w) return s;
83
- return s.slice(0, w - 1) + "\u2026";
84
- }
85
-
86
- /**
87
- * @template T
88
- * @param {T[]} items
89
- * @param {number} cursor
90
- * @param {number} maxVisible
91
- */
92
- function sliceViewport(items, cursor, maxVisible) {
93
- const n = items.length;
94
- if (n === 0) return { slice: [], start: 0 };
95
- if (n <= maxVisible) return { slice: items, start: 0 };
96
- const half = Math.floor(maxVisible / 2);
97
- const start = Math.max(0, Math.min(cursor - half, n - maxVisible));
98
- return { slice: items.slice(start, start + maxVisible), start };
99
- }
100
-
101
- /**
102
- * @param {object} props
103
- * @param {Awaited<ReturnType<import('./fetch-pr-data.js').fetchStackPrDetails>>} props.rows
104
- * @param {{ next: null | Record<string, unknown> }} props.exitPayload
105
- * @param {string} [props.viewTitle]
106
- * @param {string} [props.browseOwner]
107
- * @param {string} [props.browseRepo]
108
- * @param {string} [props.repoFullName]
109
- * @param {boolean} [props.shellMode] multi-page layout with sidebar (nugit view)
110
- * @param {unknown[] | null} [props.alternateStacks] when >1, Backspace from PR-pick opens stack picker
111
- */
112
- export function StackInkApp({
113
- rows: stackRows,
114
- exitPayload,
115
- viewTitle = "nugit view",
116
- browseOwner = "",
117
- browseRepo = "",
118
- repoFullName = "",
119
- shellMode = false,
120
- alternateStacks = null
121
- }) {
122
- const canPickAlternateStack = Array.isArray(alternateStacks) && alternateStacks.length > 1;
123
- const { exit } = useApp();
124
- const { stdout } = useStdout();
125
- const { stdin } = useStdin();
126
- const [termCols, setTermCols] = useState(() => stdout?.columns || 80);
127
- const [termRows, setTermRows] = useState(() => stdout?.rows || 24);
128
- const [pulse, setPulse] = useState(0);
129
-
130
- const cols = Math.max(40, termCols || 80);
131
- const ttyRows = Math.max(16, termRows || 24);
132
-
133
- useEffect(() => {
134
- const upd = () => {
135
- setTermCols(stdout?.columns || 80);
136
- setTermRows(stdout?.rows || 24);
137
- };
138
- upd();
139
- stdout?.on?.("resize", upd);
140
- return () => stdout?.off?.("resize", upd);
141
- }, [stdout]);
142
-
143
- useEffect(() => {
144
- const t = setInterval(() => setPulse((p) => (p + 1) % 256), 350);
145
- return () => clearInterval(t);
146
- }, []);
147
-
148
- useEffect(() => {
149
- if (!shellMode) {
150
- enableSgrMouse(stdout);
151
- return () => disableSgrMouse(stdout);
152
- }
153
- return undefined;
154
- }, [stdout, shellMode]);
155
-
156
- /** @type {React.MutableRefObject<{ bodyStartR: number, nLad: number, ladderStart: number } | null>} */
157
- const layoutRef = useRef(null);
158
- /** @type {React.MutableRefObject<Array<{ r1: number, r2: number, kind: string, [k: string]: unknown }>>} */
159
- const regionsRef = useRef([]);
160
- /** @type {React.MutableRefObject<{ t: number, idx: number } | null>} */
161
- const lastTreeClickRef = useRef(null);
162
-
163
- const [showFullHelp, setShowFullHelp] = useState(false);
164
- /** Shell: main panel tab (tree first — diffs inline when cursor is on a changed file) */
165
- const [shellMainTab, setShellMainTab] = useState(0);
166
- /** Shell: keyboard focus on sidebar PR ladder; true on entry so j/k adjust PRs before the file tree */
167
- const [shellStackFocus, setShellStackFocus] = useState(() => shellMode);
168
-
169
- /** Shell: -1 = no PR loaded yet (tree waits); non-shell starts at 0 */
170
- const [prIndex, setPrIndex] = useState(() => (shellMode ? -1 : 0));
171
- const [tab, setTab] = useState(0);
172
- const [lineIndex, setLineIndex] = useState(0);
173
- const [fileIndex, setFileIndex] = useState(0);
174
- const [filePatchOffset, setFilePatchOffset] = useState(0);
175
- const [patchCursorLine, setPatchCursorLine] = useState(0);
176
- const [fileCommentIndex, setFileCommentIndex] = useState(0);
177
-
178
- const [treeLines, setTreeLines] = useState(/** @type {{ path: string, type: string }[]} */ ([]));
179
- const [treeLoading, setTreeLoading] = useState(false);
180
- const [treeError, setTreeError] = useState(/** @type {string | null} */ (null));
181
- const [treeLineIndex, setTreeLineIndex] = useState(0);
182
- const [treePreview, setTreePreview] = useState("");
183
- const [treePreviewPath, setTreePreviewPath] = useState("");
184
- const [treePreviewScroll, setTreePreviewScroll] = useState(0);
185
-
186
- const asciiTreeRows = useMemo(() => buildAsciiTreeRows(treeLines), [treeLines]);
187
- const treePreviewLines = useMemo(() => (treePreview ? treePreview.split("\n") : []), [treePreview]);
188
-
189
- useEffect(() => {
190
- setTreeLineIndex((i) => Math.min(i, Math.max(0, asciiTreeRows.length - 1)));
191
- }, [asciiTreeRows.length]);
192
-
193
- const len = stackRows?.length ?? 0;
194
- const hasPrPick = !shellMode || prIndex >= 0;
195
- const safePr = len === 0 || !hasPrPick ? 0 : Math.min(prIndex, len - 1);
196
- const row = len && hasPrPick ? stackRows[safePr] : null;
197
- const issueList = row?.issueComments || [];
198
- const reviewList = row?.reviewComments || [];
199
- const fileList = row?.files || [];
200
- const headSha =
201
- row?.pull?.head && typeof row.pull.head === "object"
202
- ? String(/** @type {Record<string, unknown>} */ (row.pull.head).sha || "")
203
- : "";
204
-
205
- const listLen = useMemo(() => {
206
- if (shellMode) {
207
- if (shellMainTab === 0) {
208
- return Math.max(1, asciiTreeRows.length);
209
- }
210
- if (shellMainTab === 1) {
211
- const bodyRaw = row?.pull && typeof row.pull.body === "string" ? row.pull.body : "";
212
- const plain = markdownToPlainLines(bodyRaw);
213
- const lines = plain ? plain.split("\n").filter((ln) => ln.length > 0) : ["(no description)"];
214
- return Math.max(1, lines.length);
215
- }
216
- if (shellMainTab === 2) {
217
- return issueList.length;
218
- }
219
- if (shellMainTab === 3) {
220
- return reviewList.length;
221
- }
222
- }
223
- if (tab === 1) return issueList.length;
224
- if (tab === 2) return reviewList.length;
225
- if (tab === 3) return fileList.length;
226
- if (tab === 4) return asciiTreeRows.length;
227
- return 0;
228
- }, [
229
- shellMode,
230
- shellMainTab,
231
- tab,
232
- issueList.length,
233
- reviewList.length,
234
- fileList.length,
235
- asciiTreeRows.length,
236
- row
237
- ]);
238
-
239
- const treeSafeLine = Math.min(treeLineIndex, Math.max(0, asciiTreeRows.length - 1));
240
- const safeLine = shellMode
241
- ? shellMainTab === 0
242
- ? treeSafeLine
243
- : Math.min(lineIndex, Math.max(0, listLen - 1))
244
- : tab === 4
245
- ? treeSafeLine
246
- : Math.min(lineIndex, Math.max(0, listLen - 1));
247
- const safeFile = fileList.length === 0 ? 0 : Math.min(fileIndex, fileList.length - 1);
248
- const selectedFile = fileList[safeFile] || null;
249
- const patchLines =
250
- selectedFile && typeof selectedFile.patch === "string"
251
- ? String(selectedFile.patch).split("\n")
252
- : [];
253
-
254
- const browsePreviewOpen = !!(
255
- treePreviewPath &&
256
- (shellMode ? shellMainTab === 0 : tab === 4)
257
- );
258
-
259
- const pathToChangedFile = useMemo(() => {
260
- /** @type {Map<string, Record<string, unknown>>} */
261
- const m = new Map();
262
- for (const f of fileList) {
263
- const n = String(f?.filename || "");
264
- if (n) m.set(n, f);
265
- }
266
- return m;
267
- }, [fileList]);
268
-
269
- const mergedPreviewRows = useMemo(() => {
270
- if (!browsePreviewOpen || !treePreviewPath || !treePreview) {
271
- return null;
272
- }
273
- const fe = fileList.find((f) => String(f?.filename || "") === treePreviewPath);
274
- if (!fe || typeof fe.patch !== "string" || !String(fe.patch).trim()) {
275
- return null;
276
- }
277
- const m = buildMergedPreviewFromPatch(treePreview, String(fe.patch), treePreviewPath);
278
- return m && m.length > 0 ? m : null;
279
- }, [browsePreviewOpen, treePreviewPath, treePreview, fileList]);
280
-
281
- const previewLineCount = mergedPreviewRows?.length ?? treePreviewLines.length;
282
-
283
- useEffect(() => {
284
- if (!browsePreviewOpen) {
285
- return;
286
- }
287
- const maxScroll = Math.max(0, previewLineCount - 1);
288
- setTreePreviewScroll((s) => Math.min(s, maxScroll));
289
- }, [browsePreviewOpen, treePreviewPath, previewLineCount]);
290
-
291
- const fileComments = useMemo(() => {
292
- if (!selectedFile) return [];
293
- const fileName = String(selectedFile.filename || "");
294
- return reviewList.filter((c) => String(c?.path || "") === fileName);
295
- }, [selectedFile, reviewList]);
296
- const safeFileComment = fileComments.length
297
- ? Math.min(fileCommentIndex, fileComments.length - 1)
298
- : 0;
299
-
300
- /** Layout: keep total output within terminal height */
301
- const sidebarW = shellMode ? 28 : 0;
302
- const innerW = shellMode
303
- ? Math.max(24, cols - 2 - sidebarW - 1 - 1)
304
- : Math.max(20, cols - 2);
305
-
306
- useEffect(() => {
307
- if (!shellMode) {
308
- return;
309
- }
310
- if (shellMainTab === 0) {
311
- setTab(4);
312
- } else if (shellMainTab === 2) {
313
- setTab(1);
314
- } else if (shellMainTab === 3) {
315
- setTab(2);
316
- } else {
317
- setTab(0);
318
- }
319
- }, [shellMode, shellMainTab]);
320
-
321
- useEffect(() => {
322
- if (!shellMode || shellMainTab !== 0) {
323
- setShellStackFocus(false);
324
- }
325
- }, [shellMode, shellMainTab]);
326
-
327
- useEffect(() => {
328
- if (shellMode && prIndex < 0 && shellMainTab !== 0) {
329
- setShellMainTab(0);
330
- }
331
- }, [shellMode, prIndex, shellMainTab]);
332
-
333
- useEffect(() => {
334
- if (!shellMode || shellMainTab !== 0) {
335
- return;
336
- }
337
- const ent = asciiTreeRows[treeSafeLine];
338
- const p = ent?.path;
339
- if (!p) {
340
- return;
341
- }
342
- const idx = fileList.findIndex((f) => String(f?.filename || "") === p);
343
- if (idx >= 0) {
344
- setFileIndex(idx);
345
- }
346
- }, [shellMode, shellMainTab, treeSafeLine, asciiTreeRows, fileList]);
347
-
348
- const exitRef = useRef(exit);
349
- exitRef.current = exit;
350
- const exitPayloadRef = useRef(exitPayload);
351
- exitPayloadRef.current = exitPayload;
352
- /** @type {React.MutableRefObject<{ canScrollPatch: boolean, maxPatchOffset: number, step: number, canScrollMergedPreview: boolean, mergedMaxScroll: number, mergedStep: number }>} */
353
- const patchScrollRef = useRef({
354
- canScrollPatch: false,
355
- maxPatchOffset: 0,
356
- step: 3,
357
- canScrollMergedPreview: false,
358
- mergedMaxScroll: 0,
359
- mergedStep: 3
360
- });
361
-
362
- const SIDEBAR_GRAPH_MAX = 7;
363
-
364
- /** Shell: title + tab row + rule + gap; footer: rule + up to 2 hint rows */
365
- const reservedTop = shellMode ? 5 : 3;
366
- const reservedHelp = shellMode ? 5 : 2;
367
- const ladderMaxForDisplay = shellMode
368
- ? Math.min(len, Math.max(2, ttyRows - (18 + SIDEBAR_GRAPH_MAX)))
369
- : Math.min(len, Math.max(2, Math.min(7, Math.floor((ttyRows - 10) / 3))));
370
- const ladderMaxReserved = shellMode ? 0 : ladderMaxForDisplay;
371
- const bodyMax = Math.max(
372
- 6,
373
- ttyRows - reservedTop - reservedHelp - ladderMaxReserved - 2
374
- );
375
-
376
- const selPathForPatch = asciiTreeRows[treeSafeLine]?.path;
377
- const hasPatchForShellTree =
378
- !!selPathForPatch &&
379
- selPathForPatch === String(selectedFile?.filename || "") &&
380
- patchLines.length > 0;
381
- const shellTreePanes =
382
- shellMode && shellMainTab === 0
383
- ? computeShellTreePaneHeights(
384
- bodyMax,
385
- browsePreviewOpen,
386
- hasPatchForShellTree && !browsePreviewOpen
387
- )
388
- : null;
389
-
390
- let patchPageSize = 12;
391
- if (tab === 3) {
392
- patchPageSize = Math.max(4, Math.min(20, bodyMax - 8));
393
- } else if (shellTreePanes && shellTreePanes.patchH > 0) {
394
- patchPageSize = Math.max(4, shellTreePanes.patchH);
395
- } else if (shellMode && shellMainTab === 0) {
396
- patchPageSize = Math.max(4, Math.min(20, bodyMax - 8));
397
- }
398
- const maxPatchOffset = Math.max(0, patchLines.length - patchPageSize);
399
-
400
- const canScrollPatchShell =
401
- shellMode &&
402
- shellMainTab === 0 &&
403
- hasPatchForShellTree &&
404
- !browsePreviewOpen;
405
- const previewHForMergedScroll = shellTreePanes?.previewH ?? 4;
406
- const mergedLen = mergedPreviewRows?.length ?? 0;
407
- const mergedMaxScroll = Math.max(0, mergedLen - previewHForMergedScroll);
408
- const canScrollMergedPreview =
409
- shellMode && shellMainTab === 0 && browsePreviewOpen && mergedLen > 0;
410
- patchScrollRef.current = {
411
- canScrollPatch: canScrollPatchShell,
412
- maxPatchOffset,
413
- step: Math.max(3, Math.min(10, patchPageSize)),
414
- canScrollMergedPreview,
415
- mergedMaxScroll,
416
- mergedStep: Math.max(2, Math.min(8, previewHForMergedScroll))
417
- };
418
-
419
- useEffect(() => {
420
- if (!shellMode || !stdin) {
421
- return undefined;
422
- }
423
- const onData = (buf) => {
424
- const s = buf.toString();
425
- if (/\x1b\[15~/.test(s)) {
426
- exitPayloadRef.current.next = { type: "refresh" };
427
- exitRef.current();
428
- return;
429
- }
430
- const r = patchScrollRef.current;
431
- if (/\x1b\[5~/.test(s)) {
432
- if (r.canScrollMergedPreview) {
433
- setTreePreviewScroll((o) => Math.max(0, o - r.mergedStep));
434
- } else if (r.canScrollPatch) {
435
- setFilePatchOffset((o) => Math.max(0, o - r.step));
436
- }
437
- return;
438
- }
439
- if (/\x1b\[6~/.test(s)) {
440
- if (r.canScrollMergedPreview) {
441
- setTreePreviewScroll((o) => Math.min(r.mergedMaxScroll, o + r.mergedStep));
442
- } else if (r.canScrollPatch) {
443
- setFilePatchOffset((o) => Math.min(r.maxPatchOffset, o + r.step));
444
- }
445
- }
446
- };
447
- stdin.on("data", onData);
448
- return () => {
449
- stdin.off("data", onData);
450
- };
451
- }, [shellMode, stdin]);
452
-
453
- const treeViewActive = shellMode ? shellMainTab === 0 : tab === 4;
454
-
455
- useEffect(() => {
456
- if (!treeViewActive || !browseOwner || !browseRepo || !headSha) {
457
- return;
458
- }
459
- let cancelled = false;
460
- setTreeLoading(true);
461
- setTreeError(null);
462
- setTreeLines([]);
463
- setTreePreview("");
464
- setTreePreviewPath("");
465
- setTreePreviewScroll(0);
466
- githubGetGitTree(browseOwner, browseRepo, headSha, true)
467
- .then((data) => {
468
- if (cancelled) return;
469
- const tree = Array.isArray(data.tree) ? data.tree : [];
470
- if (tree.length > 2500) {
471
- setTreeError(`Large tree (${tree.length} entries); showing first 2500`);
472
- }
473
- const cap = tree.slice(0, 2500);
474
- const lines = cap
475
- .filter((t) => t && typeof t === "object" && typeof t.path === "string")
476
- .map((t) => {
477
- const o = /** @type {Record<string, unknown>} */ (t);
478
- return { path: String(o.path), type: String(o.type || "blob") };
479
- })
480
- .sort((a, b) => a.path.localeCompare(b.path));
481
- setTreeLines(lines);
482
- setTreeLoading(false);
483
- })
484
- .catch((e) => {
485
- if (!cancelled) {
486
- setTreeError(String(/** @type {Error} */ (e)?.message || e));
487
- setTreeLoading(false);
488
- }
489
- });
490
- return () => {
491
- cancelled = true;
492
- };
493
- }, [treeViewActive, browseOwner, browseRepo, headSha, safePr]);
494
-
495
- useEffect(() => {
496
- setTreeLineIndex(0);
497
- setTreePreview("");
498
- setTreePreviewPath("");
499
- setTreePreviewScroll(0);
500
- }, [tab, treeViewActive, headSha]);
501
-
502
- const patchOffsetForLine = (lines, lineNo) => {
503
- if (!lineNo || !Number.isInteger(lineNo) || lineNo < 1) return 0;
504
- let newLine = 0;
505
- for (let i = 0; i < lines.length; i++) {
506
- const t = lines[i];
507
- if (t.startsWith("@@")) {
508
- const m = t.match(/\+(\d+)/);
509
- if (m) newLine = Number.parseInt(m[1], 10) - 1;
510
- continue;
511
- }
512
- if (t.startsWith("+") || t.startsWith(" ")) {
513
- newLine += 1;
514
- }
515
- if (newLine >= lineNo) {
516
- return Math.max(0, i - 2);
517
- }
518
- }
519
- return 0;
520
- };
521
-
522
- const closeBrowsePreview = () => {
523
- setTreePreview("");
524
- setTreePreviewPath("");
525
- setTreePreviewScroll(0);
526
- };
527
-
528
- const loadBrowseBlob = (blobPath) => {
529
- if (!browseOwner || !browseRepo || !headSha || !blobPath) {
530
- return;
531
- }
532
- githubGetBlobText(browseOwner, browseRepo, blobPath, headSha).then((text) => {
533
- let t = text || "(empty or unreadable)";
534
- if (t.length > MAX_PREVIEW_CHARS) {
535
- t = t.slice(0, MAX_PREVIEW_CHARS) + "\n\u2026 [truncated at char limit]";
536
- }
537
- const linesArr = t.split("\n");
538
- if (linesArr.length > MAX_PREVIEW_LINES) {
539
- t = linesArr.slice(0, MAX_PREVIEW_LINES).join("\n") + "\n\u2026 [truncated at line limit]";
540
- }
541
- setTreePreviewPath(blobPath);
542
- setTreePreview(t);
543
- setTreePreviewScroll(0);
544
- });
545
- };
546
-
547
- useInput((input, key) => {
548
- if (shellMode && parseSgrMouse(input)) {
549
- return;
550
- }
551
- const mouse = parseSgrMouse(input);
552
- if (mouse) {
553
- const L = layoutRef.current;
554
- if (!L) {
555
- return;
556
- }
557
- const { row, col, button, release } = mouse;
558
- if (col < 2) {
559
- return;
560
- }
561
-
562
- if (isWheelUp(button) || isWheelDown(button)) {
563
- const down = isWheelDown(button);
564
- const step = down ? 3 : -3;
565
- const hit = regionsRef.current.find((z) => row >= z.r1 && row <= z.r2);
566
- if (hit?.kind === "preview" && L.browsePreviewOpen && L.treePreviewLinesLen) {
567
- const pv = Math.max(2, L.browsePreviewH || 2);
568
- const maxS = Math.max(0, L.treePreviewLinesLen - pv);
569
- setTreePreviewScroll((s) => Math.max(0, Math.min(s + step, maxS)));
570
- return;
571
- }
572
- if (hit?.kind === "patch") {
573
- setFilePatchOffset((i) =>
574
- Math.max(0, Math.min(i + (down ? 3 : -3), L.maxPatchOffset || 0))
575
- );
576
- return;
577
- }
578
- if (hit?.kind === "issueLine" || hit?.kind === "reviewLine") {
579
- if (down) {
580
- setLineIndex((i) => Math.min(i + 1, Math.max(0, L.listLen - 1)));
581
- } else {
582
- setLineIndex((i) => Math.max(i - 1, 0));
583
- }
584
- return;
585
- }
586
- if (hit?.kind === "fileLine") {
587
- if (down) {
588
- setFileIndex((i) => Math.min(i + 1, Math.max(0, L.fileListLen - 1)));
589
- setFilePatchOffset(0); setPatchCursorLine(0);
590
- setFileCommentIndex(0);
591
- } else {
592
- setFileIndex((i) => Math.max(i - 1, 0));
593
- setFilePatchOffset(0); setPatchCursorLine(0);
594
- setFileCommentIndex(0);
595
- }
596
- return;
597
- }
598
- if (
599
- hit?.kind === "treeLine" &&
600
- L.treeLen > 0 &&
601
- ((L.tab === 4 && !L.browsePreviewOpen) ||
602
- (L.shellMainTab === 0 && L.shellMode))
603
- ) {
604
- if (L.shellMode && L.shellMainTab === 0 && L.shellStackFocus && L.len > 0) {
605
- if (down) {
606
- setPrIndex((i) => (i < 0 ? 0 : Math.min(i + 1, L.len - 1)));
607
- } else {
608
- setPrIndex((i) => (i <= 0 ? -1 : i - 1));
609
- }
610
- setTreeLineIndex(0);
611
- setFilePatchOffset(0); setPatchCursorLine(0);
612
- return;
613
- }
614
- if (L.shellMode && L.shellMainTab === 0 && L.shellStackFocus) {
615
- return;
616
- }
617
- if (down) {
618
- setTreeLineIndex((i) => Math.min(i + 1, Math.max(0, L.treeLen - 1)));
619
- } else {
620
- setTreeLineIndex((i) => Math.max(i - 1, 0));
621
- }
622
- return;
623
- }
624
- if (L.tab === 0 && L.len > 0) {
625
- if (down) {
626
- setPrIndex((i) => Math.min(i + 1, L.len - 1));
627
- } else {
628
- setPrIndex((i) => Math.max(i - 1, 0));
629
- }
630
- return;
631
- }
632
- if ((L.tab === 1 || L.tab === 2) && L.listLen > 0) {
633
- if (down) {
634
- setLineIndex((i) => Math.min(i + 1, Math.max(0, L.listLen - 1)));
635
- } else {
636
- setLineIndex((i) => Math.max(i - 1, 0));
637
- }
638
- return;
639
- }
640
- if (L.tab === 3) {
641
- setFilePatchOffset((i) =>
642
- Math.max(0, Math.min(i + (down ? 3 : -3), L.maxPatchOffset || 0))
643
- );
644
- return;
645
- }
646
- if (L.tab === 4 && L.browsePreviewOpen && L.treePreviewLinesLen) {
647
- const pv = Math.max(2, L.browsePreviewH || 2);
648
- const maxS = Math.max(0, L.treePreviewLinesLen - pv);
649
- setTreePreviewScroll((s) => Math.max(0, Math.min(s + step, maxS)));
650
- return;
651
- }
652
- if (L.tab === 4 && L.treeLen > 0) {
653
- if (down) {
654
- setTreeLineIndex((i) => Math.min(i + 1, L.treeLen - 1));
655
- } else {
656
- setTreeLineIndex((i) => Math.max(i - 1, 0));
657
- }
658
- }
659
- return;
660
- }
661
-
662
- if (release) {
663
- return;
664
- }
665
- const rightish = (button & 3) === 2 || button === 34;
666
- if (rightish && L.browsePreviewOpen) {
667
- closeBrowsePreview();
668
- return;
669
- }
670
-
671
- const nLad = L.nLad;
672
- if (row >= 3 && row <= 2 + nLad) {
673
- setPrIndex(L.ladderStart + (row - 3));
674
- return;
675
- }
676
-
677
- for (const z of regionsRef.current) {
678
- if (row < z.r1 || row > z.r2) {
679
- continue;
680
- }
681
- if (z.kind === "issueLine") {
682
- setLineIndex(/** @type {number} */ (z.index));
683
- return;
684
- }
685
- if (z.kind === "reviewLine") {
686
- setLineIndex(/** @type {number} */ (z.index));
687
- return;
688
- }
689
- if (z.kind === "fileLine") {
690
- setFileIndex(/** @type {number} */ (z.index));
691
- setFilePatchOffset(0); setPatchCursorLine(0);
692
- setFileCommentIndex(0);
693
- return;
694
- }
695
- if (z.kind === "treeLine") {
696
- const idx = /** @type {number} */ (z.index);
697
- const p = /** @type {string | null | undefined} */ (z.path);
698
- setTreeLineIndex(idx);
699
- const now = Date.now();
700
- const prev = lastTreeClickRef.current;
701
- const dbl = prev && prev.idx === idx && now - prev.t < 500;
702
- lastTreeClickRef.current = { t: now, idx };
703
- if (dbl && p) {
704
- loadBrowseBlob(p);
705
- }
706
- return;
707
- }
708
- if (z.kind === "preview" || z.kind === "patch") {
709
- return;
710
- }
711
- }
712
- return;
713
- }
714
-
715
- const rScroll = patchScrollRef.current;
716
- if (shellMode && shellMainTab === 0 && rScroll.canScrollMergedPreview) {
717
- if (key.pageDown) {
718
- setTreePreviewScroll((i) => Math.min(i + rScroll.mergedStep, rScroll.mergedMaxScroll));
719
- return;
720
- }
721
- if (key.pageUp) {
722
- setTreePreviewScroll((i) => Math.max(i - rScroll.mergedStep, 0));
723
- return;
724
- }
725
- }
726
- const shellPatchScroll =
727
- shellMode &&
728
- shellMainTab === 0 &&
729
- asciiTreeRows[treeSafeLine]?.path === String(selectedFile?.filename || "") &&
730
- patchLines.length > 0 &&
731
- !browsePreviewOpen;
732
- if (shellPatchScroll) {
733
- if (key.pageDown) {
734
- const { step, maxPatchOffset: maxO } = patchScrollRef.current;
735
- setFilePatchOffset((i) => Math.min(i + step, maxO));
736
- setPatchCursorLine((c) => Math.min(c + step, patchLines.length - 1));
737
- return;
738
- }
739
- if (key.pageUp) {
740
- const { step } = patchScrollRef.current;
741
- setFilePatchOffset((i) => Math.max(i - step, 0));
742
- setPatchCursorLine((c) => Math.max(c - step, 0));
743
- return;
744
- }
745
- }
746
-
747
- if (input === "?") {
748
- setShowFullHelp((v) => !v);
749
- return;
750
- }
751
-
752
- if (input === "C" && shellPatchScroll && row && selectedFile && !row.error) {
753
- exitPayload.next = {
754
- type: "pull_review_line_comment",
755
- prNumber: row.entry.pr_number,
756
- path: String(selectedFile.filename || ""),
757
- patchLineIndex: patchCursorLine,
758
- commitId: headSha
759
- };
760
- exit();
761
- return;
762
- }
763
-
764
- const backKey = key.backspace || key.delete || input === "\x7f" || input === "\b";
765
- if (backKey) {
766
- if (browsePreviewOpen) {
767
- closeBrowsePreview();
768
- return;
769
- }
770
- if (shellMode && shellStackFocus && canPickAlternateStack) {
771
- exitPayload.next = { type: "pick_stack" };
772
- exit();
773
- return;
774
- }
775
- if (shellMode && shellStackFocus) {
776
- setShellStackFocus(false);
777
- return;
778
- }
779
- if (shellMode && shellMainTab === 0) {
780
- setShellStackFocus(true);
781
- return;
782
- }
783
- }
784
-
785
- if ((key.return || input === " ") && shellMode && shellStackFocus) {
786
- if (prIndex < 0 && len > 0) {
787
- setPrIndex(0);
788
- return;
789
- }
790
- setShellStackFocus(false);
791
- return;
792
- }
793
-
794
- if (shellMode && /^[1-9]$/.test(input) && len > 0) {
795
- const n = Number.parseInt(input, 10) - 1;
796
- if (n < len) {
797
- setShellStackFocus(false);
798
- setPrIndex(n);
799
- setShellMainTab(0);
800
- setLineIndex(0);
801
- setTreeLineIndex(0);
802
- setFilePatchOffset(0); setPatchCursorLine(0);
803
- return;
804
- }
805
- }
806
-
807
- if (shellMode && input === "[" && len > 0) {
808
- setShellStackFocus(false);
809
- setPrIndex((i) => (i <= 0 ? -1 : i - 1));
810
- setTreeLineIndex(0);
811
- setFilePatchOffset(0); setPatchCursorLine(0);
812
- return;
813
- }
814
- if (shellMode && input === "]" && len > 0) {
815
- setShellStackFocus(false);
816
- setPrIndex((i) => (i < 0 ? 0 : Math.min(len - 1, i + 1)));
817
- setTreeLineIndex(0);
818
- setFilePatchOffset(0); setPatchCursorLine(0);
819
- return;
820
- }
821
- if (input === "O" && row?.pull?.html_url) {
822
- openUrl(String(row.pull.html_url));
823
- return;
824
- }
825
-
826
- if (input === "q" || key.escape) {
827
- if (browsePreviewOpen) {
828
- closeBrowsePreview();
829
- return;
830
- }
831
- if (shellMode && shellStackFocus) {
832
- setShellStackFocus(false);
833
- return;
834
- }
835
- exitPayload.next = { type: "quit" };
836
- exit();
837
- return;
838
- }
839
- if (input === "u") {
840
- exitPayload.next = { type: "refresh" };
841
- exit();
842
- return;
843
- }
844
- if (key.tab) {
845
- if (shellMode && shellStackFocus) {
846
- setShellStackFocus(false);
847
- return;
848
- }
849
- if (shellMode && (!row || len === 0)) {
850
- return;
851
- }
852
- if (shellMode) {
853
- setShellMainTab((t) => (t + 1) % SHELL_TAB_COUNT);
854
- setLineIndex(0);
855
- return;
856
- }
857
- setTab((t) => (t + 1) % TAB_COUNT);
858
- setLineIndex(0);
859
- setTreeLineIndex(0);
860
- setFilePatchOffset(0); setPatchCursorLine(0);
861
- setFileCommentIndex(0);
862
- return;
863
- }
864
- if (!shellMode && input === "[") {
865
- setTab((t) => (t + TAB_COUNT - 1) % TAB_COUNT);
866
- setLineIndex(0);
867
- setTreeLineIndex(0);
868
- setFilePatchOffset(0); setPatchCursorLine(0);
869
- setFileCommentIndex(0);
870
- return;
871
- }
872
- if (!shellMode && input === "]") {
873
- setTab((t) => (t + 1) % TAB_COUNT);
874
- setLineIndex(0);
875
- setTreeLineIndex(0);
876
- setFilePatchOffset(0); setPatchCursorLine(0);
877
- setFileCommentIndex(0);
878
- return;
879
- }
880
-
881
- if (input === "b" && browsePreviewOpen && (tab === 4 || (shellMode && shellMainTab === 0))) {
882
- closeBrowsePreview();
883
- return;
884
- }
885
-
886
- if (
887
- browsePreviewOpen &&
888
- previewLineCount > 0 &&
889
- input === " " &&
890
- (tab === 4 || (shellMode && shellMainTab === 0))
891
- ) {
892
- const prevH = Math.max(3, Math.floor(bodyMax * 0.35));
893
- const pv = Math.max(2, bodyMax - prevH - 2);
894
- const maxS = Math.max(0, previewLineCount - pv);
895
- setTreePreviewScroll((s) =>
896
- Math.min(s + Math.max(1, Math.floor(pv / 2)), maxS)
897
- );
898
- return;
899
- }
900
-
901
- if (!shellMode && (key.return || input === " ") && tab === 0 && row?.pull?.html_url) {
902
- openUrl(String(row.pull.html_url));
903
- return;
904
- }
905
- if ((key.return || input === " ") && tab === 1) {
906
- const ic = issueList[safeLine];
907
- if (ic?.html_url) {
908
- openUrl(String(ic.html_url));
909
- return;
910
- }
911
- }
912
- if ((key.return || input === " ") && tab === 2) {
913
- const c = reviewList[safeLine];
914
- if (c?.html_url) {
915
- openUrl(String(c.html_url));
916
- return;
917
- }
918
- }
919
- if ((key.return || input === " ") && tab === 3 && fileList.length) {
920
- setFileIndex((i) => Math.min(i + 1, fileList.length - 1));
921
- setFilePatchOffset(0); setPatchCursorLine(0);
922
- setFileCommentIndex(0);
923
- return;
924
- }
925
-
926
- if (
927
- (key.return || input === " ") &&
928
- tab === 4 &&
929
- !browsePreviewOpen &&
930
- !shellMode &&
931
- browseOwner &&
932
- browseRepo &&
933
- headSha &&
934
- asciiTreeRows[safeLine]
935
- ) {
936
- const ent = asciiTreeRows[safeLine];
937
- if (ent.path) {
938
- loadBrowseBlob(ent.path);
939
- }
940
- return;
941
- }
942
-
943
- if (
944
- shellMode &&
945
- shellMainTab === 0 &&
946
- (key.return || input === " ") &&
947
- !shellStackFocus &&
948
- !browsePreviewOpen &&
949
- browseOwner &&
950
- browseRepo &&
951
- headSha &&
952
- asciiTreeRows[treeSafeLine]
953
- ) {
954
- const ent = asciiTreeRows[treeSafeLine];
955
- if (ent.path) {
956
- loadBrowseBlob(ent.path);
957
- }
958
- return;
959
- }
960
-
961
- if (input === "j" || key.downArrow) {
962
- if (shellMode && shellStackFocus && len > 0) {
963
- setPrIndex((i) => (i < 0 ? 0 : Math.min(i + 1, len - 1)));
964
- setTreeLineIndex(0);
965
- setFilePatchOffset(0); setPatchCursorLine(0);
966
- return;
967
- }
968
- if (shellMode && !shellStackFocus && shellMainTab === 0 && len > 0 && prIndex < 0) {
969
- setShellStackFocus(true);
970
- setPrIndex(0);
971
- setTreeLineIndex(0);
972
- setFilePatchOffset(0); setPatchCursorLine(0);
973
- return;
974
- }
975
- if (
976
- (shellMode && shellMainTab === 0 && browsePreviewOpen && previewLineCount > 0) ||
977
- (!shellMode && tab === 4 && browsePreviewOpen && previewLineCount > 0)
978
- ) {
979
- const prevH = Math.max(3, Math.floor(bodyMax * 0.35));
980
- const pv = Math.max(2, bodyMax - prevH - 2);
981
- const maxS = Math.max(0, previewLineCount - pv);
982
- setTreePreviewScroll((s) => Math.min(s + 1, maxS));
983
- return;
984
- }
985
- if (
986
- shellMode &&
987
- shellMainTab === 0 &&
988
- !shellStackFocus &&
989
- !browsePreviewOpen &&
990
- asciiTreeRows.length
991
- ) {
992
- setTreeLineIndex((i) => Math.min(i + 1, asciiTreeRows.length - 1));
993
- return;
994
- }
995
- if (shellMode && shellMainTab > 0 && listLen > 0) {
996
- setLineIndex((i) => Math.min(i + 1, Math.max(0, listLen - 1)));
997
- return;
998
- }
999
- if (tab === 0 && len > 0 && !shellMode) {
1000
- setPrIndex((i) => Math.min(i + 1, len - 1));
1001
- } else if (tab === 3) {
1002
- setFilePatchOffset((i) => Math.min(i + 1, maxPatchOffset));
1003
- } else if (tab === 4 && asciiTreeRows.length && !shellMode) {
1004
- setTreeLineIndex((i) => Math.min(i + 1, asciiTreeRows.length - 1));
1005
- } else {
1006
- setLineIndex((i) => Math.min(i + 1, Math.max(0, listLen - 1)));
1007
- }
1008
- return;
1009
- }
1010
- if (input === "k" || key.upArrow) {
1011
- if (shellMode && shellStackFocus && len > 0) {
1012
- setPrIndex((i) => (i <= 0 ? -1 : i - 1));
1013
- setTreeLineIndex(0);
1014
- setFilePatchOffset(0); setPatchCursorLine(0);
1015
- return;
1016
- }
1017
- if (shellMode && !shellStackFocus && shellMainTab === 0 && len > 0 && prIndex < 0) {
1018
- setShellStackFocus(true);
1019
- setPrIndex(Math.max(0, len - 1));
1020
- setTreeLineIndex(0);
1021
- setFilePatchOffset(0); setPatchCursorLine(0);
1022
- return;
1023
- }
1024
- if (
1025
- (shellMode && shellMainTab === 0 && browsePreviewOpen && previewLineCount > 0) ||
1026
- (!shellMode && tab === 4 && browsePreviewOpen && previewLineCount > 0)
1027
- ) {
1028
- const prevH = Math.max(3, Math.floor(bodyMax * 0.35));
1029
- const pv = Math.max(2, bodyMax - prevH - 2);
1030
- const maxS = Math.max(0, previewLineCount - pv);
1031
- setTreePreviewScroll((s) => Math.max(s - 1, 0));
1032
- return;
1033
- }
1034
- if (
1035
- shellMode &&
1036
- shellMainTab === 0 &&
1037
- !shellStackFocus &&
1038
- !browsePreviewOpen &&
1039
- asciiTreeRows.length
1040
- ) {
1041
- setTreeLineIndex((i) => Math.max(i - 1, 0));
1042
- return;
1043
- }
1044
- if (shellMode && shellMainTab > 0 && listLen > 0) {
1045
- setLineIndex((i) => Math.max(i - 1, 0));
1046
- return;
1047
- }
1048
- if (tab === 0 && len > 0 && !shellMode) {
1049
- setPrIndex((i) => Math.max(i - 1, 0));
1050
- } else if (tab === 3) {
1051
- setFilePatchOffset((i) => Math.max(i - 1, 0));
1052
- } else if (tab === 4 && asciiTreeRows.length && !shellMode) {
1053
- setTreeLineIndex((i) => Math.max(i - 1, 0));
1054
- } else {
1055
- setLineIndex((i) => Math.max(i - 1, 0));
1056
- }
1057
- return;
1058
- }
1059
-
1060
- if (input === "o" && row && !row.error) {
1061
- if (shellMode) {
1062
- setShellStackFocus(false);
1063
- setShellMainTab(0);
1064
- return;
1065
- }
1066
- setTab(3);
1067
- setFileIndex(0);
1068
- setFilePatchOffset(0); setPatchCursorLine(0);
1069
- setFileCommentIndex(0);
1070
- return;
1071
- }
1072
-
1073
- if (tab === 3 && input === "n") {
1074
- setFileIndex((i) => Math.min(i + 1, Math.max(0, fileList.length - 1)));
1075
- setFilePatchOffset(0); setPatchCursorLine(0);
1076
- setFileCommentIndex(0);
1077
- return;
1078
- }
1079
- if (tab === 3 && input === "p") {
1080
- setFileIndex((i) => Math.max(i - 1, 0));
1081
- setFilePatchOffset(0); setPatchCursorLine(0);
1082
- setFileCommentIndex(0);
1083
- return;
1084
- }
1085
- if (tab === 3 && input === "m" && fileComments.length) {
1086
- setFileCommentIndex((i) => (i + 1) % fileComments.length);
1087
- const c = fileComments[(safeFileComment + 1) % fileComments.length];
1088
- const lineNo = c?.line ?? c?.original_line ?? 0;
1089
- setFilePatchOffset(Math.min(maxPatchOffset, patchOffsetForLine(patchLines, Number(lineNo) || 0)));
1090
- return;
1091
- }
1092
- if (tab === 3 && input === "M" && fileComments.length) {
1093
- const next = (safeFileComment - 1 + fileComments.length) % fileComments.length;
1094
- setFileCommentIndex(next);
1095
- const c = fileComments[next];
1096
- const lineNo = c?.line ?? c?.original_line ?? 0;
1097
- setFilePatchOffset(Math.min(maxPatchOffset, patchOffsetForLine(patchLines, Number(lineNo) || 0)));
1098
- return;
1099
- }
1100
-
1101
- if (input === "K" && row && !row.error && (!shellMode ? tab === 4 : shellMainTab === 0)) {
1102
- exitPayload.next = {
1103
- type: "materialize_clone",
1104
- prNumber: row.entry.pr_number
1105
- };
1106
- exit();
1107
- return;
1108
- }
1109
-
1110
- const reviewNav =
1111
- tab === 2 || (shellMode && shellMainTab === 3);
1112
- if (input === "l" && reviewNav) {
1113
- const c = reviewList[safeLine];
1114
- if (c?.html_url) {
1115
- openUrl(c.html_url);
1116
- }
1117
- return;
1118
- }
1119
- if (input === "g" && reviewNav && row && !row.error) {
1120
- const c = reviewList[safeLine];
1121
- const targetPath = String(c?.path || "");
1122
- if (targetPath) {
1123
- const idx = fileList.findIndex((f) => String(f?.filename || "") === targetPath);
1124
- if (idx >= 0) {
1125
- if (shellMode) {
1126
- setShellMainTab(0);
1127
- setTab(4);
1128
- const tidx = asciiTreeRows.findIndex((r) => r.path === targetPath);
1129
- if (tidx >= 0) {
1130
- setTreeLineIndex(tidx);
1131
- }
1132
- } else {
1133
- setTab(3);
1134
- }
1135
- setFileIndex(idx);
1136
- const selected = fileList[idx];
1137
- const pls = typeof selected?.patch === "string" ? String(selected.patch).split("\n") : [];
1138
- const lineNo = c?.line ?? c?.original_line ?? 0;
1139
- const off = patchOffsetForLine(pls, Number(lineNo) || 0);
1140
- setFilePatchOffset(off);
1141
- const fcIdx = reviewList
1142
- .filter((rc) => String(rc?.path || "") === targetPath)
1143
- .findIndex((rc) => rc?.id === c?.id);
1144
- setFileCommentIndex(fcIdx >= 0 ? fcIdx : 0);
1145
- }
1146
- }
1147
- return;
1148
- }
1149
-
1150
- if (input === "S" && row && !row.error) {
1151
- exitPayload.next = {
1152
- type: "split",
1153
- prNumber: row.entry.pr_number
1154
- };
1155
- exit();
1156
- return;
1157
- }
1158
-
1159
- if (input === "a" && row && !row.error) {
1160
- exitPayload.next = {
1161
- type: "submit_review",
1162
- prNumber: row.entry.pr_number,
1163
- event: "APPROVE",
1164
- body: ""
1165
- };
1166
- exit();
1167
- return;
1168
- }
1169
-
1170
- if (input === "e" && row && !row.error) {
1171
- exitPayload.next = {
1172
- type: "submit_review_changes",
1173
- prNumber: row.entry.pr_number
1174
- };
1175
- exit();
1176
- return;
1177
- }
1178
-
1179
- if (input === "r" && row && !row.error) {
1180
- exitPayload.next = {
1181
- type: "issue_comment",
1182
- prNumber: row.entry.pr_number
1183
- };
1184
- exit();
1185
- return;
1186
- }
1187
-
1188
- if (input === "R" && row && !row.error) {
1189
- exitPayload.next = {
1190
- type: "request_reviewers",
1191
- prNumber: row.entry.pr_number
1192
- };
1193
- exit();
1194
- return;
1195
- }
1196
-
1197
- if (input === "t" && reviewNav && row && !row.error) {
1198
- const c = reviewList[safeLine];
1199
- if (c?.id != null) {
1200
- exitPayload.next = {
1201
- type: "review_reply",
1202
- commentId: c.id
1203
- };
1204
- exit();
1205
- }
1206
- }
1207
- });
1208
-
1209
- if (len === 0) {
1210
- layoutRef.current = null;
1211
- regionsRef.current = [];
1212
- return React.createElement(
1213
- Box,
1214
- { flexDirection: "column", width: cols, padding: 1 },
1215
- React.createElement(Text, { color: "red" }, "No PRs in stack."),
1216
- React.createElement(Text, { dimColor: true }, "Press q to quit.")
1217
- );
1218
- }
1219
-
1220
- const ladderTruncW = shellMode ? Math.max(8, sidebarW - 2) : innerW;
1221
- const ladderBlinkOn = shellStackFocus && Math.floor(pulse / 2) % 2 === 0;
1222
- const ladderScrollAnchor = hasPrPick ? safePr : 0;
1223
- const ladderRows = stackRows.map((r, i) => {
1224
- const mark = hasPrPick && i === safePr ? ">" : " ";
1225
- const err = r.error ? ` ${r.error}` : "";
1226
- const title = r.pull?.title || err || "(loading)";
1227
- const num = r.entry.pr_number;
1228
- const st = r.pull?.draft ? "draft" : r.pull?.state || "?";
1229
- const reviewState = String(r.reviewSummary || "none");
1230
- const vm = r.viewerReviewMeta;
1231
- const stale = vm?.staleApproval ? "!" : "";
1232
- const risky = vm?.riskyChangeAfterApproval === true ? "*" : "";
1233
- const badge =
1234
- reviewState === "approved"
1235
- ? "A"
1236
- : reviewState === "changes_requested"
1237
- ? "CR"
1238
- : reviewState === "commented" || (r.reviewComments?.length || 0) > 0
1239
- ? "C"
1240
- : "-";
1241
- const { add, del } = prAddDelTotals(r);
1242
- const head = `${mark} #${num} `;
1243
- const statPlain = `+${add} -${del} `;
1244
- const meta = `[${st}] ${badge}${stale}${risky} `;
1245
- const titleBudget = Math.max(4, ladderTruncW - head.length - statPlain.length - meta.length);
1246
- const titleT = truncVis(title, titleBudget);
1247
- const lineColored =
1248
- chalk.white(head) +
1249
- chalk.green(`+${add}`) +
1250
- chalk.white(" ") +
1251
- chalk.red(`-${del}`) +
1252
- chalk.white(` ${meta}${titleT}`);
1253
- if (hasPrPick && i === safePr) {
1254
- if (shellMode && shellStackFocus) {
1255
- return ladderBlinkOn ? chalk.inverse(lineColored) : lineColored;
1256
- }
1257
- return chalk.inverse(lineColored);
1258
- }
1259
- return lineColored;
1260
- });
1261
- const { slice: ladderVis, start: ladderStart } = sliceViewport(
1262
- ladderRows,
1263
- ladderScrollAnchor,
1264
- ladderMaxForDisplay
1265
- );
1266
-
1267
- const tabName = ["overview", "conversation", "review", "files", "browse"][tab];
1268
- const pageLabel = tabName;
1269
- const nLad = ladderVis.length;
1270
- const bodyStartR = shellMode ? 6 : 4 + nLad;
1271
- let rowPtr = bodyStartR;
1272
- /** @type {string[]} */
1273
- let bodyLines = [];
1274
- /** @type {Array<{ r1: number, r2: number, kind: string, [k: string]: unknown }>} */
1275
- let regions = [];
1276
- let layoutBrowsePreviewH = 0;
1277
- let layoutTreeBlockH = 0;
1278
-
1279
- const pushBody = (line) => {
1280
- const r = rowPtr;
1281
- bodyLines.push(line);
1282
- rowPtr++;
1283
- return r;
1284
- };
1285
-
1286
- if (row?.error) {
1287
- pushBody(truncVis(row.error, innerW));
1288
- } else if (shellMode && !row) {
1289
- if (shellMainTab === 0) {
1290
- pushBody(
1291
- chalk.dim(
1292
- "Pick a PR from the ladder: j/k or [ ] \u00b7 Enter selects and moves focus to the file tree"
1293
- )
1294
- );
1295
- } else {
1296
- pushBody(chalk.dim("Select a PR in the sidebar to use this tab."));
1297
- }
1298
- } else if (shellMode && row?.pull) {
1299
- if (shellMainTab === 0) {
1300
- const selPath = asciiTreeRows[treeSafeLine]?.path;
1301
- const showPatch =
1302
- !!selPath &&
1303
- selPath === String(selectedFile?.filename || "") &&
1304
- patchLines.length > 0 &&
1305
- !browsePreviewOpen;
1306
-
1307
- const panes =
1308
- shellTreePanes ||
1309
- computeShellTreePaneHeights(bodyMax, browsePreviewOpen, showPatch);
1310
- const treeBlockH = panes.treeBlockH;
1311
- const patchH = showPatch ? panes.patchH : 0;
1312
- const previewH = browsePreviewOpen ? panes.previewH : 0;
1313
- layoutTreeBlockH = treeBlockH;
1314
- layoutBrowsePreviewH = previewH;
1315
-
1316
- if (treeLoading) {
1317
- pushBody("Loading tree\u2026");
1318
- } else if (treeError) {
1319
- pushBody(truncVis(treeError, innerW));
1320
- }
1321
- const previewPathOpen = browsePreviewOpen && treePreviewPath ? treePreviewPath : "";
1322
- const treeDisplay = asciiTreeRows.map((r, i) => {
1323
- const mark = i === treeSafeLine ? ">" : " ";
1324
- const f = r.path ? pathToChangedFile.get(r.path) : undefined;
1325
- const add = f ? Number(f.additions) || 0 : 0;
1326
- const del = f ? Number(f.deletions) || 0 : 0;
1327
- const flag = f
1328
- ? add > 0 && del > 0
1329
- ? chalk.yellow("*")
1330
- : add > 0
1331
- ? chalk.green("+")
1332
- : del > 0
1333
- ? chalk.red("-")
1334
- : chalk.cyan("*")
1335
- : " ";
1336
- const tw = Math.max(8, innerW - 4);
1337
- const plainT = truncVis(r.text, tw);
1338
- let lineText = plainT;
1339
- if (f) {
1340
- if (add > 0 && del > 0) {
1341
- lineText = chalk.yellow(plainT);
1342
- } else if (add > 0) {
1343
- lineText = chalk.green(plainT);
1344
- } else if (del > 0) {
1345
- lineText = chalk.red(plainT);
1346
- } else {
1347
- lineText = chalk.cyan(plainT);
1348
- }
1349
- }
1350
- if (r.path && previewPathOpen && r.path === previewPathOpen) {
1351
- lineText = chalk.inverse(lineText);
1352
- }
1353
- return `${mark}${flag} ${lineText}`;
1354
- });
1355
- const { slice: treeSlice, start: treeStart } = sliceViewport(treeDisplay, treeSafeLine, treeBlockH);
1356
- if (treeStart > 0) {
1357
- pushBody(chalk.dim(`\u2191 tree ${treeStart} more`));
1358
- }
1359
- const treeSliceStartR = rowPtr;
1360
- for (const ln of treeSlice) {
1361
- pushBody(ln);
1362
- }
1363
- for (let li = 0; li < treeSlice.length; li++) {
1364
- const idx = treeStart + li;
1365
- const tr = asciiTreeRows[idx];
1366
- regions.push({
1367
- kind: "treeLine",
1368
- r1: treeSliceStartR + li,
1369
- r2: treeSliceStartR + li,
1370
- index: idx,
1371
- path: tr?.path ?? null
1372
- });
1373
- }
1374
- if (treeStart + treeSlice.length < treeDisplay.length) {
1375
- pushBody(chalk.dim(`\u2193 tree ${treeDisplay.length - treeStart - treeSlice.length} more`));
1376
- }
1377
- if (!treeLoading && asciiTreeRows.length === 0 && !treeError) {
1378
- pushBody("(empty tree)");
1379
- }
1380
-
1381
- if (showPatch) {
1382
- pushBody("");
1383
- const safePatchCursor = Math.min(patchCursorLine, patchLines.length - 1);
1384
- pushBody(
1385
- chalk.bold(
1386
- truncVis(
1387
- `PR diff: ${String(selectedFile?.filename || "?")} (${filePatchOffset + 1}\u2013${Math.min(filePatchOffset + patchPageSize, patchLines.length)}/${patchLines.length}) C: comment on line`,
1388
- innerW
1389
- )
1390
- )
1391
- );
1392
- const page = patchLines.slice(filePatchOffset, filePatchOffset + patchPageSize);
1393
- let used = bodyLines.length;
1394
- const patchStartR = rowPtr;
1395
- for (let pi = 0; pi < page.length; pi++) {
1396
- if (bodyLines.length - used >= patchH) {
1397
- break;
1398
- }
1399
- const absIdx = filePatchOffset + pi;
1400
- const isCursor = absIdx === safePatchCursor;
1401
- const pl = page[pi];
1402
- let line = pl;
1403
- if (line.startsWith("+++ ") || line.startsWith("--- ")) {
1404
- line = chalk.bold(line);
1405
- } else if (line.startsWith("@@")) {
1406
- line = chalk.cyan(line);
1407
- } else if (line.startsWith("+")) {
1408
- line = chalk.green(line);
1409
- } else if (line.startsWith("-")) {
1410
- line = chalk.red(line);
1411
- } else {
1412
- line = chalk.gray(line);
1413
- }
1414
- if (isCursor) {
1415
- line = chalk.inverse(line);
1416
- }
1417
- pushBody(truncVis(line, innerW));
1418
- }
1419
- const patchEndR = rowPtr - 1;
1420
- if (patchEndR >= patchStartR) {
1421
- regions.push({ kind: "patch", r1: patchStartR, r2: patchEndR });
1422
- }
1423
- if (filePatchOffset + patchPageSize < patchLines.length) {
1424
- pushBody(chalk.dim("PgUp/Dn scroll · C: comment on highlighted line"));
1425
- }
1426
- } else if (!browsePreviewOpen && !showPatch && asciiTreeRows.length && !treeLoading) {
1427
- pushBody(
1428
- chalk.dim(
1429
- "Highlight a path marked +/−/* for this PR\u2019s diff. Enter opens file text."
1430
- )
1431
- );
1432
- }
1433
-
1434
- if (browsePreviewOpen && treePreviewPath) {
1435
- pushBody("");
1436
- const merged = mergedPreviewRows;
1437
- const sub =
1438
- merged && merged.length > 0
1439
- ? "diff in file "
1440
- : "";
1441
- pushBody(
1442
- chalk.bold(truncVis(`File: ${treePreviewPath}`, innerW)) +
1443
- chalk.whiteBright(
1444
- ` ${sub}[${treePreviewScroll + 1}-${Math.min(treePreviewScroll + previewH, previewLineCount)}/${previewLineCount}] wheel/Space scroll Backspace closes`
1445
- )
1446
- );
1447
- const prevSlice = merged
1448
- ? merged.slice(treePreviewScroll, treePreviewScroll + previewH)
1449
- : treePreviewLines.slice(treePreviewScroll, treePreviewScroll + previewH).map((t) => ({
1450
- text: t,
1451
- kind: /** @type {"ctx"} */ ("ctx")
1452
- }));
1453
- const prevStartR = rowPtr;
1454
- for (const row of prevSlice) {
1455
- const tv = truncVis(row.text, innerW);
1456
- const styled =
1457
- row.kind === "add"
1458
- ? chalk.green(tv)
1459
- : row.kind === "del"
1460
- ? chalk.red(tv)
1461
- : chalk.whiteBright(tv);
1462
- pushBody(styled);
1463
- }
1464
- const prevEndR = rowPtr - 1;
1465
- if (prevEndR >= prevStartR) {
1466
- regions.push({ kind: "preview", r1: prevStartR, r2: prevEndR });
1467
- }
1468
- }
1469
- } else if (shellMainTab === 1) {
1470
- const bodyRaw = typeof row.pull.body === "string" ? row.pull.body : "";
1471
- const plain = markdownToPlainLines(bodyRaw);
1472
- const bl = plain ? plain.split("\n") : [];
1473
- const lines = bl.length ? bl : ["(no description)"];
1474
- const { slice, start } = sliceViewport(lines, safeLine, bodyMax);
1475
- if (start > 0) {
1476
- pushBody(chalk.dim(`\u2191 ${start} lines above`));
1477
- }
1478
- for (let si = 0; si < slice.length; si++) {
1479
- const ln = slice[si];
1480
- const gi = start + si;
1481
- const out = truncVis(ln, innerW);
1482
- pushBody(gi === safeLine ? chalk.inverse(out) : out);
1483
- }
1484
- if (start + slice.length < lines.length) {
1485
- pushBody(chalk.dim(`\u2193 ${lines.length - start - slice.length} more`));
1486
- }
1487
- } else if (shellMainTab === 2) {
1488
- const lines = issueList.map((c, i) => {
1489
- const mark = i === safeLine ? ">" : " ";
1490
- const who = c.user?.login || "?";
1491
- const one = (c.body || "").split("\n")[0];
1492
- const t = truncVis(`${mark} @${who}: ${one}`, innerW);
1493
- return i === safeLine ? chalk.inverse(t) : t;
1494
- });
1495
- const { slice, start } = sliceViewport(lines, safeLine, bodyMax);
1496
- if (start > 0) {
1497
- pushBody(chalk.dim(`\u2191 ${start} more`));
1498
- }
1499
- const sliceStartR = rowPtr;
1500
- for (const ln of slice) {
1501
- pushBody(ln);
1502
- }
1503
- for (let li = 0; li < slice.length; li++) {
1504
- regions.push({
1505
- kind: "issueLine",
1506
- r1: sliceStartR + li,
1507
- r2: sliceStartR + li,
1508
- index: start + li
1509
- });
1510
- }
1511
- if (start + slice.length < lines.length) {
1512
- pushBody(chalk.dim(`\u2193 ${lines.length - start - slice.length} more`));
1513
- }
1514
- if (lines.length === 0) {
1515
- pushBody("(no conversation comments)");
1516
- }
1517
- } else if (shellMainTab === 3) {
1518
- const lines = reviewList.map((c, i) => {
1519
- const mark = i === safeLine ? ">" : " ";
1520
- const path = c.path || "?";
1521
- const ln = c.line ?? c.original_line ?? "?";
1522
- const one = (c.body || "").split("\n")[0];
1523
- const t = truncVis(`${mark} ${path}:${ln} ${one}`, innerW);
1524
- return i === safeLine ? chalk.inverse(t) : t;
1525
- });
1526
- const { slice, start } = sliceViewport(lines, safeLine, bodyMax);
1527
- if (start > 0) {
1528
- pushBody(chalk.dim(`\u2191 ${start} more`));
1529
- }
1530
- const sliceStartR = rowPtr;
1531
- for (const ln of slice) {
1532
- pushBody(ln);
1533
- }
1534
- for (let li = 0; li < slice.length; li++) {
1535
- regions.push({
1536
- kind: "reviewLine",
1537
- r1: sliceStartR + li,
1538
- r2: sliceStartR + li,
1539
- index: start + li
1540
- });
1541
- }
1542
- if (start + slice.length < lines.length) {
1543
- pushBody(chalk.dim(`\u2193 ${lines.length - start - slice.length} more`));
1544
- }
1545
- if (lines.length === 0) {
1546
- pushBody("(no review thread comments)");
1547
- }
1548
- }
1549
- } else if (tab === 0 && row?.pull && !shellMode) {
1550
- const p = row.pull;
1551
- pushBody(truncVis(`Title: ${p.title || ""}`, innerW));
1552
- pushBody(truncVis(`Head: ${p.head?.ref || ""} Base: ${p.base?.ref || ""}`, innerW));
1553
- pushBody(
1554
- truncVis(`Comments: ${issueList.length} conv / ${reviewList.length} review (line)`, innerW)
1555
- );
1556
- const rs = String(row.reviewSummary || "none");
1557
- pushBody(
1558
- truncVis(
1559
- `Review: ${
1560
- rs === "approved"
1561
- ? "approved"
1562
- : rs === "changes_requested"
1563
- ? "changes requested"
1564
- : rs === "commented"
1565
- ? "commented"
1566
- : "no review state"
1567
- }`,
1568
- innerW
1569
- )
1570
- );
1571
- const vm = row.viewerReviewMeta;
1572
- if (vm) {
1573
- if (vm.dismissedReview) {
1574
- pushBody(truncVis("Note: a prior review may have been dismissed/superseded.", innerW));
1575
- }
1576
- if (vm.staleApproval) {
1577
- pushBody(
1578
- truncVis(
1579
- `Your approval is on an older commit (+${vm.commitsSinceApproval} commits). Press a to re-approve.`,
1580
- innerW
1581
- )
1582
- );
1583
- }
1584
- if (vm.riskyChangeAfterApproval === true) {
1585
- pushBody(truncVis("Commits since your approval look non-merge — please re-read.", innerW));
1586
- } else if (vm.riskyChangeAfterApproval === false && vm.staleApproval) {
1587
- pushBody(truncVis("Delta since your approval looks merge-only.", innerW));
1588
- }
1589
- }
1590
- } else if (tab === 1 && !shellMode) {
1591
- const lines = issueList.map((c, i) => {
1592
- const mark = i === safeLine ? ">" : " ";
1593
- const who = c.user?.login || "?";
1594
- const one = (c.body || "").split("\n")[0];
1595
- return truncVis(`${mark} @${who}: ${one}`, innerW);
1596
- });
1597
- const { slice, start } = sliceViewport(lines, safeLine, bodyMax);
1598
- if (start > 0) {
1599
- pushBody(chalk.dim(`\u2191 ${start} more`));
1600
- }
1601
- const sliceStartR = rowPtr;
1602
- for (const ln of slice) {
1603
- pushBody(ln);
1604
- }
1605
- for (let li = 0; li < slice.length; li++) {
1606
- regions.push({
1607
- kind: "issueLine",
1608
- r1: sliceStartR + li,
1609
- r2: sliceStartR + li,
1610
- index: start + li
1611
- });
1612
- }
1613
- if (start + slice.length < lines.length) {
1614
- pushBody(chalk.dim(`\u2193 ${lines.length - start - slice.length} more`));
1615
- }
1616
- if (lines.length === 0) {
1617
- pushBody("(no issue comments)");
1618
- }
1619
- } else if (tab === 2 && !shellMode) {
1620
- const lines = reviewList.map((c, i) => {
1621
- const mark = i === safeLine ? ">" : " ";
1622
- const path = c.path || "?";
1623
- const ln = c.line ?? c.original_line ?? "?";
1624
- const one = (c.body || "").split("\n")[0];
1625
- return truncVis(`${mark} ${path}:${ln} ${one}`, innerW);
1626
- });
1627
- const { slice, start } = sliceViewport(lines, safeLine, bodyMax);
1628
- if (start > 0) {
1629
- pushBody(chalk.dim(`\u2191 ${start} more`));
1630
- }
1631
- const sliceStartR = rowPtr;
1632
- for (const ln of slice) {
1633
- pushBody(ln);
1634
- }
1635
- for (let li = 0; li < slice.length; li++) {
1636
- regions.push({
1637
- kind: "reviewLine",
1638
- r1: sliceStartR + li,
1639
- r2: sliceStartR + li,
1640
- index: start + li
1641
- });
1642
- }
1643
- if (start + slice.length < lines.length) {
1644
- pushBody(chalk.dim(`\u2193 ${lines.length - start - slice.length} more`));
1645
- }
1646
- if (lines.length === 0) {
1647
- pushBody("(no review comments)");
1648
- pushBody(chalk.dim("Tip: press g on a review comment to jump to file diff"));
1649
- }
1650
- } else if (tab === 3) {
1651
- const fileListH = Math.min(6, Math.max(3, Math.floor(bodyMax * 0.28)));
1652
- const patchH = Math.max(3, bodyMax - fileListH - 3);
1653
- const flLines = fileList.map((f, i) => {
1654
- const mark = i === safeFile ? ">" : " ";
1655
- const name = String(f.filename || "?");
1656
- const st = String(f.status || "?");
1657
- const add = Number(f.additions) || 0;
1658
- const del = Number(f.deletions) || 0;
1659
- const ch = `+${add} -${del}`;
1660
- const meta = chalk.gray(`[${st}] ${ch}`);
1661
- const nameTrunc = truncVis(name, Math.max(12, innerW - 18));
1662
- let nameStyled = chalk.whiteBright(nameTrunc);
1663
- if (add > 0 && del > 0) {
1664
- nameStyled = chalk.yellow(nameTrunc);
1665
- } else if (add > 0) {
1666
- nameStyled = chalk.green(nameTrunc);
1667
- } else if (del > 0) {
1668
- nameStyled = chalk.red(nameTrunc);
1669
- }
1670
- return `${mark} ${meta} ${nameStyled}`;
1671
- });
1672
- const { slice: flSlice, start: flStart } = sliceViewport(flLines, safeFile, fileListH);
1673
- if (fileList.length === 0) {
1674
- pushBody("(no changed files)");
1675
- } else {
1676
- if (flStart > 0) {
1677
- pushBody(chalk.dim(`\u2191 files ${flStart} more`));
1678
- }
1679
- const flStartR = rowPtr;
1680
- for (const ln of flSlice) {
1681
- pushBody(ln);
1682
- }
1683
- for (let li = 0; li < flSlice.length; li++) {
1684
- regions.push({
1685
- kind: "fileLine",
1686
- r1: flStartR + li,
1687
- r2: flStartR + li,
1688
- index: flStart + li
1689
- });
1690
- }
1691
- if (flStart + flSlice.length < flLines.length) {
1692
- pushBody(chalk.dim(`\u2193 files ${flLines.length - flStart - flSlice.length} more`));
1693
- }
1694
- if (selectedFile) {
1695
- pushBody("");
1696
- pushBody(
1697
- truncVis(
1698
- `Patch: ${String(selectedFile.filename || "?")} (${filePatchOffset + 1}-${Math.min(filePatchOffset + patchPageSize, patchLines.length)}/${patchLines.length || 0})`,
1699
- innerW
1700
- )
1701
- );
1702
- const patch = typeof selectedFile.patch === "string" ? selectedFile.patch : "";
1703
- if (!patch) {
1704
- pushBody("(patch not available from GitHub API for this file)");
1705
- } else {
1706
- const page = patchLines.slice(filePatchOffset, filePatchOffset + patchPageSize);
1707
- let used = bodyLines.length;
1708
- const patchStartR = rowPtr;
1709
- for (const pl of page) {
1710
- if (bodyLines.length - used >= patchH) {
1711
- break;
1712
- }
1713
- let line = pl;
1714
- if (line.startsWith("+++ ") || line.startsWith("--- ")) {
1715
- line = chalk.bold(line);
1716
- } else if (line.startsWith("@@")) {
1717
- line = chalk.cyan(line);
1718
- } else if (line.startsWith("+")) {
1719
- line = chalk.green(line);
1720
- } else if (line.startsWith("-")) {
1721
- line = chalk.red(line);
1722
- } else {
1723
- line = chalk.gray(line);
1724
- }
1725
- pushBody(truncVis(line, innerW));
1726
- }
1727
- const patchEndR = rowPtr - 1;
1728
- if (patchEndR >= patchStartR) {
1729
- regions.push({ kind: "patch", r1: patchStartR, r2: patchEndR });
1730
- }
1731
- if (filePatchOffset + patchPageSize < patchLines.length) {
1732
- pushBody(chalk.dim("j/k = more patch"));
1733
- }
1734
- }
1735
- if (fileComments.length) {
1736
- const c = fileComments[safeFileComment];
1737
- pushBody(
1738
- truncVis(
1739
- `Comment ${safeFileComment + 1}/${fileComments.length} @${c?.user?.login || "?"}`,
1740
- innerW
1741
- )
1742
- );
1743
- pushBody(truncVis(String(c?.body || "").split("\n")[0], innerW));
1744
- }
1745
- }
1746
- }
1747
- } else if (tab === 4 && !shellMode) {
1748
- const treeBlockH = browsePreviewOpen
1749
- ? Math.max(4, Math.floor(bodyMax * 0.38))
1750
- : bodyMax - 1;
1751
- const previewH = browsePreviewOpen ? Math.max(3, bodyMax - treeBlockH - 1) : 0;
1752
- layoutTreeBlockH = treeBlockH;
1753
- layoutBrowsePreviewH = previewH;
1754
-
1755
- if (treeLoading) {
1756
- pushBody("Loading tree\u2026");
1757
- } else if (treeError) {
1758
- pushBody(truncVis(treeError, innerW));
1759
- }
1760
- const previewPathOpenNs = browsePreviewOpen && treePreviewPath ? treePreviewPath : "";
1761
- const treeDisplay = asciiTreeRows.map((r, i) => {
1762
- const mark = i === safeLine ? ">" : " ";
1763
- const f = r.path ? pathToChangedFile.get(r.path) : undefined;
1764
- const add = f ? Number(f.additions) || 0 : 0;
1765
- const del = f ? Number(f.deletions) || 0 : 0;
1766
- const flag = f
1767
- ? add > 0 && del > 0
1768
- ? chalk.yellow("*")
1769
- : add > 0
1770
- ? chalk.green("+")
1771
- : del > 0
1772
- ? chalk.red("-")
1773
- : chalk.cyan("*")
1774
- : " ";
1775
- const tw = Math.max(8, innerW - 4);
1776
- const plainT = truncVis(r.text, tw);
1777
- let lineText = plainT;
1778
- if (f) {
1779
- if (add > 0 && del > 0) {
1780
- lineText = chalk.yellow(plainT);
1781
- } else if (add > 0) {
1782
- lineText = chalk.green(plainT);
1783
- } else if (del > 0) {
1784
- lineText = chalk.red(plainT);
1785
- } else {
1786
- lineText = chalk.cyan(plainT);
1787
- }
1788
- }
1789
- if (r.path && previewPathOpenNs && r.path === previewPathOpenNs) {
1790
- lineText = chalk.inverse(lineText);
1791
- }
1792
- return `${mark}${flag} ${lineText}`;
1793
- });
1794
- const { slice: treeSlice, start: treeStart } = sliceViewport(treeDisplay, safeLine, treeBlockH);
1795
- if (treeStart > 0) {
1796
- pushBody(chalk.dim(`\u2191 tree ${treeStart} more`));
1797
- }
1798
- const treeSliceStartR = rowPtr;
1799
- for (const ln of treeSlice) {
1800
- pushBody(ln);
1801
- }
1802
- for (let li = 0; li < treeSlice.length; li++) {
1803
- const idx = treeStart + li;
1804
- const tr = asciiTreeRows[idx];
1805
- regions.push({
1806
- kind: "treeLine",
1807
- r1: treeSliceStartR + li,
1808
- r2: treeSliceStartR + li,
1809
- index: idx,
1810
- path: tr?.path ?? null
1811
- });
1812
- }
1813
- if (treeStart + treeSlice.length < treeDisplay.length) {
1814
- pushBody(chalk.dim(`\u2193 tree ${treeDisplay.length - treeStart - treeSlice.length} more`));
1815
- }
1816
- if (!treeLoading && asciiTreeRows.length === 0 && !treeError) {
1817
- pushBody("(empty tree)");
1818
- }
1819
-
1820
- if (browsePreviewOpen && treePreviewPath) {
1821
- pushBody("");
1822
- const mergedNs = mergedPreviewRows;
1823
- const subNs = mergedNs && mergedNs.length > 0 ? "diff in file " : "";
1824
- pushBody(
1825
- chalk.bold(truncVis(`File: ${treePreviewPath}`, innerW)) +
1826
- chalk.whiteBright(
1827
- ` ${subNs}[${treePreviewScroll + 1}-${Math.min(treePreviewScroll + previewH, previewLineCount)}/${previewLineCount}] wheel/Space scroll BS/Esc back`
1828
- )
1829
- );
1830
- const prevSliceNs = mergedNs
1831
- ? mergedNs.slice(treePreviewScroll, treePreviewScroll + previewH)
1832
- : treePreviewLines.slice(treePreviewScroll, treePreviewScroll + previewH).map((t) => ({
1833
- text: t,
1834
- kind: /** @type {"ctx"} */ ("ctx")
1835
- }));
1836
- const prevStartRNs = rowPtr;
1837
- for (const row of prevSliceNs) {
1838
- const tv = truncVis(row.text, innerW);
1839
- const styled =
1840
- row.kind === "add"
1841
- ? chalk.green(tv)
1842
- : row.kind === "del"
1843
- ? chalk.red(tv)
1844
- : chalk.whiteBright(tv);
1845
- pushBody(styled);
1846
- }
1847
- const prevEndRNs = rowPtr - 1;
1848
- if (prevEndRNs >= prevStartRNs) {
1849
- regions.push({ kind: "preview", r1: prevStartRNs, r2: prevEndRNs });
1850
- }
1851
- }
1852
- }
1853
-
1854
- if (bodyLines.length > bodyMax) {
1855
- const lastR = bodyStartR + bodyMax - 1;
1856
- bodyLines = bodyLines.slice(0, bodyMax);
1857
- regions = regions
1858
- .filter((reg) => reg.r1 <= lastR)
1859
- .map((reg) => ({ ...reg, r2: Math.min(reg.r2, lastR) }))
1860
- .filter((reg) => reg.r1 <= reg.r2);
1861
- }
1862
-
1863
- const browsePreviewHForLayout = treeViewActive ? layoutBrowsePreviewH : 0;
1864
- layoutRef.current = {
1865
- bodyStartR,
1866
- nLad: shellMode ? 0 : nLad,
1867
- ladderStart,
1868
- browsePreviewH: browsePreviewHForLayout,
1869
- bodyMax,
1870
- tab,
1871
- shellMode,
1872
- shellMainTab: shellMode ? shellMainTab : undefined,
1873
- shellStackFocus: shellMode ? shellStackFocus : false,
1874
- browsePreviewOpen,
1875
- treePreviewLinesLen: previewLineCount,
1876
- maxPatchOffset,
1877
- patchPageSize,
1878
- listLen,
1879
- len,
1880
- fileListLen: fileList.length,
1881
- treeLen: asciiTreeRows.length
1882
- };
1883
- regionsRef.current = regions;
1884
-
1885
- const selectedReview =
1886
- tab === 2 || (shellMode && shellMainTab === 3) ? reviewList[safeLine] : null;
1887
- const helpParts = ["Tab/[]", "o files", "j/k", "Enter/Space", "BS back", "wheel", "u", "q"];
1888
- if (tab === 0) {
1889
- helpParts.push("PR ladder+mouse");
1890
- }
1891
- if (tab === 3) {
1892
- helpParts.push("n/p m/M");
1893
- }
1894
- if (tab === 4) {
1895
- if (browsePreviewOpen) {
1896
- helpParts.push("Space page", "b");
1897
- } else {
1898
- helpParts.push("tree click", "dbl-open", "K");
1899
- }
1900
- }
1901
- if (row && !row.error) {
1902
- if (tab === 0) {
1903
- helpParts.push("S a e");
1904
- }
1905
- helpParts.push("r R");
1906
- }
1907
- if (tab === 2 && selectedReview?.html_url) {
1908
- helpParts.push("l g t");
1909
- }
1910
- if (shellMode) {
1911
- helpParts.push("[ ] PR", "1-9", "PgUp/Dn diff", "O", "K", "Enter Space", "BS stack", "b preview", "Esc q", "?");
1912
- }
1913
- const helpRaw = helpParts.join(" · ");
1914
- const fullFooterW = Math.max(40, cols - 2);
1915
- /** @type {string} */
1916
- let helpLine1;
1917
- /** @type {string} */
1918
- let helpLine2;
1919
- /** @type {string} */
1920
- let helpLine3 = "";
1921
- if (shellMode) {
1922
- if (showFullHelp) {
1923
- helpLine1 = truncVis(helpRaw, fullFooterW);
1924
- helpLine2 =
1925
- helpRaw.length > fullFooterW ? truncVis(helpRaw.slice(fullFooterW, fullFooterW * 2), fullFooterW) : "";
1926
- helpLine3 =
1927
- helpRaw.length > fullFooterW * 2 ? truncVis(helpRaw.slice(fullFooterW * 2), fullFooterW) : "";
1928
- } else if (shellStackFocus) {
1929
- helpLine1 = "Tab to cycle functions.";
1930
- helpLine2 = truncVis(
1931
- canPickAlternateStack
1932
- ? "j/k or [ ] pick PR · Enter/Space tree · Backspace pick another stack · Tab Esc cancel"
1933
- : "j/k or [ ] pick PR · Enter or Space back to tree · Backspace Tab Esc cancel pick",
1934
- fullFooterW
1935
- );
1936
- } else if (browsePreviewOpen) {
1937
- helpLine1 = "Tab to cycle functions.";
1938
- helpLine2 = truncVis(
1939
- "Enter or Space open file · Backspace or b close file · [ ] 1-9 PR · PgUp/Dn wheel diff/preview · O · K · ? · Esc q",
1940
- fullFooterW
1941
- );
1942
- } else {
1943
- helpLine1 = "Tab to cycle functions.";
1944
- helpLine2 = truncVis(
1945
- canPickAlternateStack
1946
- ? "Enter/Space file · Backspace PR pick · Backspace again other stack · [ ] 1-9 · PgUp/Dn wheel · C comment · O · K · ? · Esc q"
1947
- : "Enter or Space open file · Backspace pick PR · [ ] 1-9 PR · PgUp/Dn wheel diff · C comment · O · K · ? · Esc q",
1948
- fullFooterW
1949
- );
1950
- }
1951
- } else if (showFullHelp) {
1952
- helpLine1 = truncVis(helpRaw, innerW);
1953
- helpLine2 =
1954
- helpRaw.length > innerW ? truncVis(helpRaw.slice(innerW), innerW) : "";
1955
- } else {
1956
- helpLine1 = truncVis("Tab/[] · j/k · Enter/Space · BS · mouse · ? details · u · q", innerW);
1957
- helpLine2 = "";
1958
- }
1959
-
1960
- const spin = SPIN_FRAMES[pulse % SPIN_FRAMES.length];
1961
- const clock = new Date().toLocaleTimeString(undefined, { hour12: false });
1962
-
1963
- const footerRule = "\u2500".repeat(Math.max(0, innerW));
1964
-
1965
- const displayRepo =
1966
- repoFullName || (browseOwner && browseRepo ? `${browseOwner}/${browseRepo}` : viewTitle);
1967
-
1968
- const mainColumn = React.createElement(
1969
- Box,
1970
- { flexDirection: "column", width: innerW },
1971
- React.createElement(
1972
- Box,
1973
- { flexDirection: "row", width: innerW, justifyContent: "space-between" },
1974
- React.createElement(Text, { color: "cyan", bold: true }, truncVis(viewTitle, Math.floor(innerW * 0.5))),
1975
- React.createElement(
1976
- Text,
1977
- { dimColor: false, color: "green" },
1978
- `${spin} ${clock}`
1979
- )
1980
- ),
1981
- ...(shellMode
1982
- ? []
1983
- : [
1984
- ...(ladderVis.length > 0
1985
- ? [
1986
- React.createElement(
1987
- Text,
1988
- { key: "ladsum", dimColor: true },
1989
- ladderStart > 0 || ladderStart + ladderVis.length < ladderRows.length
1990
- ? `PRs ${ladderStart + 1}-${ladderStart + ladderVis.length}/${len}`
1991
- : ""
1992
- )
1993
- ]
1994
- : []),
1995
- ...ladderVis.map((line, i) =>
1996
- React.createElement(Text, { key: `l-${ladderStart + i}` }, line)
1997
- )
1998
- ]),
1999
- shellMode
2000
- ? React.createElement(
2001
- Box,
2002
- { key: "shell-tabs", flexDirection: "column", width: innerW, marginBottom: 1 },
2003
- React.createElement(
2004
- Box,
2005
- { flexDirection: "row", width: innerW, flexWrap: "wrap" },
2006
- ...SHELL_TAB_LABELS.map((lab, i) =>
2007
- React.createElement(
2008
- Text,
2009
- {
2010
- key: `st-${i}`,
2011
- color: i === shellMainTab ? "yellow" : "gray",
2012
- bold: i === shellMainTab
2013
- },
2014
- i === shellMainTab ? `${lab} ` : `${lab} `
2015
- )
2016
- )
2017
- ),
2018
- React.createElement(Text, { dimColor: true, color: "gray" }, footerRule)
2019
- )
2020
- : React.createElement(Text, { color: "magenta" }, `Tab: ${tabName}`),
2021
- React.createElement(
2022
- Box,
2023
- { flexDirection: "column", width: innerW },
2024
- ...bodyLines.map((line, idx) => React.createElement(Text, { key: `b-${idx}` }, line))
2025
- ),
2026
- ...(!shellMode
2027
- ? [
2028
- React.createElement(Text, { key: "h1", dimColor: true }, helpLine1),
2029
- helpLine2 ? React.createElement(Text, { key: "h2", dimColor: true }, helpLine2) : null
2030
- ]
2031
- : [])
2032
- );
2033
-
2034
- const footerRuleFull = "\u2500".repeat(fullFooterW);
2035
- const shellFooterBlock = shellMode
2036
- ? React.createElement(
2037
- Box,
2038
- { key: "shell-foot", flexDirection: "column", width: fullFooterW, marginTop: 1 },
2039
- React.createElement(Text, { color: "cyan" }, footerRuleFull),
2040
- React.createElement(Text, { color: "cyan" }, helpLine1),
2041
- helpLine2 ? React.createElement(Text, { color: "cyan" }, helpLine2) : null,
2042
- helpLine3 ? React.createElement(Text, { color: "cyan" }, helpLine3) : null
2043
- )
2044
- : null;
2045
-
2046
- if (!shellMode) {
2047
- return React.createElement(Box, { flexDirection: "column", width: cols, paddingX: 1 }, mainColumn);
2048
- }
2049
-
2050
- const sepW = Math.max(2, sidebarW - 2);
2051
- const sepBar = "\u2500".repeat(sepW);
2052
- const branchGraphW = Math.max(10, sidebarW - 2);
2053
- const shellBranchGraphLines = buildStackBranchGraphLines(
2054
- stackRows,
2055
- hasPrPick ? safePr : -1,
2056
- SIDEBAR_GRAPH_MAX,
2057
- branchGraphW
2058
- );
2059
- const sidebarInner = [
2060
- React.createElement(
2061
- Text,
2062
- { key: "sr", bold: true, color: "cyan" },
2063
- truncVis(displayRepo, sidebarW - 2)
2064
- ),
2065
- React.createElement(Text, { key: "ssep0", color: "gray" }, sepBar),
2066
- React.createElement(Text, { key: "ss1", color: "white" }, `PRs: ${len}`),
2067
- React.createElement(Text, { key: "ss2", color: "white" }, `Changed files: ${fileList.length}`),
2068
- React.createElement(
2069
- Text,
2070
- { key: "ss4", color: hasPrPick ? "whiteBright" : "gray", dimColor: !hasPrPick },
2071
- `Focus: ${hasPrPick ? `#${row.entry.pr_number}` : "\u2014"}`
2072
- ),
2073
- React.createElement(Text, { key: "ssep1", color: "gray" }, sepBar),
2074
- ladderVis.length > 0
2075
- ? React.createElement(
2076
- Text,
2077
- { key: "slab", color: "white", dimColor: true },
2078
- ladderStart > 0 || ladderStart + ladderVis.length < ladderRows.length
2079
- ? `Ladder ${ladderStart + 1}\u2013${ladderStart + ladderVis.length}/${len}`
2080
- : "Stack"
2081
- )
2082
- : null,
2083
- ...ladderVis.map((line, i) =>
2084
- React.createElement(Text, { key: `ls-${ladderStart + i}` }, line)
2085
- ),
2086
- React.createElement(Text, { key: "ssep2", color: "gray" }, sepBar),
2087
- React.createElement(Text, { key: "sbranch", color: "white", dimColor: true }, "Branch"),
2088
- ...shellBranchGraphLines.map((ln, gi) =>
2089
- React.createElement(Text, { key: `sg-${gi}` }, ln)
2090
- )
2091
- ];
2092
-
2093
- const divRows = Math.min(Math.max(14, ttyRows - 8), 64);
2094
- const dividerCol = React.createElement(
2095
- Box,
2096
- { key: "sdiv", flexDirection: "column", width: 1, marginRight: 0 },
2097
- ...Array.from({ length: divRows }, (_, i) =>
2098
- React.createElement(Text, { key: `dv${i}`, color: "gray" }, "\u2502")
2099
- )
2100
- );
2101
-
2102
- return React.createElement(
2103
- Box,
2104
- { flexDirection: "column", width: cols, paddingX: 1 },
2105
- React.createElement(
2106
- Box,
2107
- { flexDirection: "row", width: cols - 2 },
2108
- React.createElement(
2109
- Box,
2110
- { flexDirection: "column", width: sidebarW, marginRight: 0 },
2111
- ...sidebarInner.filter(Boolean)
2112
- ),
2113
- dividerCol,
2114
- mainColumn
2115
- ),
2116
- shellFooterBlock
2117
- );
2118
- }
1
+ // Viewer page component lives in cli/src/tui/pages/viewer.js.
2
+ // This file is kept for backward-compatible imports within stack-view/.
3
+ export { StackInkApp, createExitPayload } from "../tui/pages/viewer.js";