nugit-cli 0.0.1 → 0.1.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/package.json +1 -1
- package/src/api-client.js +10 -11
- package/src/github-device-flow.js +1 -1
- package/src/github-oauth-client-id.js +11 -0
- package/src/github-pr-social.js +42 -0
- package/src/github-rest.js +114 -6
- package/src/nugit-stack.js +20 -0
- package/src/nugit-start.js +4 -4
- package/src/nugit.js +37 -22
- package/src/review-hub/review-autoapprove.js +95 -0
- package/src/review-hub/review-hub-back.js +10 -0
- package/src/review-hub/review-hub-ink.js +166 -0
- package/src/review-hub/run-review-hub.js +188 -0
- package/src/split-view/run-split.js +16 -3
- package/src/split-view/split-ink.js +2 -2
- package/src/stack-discover.js +9 -1
- package/src/stack-infer-from-prs.js +71 -0
- package/src/stack-view/diff-line-map.js +62 -0
- package/src/stack-view/fetch-pr-data.js +104 -4
- package/src/stack-view/infer-chains-to-pick-stacks.js +70 -0
- package/src/stack-view/ink-app.js +1853 -156
- package/src/stack-view/loading-ink.js +44 -0
- package/src/stack-view/merge-alternate-pick-stacks.js +223 -0
- package/src/stack-view/patch-preview-merge.js +108 -0
- package/src/stack-view/remote-infer-doc.js +93 -0
- package/src/stack-view/repo-picker-back.js +10 -0
- package/src/stack-view/run-stack-view.js +685 -50
- package/src/stack-view/run-view-entry.js +119 -0
- package/src/stack-view/sgr-mouse.js +56 -0
- package/src/stack-view/stack-branch-graph.js +95 -0
- package/src/stack-view/stack-pick-graph.js +93 -0
- package/src/stack-view/stack-pick-ink.js +270 -0
- package/src/stack-view/stack-pick-layout.js +19 -0
- package/src/stack-view/stack-pick-sort.js +188 -0
- package/src/stack-view/terminal-fullscreen.js +45 -0
- package/src/stack-view/tree-ascii.js +73 -0
- package/src/stack-view/view-md-plain.js +23 -0
- package/src/stack-view/view-repo-picker-ink.js +293 -0
- package/src/stack-view/view-tui-sequential.js +126 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import { findGitRoot, stackJsonPath } from "../nugit-stack.js";
|
|
3
|
+
import { runStackViewCommand } from "./run-stack-view.js";
|
|
4
|
+
import { runRepoPickerFlow } from "./view-repo-picker-ink.js";
|
|
5
|
+
import { RepoPickerBackError } from "./repo-picker-back.js";
|
|
6
|
+
import { enterAlternateScreen, leaveAlternateScreen } from "./terminal-fullscreen.js";
|
|
7
|
+
import { getRepoFullNameFromGitRoot } from "../git-info.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {boolean} noTui
|
|
11
|
+
* @param {() => Promise<void>} fn
|
|
12
|
+
*/
|
|
13
|
+
async function withViewFullscreen(noTui, fn) {
|
|
14
|
+
const tty = process.stdin.isTTY && process.stdout.isTTY;
|
|
15
|
+
if (tty && !noTui) {
|
|
16
|
+
enterAlternateScreen(process.stdout);
|
|
17
|
+
try {
|
|
18
|
+
await fn();
|
|
19
|
+
} finally {
|
|
20
|
+
leaveAlternateScreen(process.stdout);
|
|
21
|
+
}
|
|
22
|
+
} else {
|
|
23
|
+
await fn();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve CLI args and open the stack viewer (local file, remote repo, or picker TUI).
|
|
29
|
+
* @param {string | undefined} repoPos
|
|
30
|
+
* @param {string | undefined} refPos
|
|
31
|
+
* @param {{ noTui?: boolean, repo?: string, ref?: string, file?: string, reviewAutoapply?: boolean }} opts
|
|
32
|
+
*/
|
|
33
|
+
export async function runNugitViewEntry(repoPos, refPos, opts) {
|
|
34
|
+
if (opts.file) {
|
|
35
|
+
await withViewFullscreen(!!opts.noTui, () =>
|
|
36
|
+
runStackViewCommand({ file: opts.file, noTui: opts.noTui })
|
|
37
|
+
);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const explicitRepo = (repoPos && String(repoPos).trim()) || (opts.repo && String(opts.repo).trim()) || "";
|
|
42
|
+
const explicitRef = (refPos && String(refPos).trim()) || (opts.ref && String(opts.ref).trim()) || "";
|
|
43
|
+
|
|
44
|
+
const tty = process.stdin.isTTY && process.stdout.isTTY;
|
|
45
|
+
|
|
46
|
+
if (!tty || opts.noTui) {
|
|
47
|
+
if (explicitRepo) {
|
|
48
|
+
await withViewFullscreen(!!opts.noTui, () =>
|
|
49
|
+
runStackViewCommand({
|
|
50
|
+
repo: explicitRepo,
|
|
51
|
+
ref: explicitRef || undefined,
|
|
52
|
+
noTui: opts.noTui,
|
|
53
|
+
reviewAutoapply: opts.reviewAutoapply
|
|
54
|
+
})
|
|
55
|
+
);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const root = findGitRoot();
|
|
60
|
+
if (root && fs.existsSync(stackJsonPath(root))) {
|
|
61
|
+
await withViewFullscreen(!!opts.noTui, () =>
|
|
62
|
+
runStackViewCommand({ noTui: opts.noTui, reviewAutoapply: opts.reviewAutoapply })
|
|
63
|
+
);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
throw new Error(
|
|
68
|
+
"nugit view: pass owner/repo and optional ref, use --file, run inside a repo with .nugit/stack.json, or use a TTY for the repo picker. " +
|
|
69
|
+
"Sign in with `nugit auth login` or set NUGIT_USER_TOKEN when GitHub returns 401."
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** @type {string | null} */
|
|
74
|
+
let autoRepo = null;
|
|
75
|
+
if (explicitRepo) {
|
|
76
|
+
autoRepo = explicitRepo;
|
|
77
|
+
} else {
|
|
78
|
+
const root = findGitRoot();
|
|
79
|
+
if (root) {
|
|
80
|
+
try {
|
|
81
|
+
autoRepo = getRepoFullNameFromGitRoot(root);
|
|
82
|
+
} catch {
|
|
83
|
+
autoRepo = null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
await withViewFullscreen(false, async () => {
|
|
89
|
+
let firstRun = true;
|
|
90
|
+
for (;;) {
|
|
91
|
+
let useRepo;
|
|
92
|
+
if (firstRun && autoRepo) {
|
|
93
|
+
useRepo = autoRepo;
|
|
94
|
+
} else {
|
|
95
|
+
const picked = await runRepoPickerFlow();
|
|
96
|
+
if (!picked) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
useRepo = picked.repo;
|
|
100
|
+
}
|
|
101
|
+
firstRun = false;
|
|
102
|
+
try {
|
|
103
|
+
await runStackViewCommand({
|
|
104
|
+
repo: useRepo,
|
|
105
|
+
ref: (useRepo === explicitRepo && explicitRef) ? explicitRef : undefined,
|
|
106
|
+
noTui: false,
|
|
107
|
+
reviewAutoapply: opts.reviewAutoapply,
|
|
108
|
+
allowBackToRepoPicker: true
|
|
109
|
+
});
|
|
110
|
+
break;
|
|
111
|
+
} catch (e) {
|
|
112
|
+
if (e instanceof RepoPickerBackError) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
throw e;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* xterm SGR mouse protocol (CSI ?1006 h).
|
|
3
|
+
* Sequence: ESC [ < Pb ; Px ; Py M (press) or m (release)
|
|
4
|
+
* Px = column (1-based), Py = row (1-based).
|
|
5
|
+
* @see https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} input
|
|
12
|
+
* @returns {{ button: number, col: number, row: number, release: boolean } | null}
|
|
13
|
+
*/
|
|
14
|
+
export function parseSgrMouse(input) {
|
|
15
|
+
if (typeof input !== "string" || !input.startsWith("\x1b[<")) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
const m = SGR_MOUSE_RE.exec(input);
|
|
19
|
+
if (!m) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
const button = Number.parseInt(m[1], 10);
|
|
23
|
+
const col = Number.parseInt(m[2], 10);
|
|
24
|
+
const row = Number.parseInt(m[3], 10);
|
|
25
|
+
const release = m[4] === "m";
|
|
26
|
+
return { button, col, row, release };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Wheel (motion bit 32 may be set by some terminals). */
|
|
30
|
+
export function isWheelUp(btn) {
|
|
31
|
+
return (btn & ~32) === 64;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isWheelDown(btn) {
|
|
35
|
+
return (btn & ~32) === 65;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {import('node:stream').Writable} stdout
|
|
40
|
+
*/
|
|
41
|
+
export function enableSgrMouse(stdout) {
|
|
42
|
+
if (!stdout?.isTTY) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
stdout.write("\x1b[?1000h\x1b[?1002h\x1b[?1006h");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {import('node:stream').Writable} stdout
|
|
50
|
+
*/
|
|
51
|
+
export function disableSgrMouse(stdout) {
|
|
52
|
+
if (!stdout?.isTTY) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
stdout.write("\x1b[?1006l\x1b[?1002l\x1b[?1000l");
|
|
56
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Compact ASCII “git graph” for stacked PRs (linear chain), aligned with row index.
|
|
5
|
+
* Tip is drawn first (top); base last (bottom). Selection uses a filled node + branch tint.
|
|
6
|
+
*
|
|
7
|
+
* @param {Array<{ entry: { pr_number: number }, pull?: unknown }>} rows
|
|
8
|
+
* @param {number} selectedIndex
|
|
9
|
+
* @param {number} maxLines
|
|
10
|
+
* @param {number} w
|
|
11
|
+
* @param {{ muted?: boolean, fullBranchNames?: boolean }} [opts] muted = dim stack-picker cards; fullBranchNames = no ellipsis (stack picker)
|
|
12
|
+
* @returns {string[]}
|
|
13
|
+
*/
|
|
14
|
+
export function buildStackBranchGraphLines(rows, selectedIndex, maxLines, w, opts) {
|
|
15
|
+
const muted = opts?.muted === true;
|
|
16
|
+
const fullBranchNames = opts?.fullBranchNames === true;
|
|
17
|
+
const n = rows.length;
|
|
18
|
+
if (n === 0) return [];
|
|
19
|
+
|
|
20
|
+
const labels = rows.map((r) => {
|
|
21
|
+
const pull = r.pull && typeof r.pull === "object" ? /** @type {Record<string, unknown>} */ (r.pull) : {};
|
|
22
|
+
const head = pull.head && typeof pull.head === "object" ? /** @type {Record<string, unknown>} */ (pull.head) : {};
|
|
23
|
+
const href = typeof head.ref === "string" ? head.ref : "";
|
|
24
|
+
const refLabel = fullBranchNames ? href : href.length > 10 ? `${href.slice(0, 9)}\u2026` : href;
|
|
25
|
+
return { pr: r.entry.pr_number, ref: refLabel };
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** @type {string[]} */
|
|
29
|
+
const out = [];
|
|
30
|
+
const tailChalk = muted ? (/** @param {string} s */ (s) => chalk.gray.dim(s)) : chalk.white;
|
|
31
|
+
|
|
32
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
33
|
+
const sel = !muted && i === selectedIndex;
|
|
34
|
+
const node = muted
|
|
35
|
+
? chalk.gray.dim("\u25cb")
|
|
36
|
+
: sel
|
|
37
|
+
? chalk.yellow("\u25cf")
|
|
38
|
+
: chalk.gray("\u25cb");
|
|
39
|
+
/** @type {string} */
|
|
40
|
+
let prefix;
|
|
41
|
+
if (i === n - 1) {
|
|
42
|
+
if (n === 1) {
|
|
43
|
+
prefix = " ";
|
|
44
|
+
} else if (muted) {
|
|
45
|
+
prefix = chalk.gray("\u2502 ");
|
|
46
|
+
} else {
|
|
47
|
+
prefix = sel ? chalk.yellow("\u2502 ") : chalk.gray("\u2502 ");
|
|
48
|
+
}
|
|
49
|
+
} else if (i === 0) {
|
|
50
|
+
prefix = muted ? chalk.gray("\u2514\u2500") : sel ? chalk.yellow("\u2514\u2500") : chalk.gray("\u2514\u2500");
|
|
51
|
+
} else {
|
|
52
|
+
prefix = muted ? chalk.gray("\u2502 ") : sel ? chalk.yellow("\u2502 ") : chalk.gray("\u2502 ");
|
|
53
|
+
}
|
|
54
|
+
const prefixPlain = stripAnsi(prefix).length;
|
|
55
|
+
const nodePlain = stripAnsi(node).length;
|
|
56
|
+
const budget = Math.max(4, w - prefixPlain - nodePlain - 1);
|
|
57
|
+
const tailPlain = `#${labels[i].pr}${labels[i].ref ? " " + labels[i].ref : ""}`;
|
|
58
|
+
const tailOut = fullBranchNames ? tailPlain : truncPlain(tailPlain, budget);
|
|
59
|
+
const line = `${prefix}${node} ${tailChalk(tailOut)}`;
|
|
60
|
+
out.push(line);
|
|
61
|
+
if (out.length >= maxLines) break;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Map a discovery stack’s `prs` (position-sorted, base → tip) to rows for {@link buildStackBranchGraphLines}.
|
|
69
|
+
*
|
|
70
|
+
* @param {{ prs?: { pr_number: number, head_branch?: string }[] }} stack
|
|
71
|
+
* @returns {Array<{ entry: { pr_number: number }, pull: { head: { ref: string } } }>}
|
|
72
|
+
*/
|
|
73
|
+
export function discoveryPrsToGraphRows(stack) {
|
|
74
|
+
const prs = stack && Array.isArray(stack.prs) ? stack.prs : [];
|
|
75
|
+
return prs.map((p) => ({
|
|
76
|
+
entry: { pr_number: p.pr_number },
|
|
77
|
+
pull: { head: { ref: typeof p.head_branch === "string" ? p.head_branch : "" } }
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {string} s
|
|
83
|
+
*/
|
|
84
|
+
function stripAnsi(s) {
|
|
85
|
+
return s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {string} s
|
|
90
|
+
* @param {number} w
|
|
91
|
+
*/
|
|
92
|
+
function truncPlain(s, w) {
|
|
93
|
+
if (s.length <= w) return s;
|
|
94
|
+
return s.slice(0, Math.max(0, w - 1)) + "\u2026";
|
|
95
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { hasPickerViewingContext, pickViewingHighlightIndex } from "./stack-pick-sort.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Overview row: nodes for up to 10 stacks; cursor column in yellow; open-in-viewer in cyan (when set).
|
|
6
|
+
* @param {{ tip_pr_number: number, tip_head_branch?: string }[]} stacks rows shown (often visible subset)
|
|
7
|
+
* @param {number} selectedIndex cursor (j/k)
|
|
8
|
+
* @param {number} w
|
|
9
|
+
* @param {number | null | undefined} [viewingTipPrNumber]
|
|
10
|
+
* @param {string | null | undefined} [viewingHeadRef] tip branch for matching inferred / discovery rows
|
|
11
|
+
* @param {unknown[] | null | undefined} [matchContextStacks] full picker list for head-ref disambiguation (defaults to `stacks`)
|
|
12
|
+
* @returns {string[]}
|
|
13
|
+
*/
|
|
14
|
+
export function buildPickStackOverviewLines(
|
|
15
|
+
stacks,
|
|
16
|
+
selectedIndex,
|
|
17
|
+
w,
|
|
18
|
+
viewingTipPrNumber,
|
|
19
|
+
viewingHeadRef,
|
|
20
|
+
matchContextStacks
|
|
21
|
+
) {
|
|
22
|
+
const matchCtx = matchContextStacks ?? stacks;
|
|
23
|
+
if (stacks.length === 0) {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
const viewingIdx = pickViewingHighlightIndex(stacks, viewingTipPrNumber, viewingHeadRef, matchCtx);
|
|
27
|
+
const n = stacks.length;
|
|
28
|
+
const gap = " ";
|
|
29
|
+
/** @type {string[]} */
|
|
30
|
+
const out = [];
|
|
31
|
+
const legend = hasPickerViewingContext(viewingTipPrNumber, viewingHeadRef)
|
|
32
|
+
? "Yellow = cursor · cyan = open in viewer · gray = other"
|
|
33
|
+
: "Top stacks by GitHub PR update time — cursor highlights one chain:";
|
|
34
|
+
out.push(chalk.dim(legend));
|
|
35
|
+
const nodeParts = [];
|
|
36
|
+
for (let i = 0; i < n; i++) {
|
|
37
|
+
const sel = i === selectedIndex;
|
|
38
|
+
const open = viewingIdx >= 0 && i === viewingIdx;
|
|
39
|
+
/** @type {string} */
|
|
40
|
+
let node;
|
|
41
|
+
if (sel) {
|
|
42
|
+
node = chalk.yellow("\u25cf ");
|
|
43
|
+
} else if (open) {
|
|
44
|
+
node = chalk.cyan("\u25cf ");
|
|
45
|
+
} else {
|
|
46
|
+
node = chalk.gray("\u25cb ");
|
|
47
|
+
}
|
|
48
|
+
/** @type {string} */
|
|
49
|
+
let pr;
|
|
50
|
+
if (sel) {
|
|
51
|
+
pr = chalk.yellowBright(`#${stacks[i].tip_pr_number}`);
|
|
52
|
+
} else if (open) {
|
|
53
|
+
pr = chalk.cyan(`#${stacks[i].tip_pr_number}`);
|
|
54
|
+
} else {
|
|
55
|
+
pr = chalk.gray(`#${stacks[i].tip_pr_number}`);
|
|
56
|
+
}
|
|
57
|
+
nodeParts.push(node + pr);
|
|
58
|
+
}
|
|
59
|
+
const line1 = nodeParts.join(gap);
|
|
60
|
+
out.push(line1.length > w ? chalk.dim(truncPlain(stripAnsi(line1), w)) : line1);
|
|
61
|
+
const brParts = [];
|
|
62
|
+
for (let i = 0; i < n; i++) {
|
|
63
|
+
const sel = i === selectedIndex;
|
|
64
|
+
const open = viewingIdx >= 0 && i === viewingIdx;
|
|
65
|
+
const raw = String(stacks[i].tip_head_branch || "").slice(0, 14);
|
|
66
|
+
if (sel) {
|
|
67
|
+
brParts.push(chalk.white(truncPlain(raw, 14)));
|
|
68
|
+
} else if (open) {
|
|
69
|
+
brParts.push(chalk.cyan(truncPlain(raw, 14)));
|
|
70
|
+
} else {
|
|
71
|
+
brParts.push(chalk.gray(truncPlain(raw, 14)));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const line2 = brParts.join(gap);
|
|
75
|
+
out.push(line2.length > w ? chalk.dim(truncPlain(stripAnsi(line2), w)) : line2);
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {string} s
|
|
81
|
+
*/
|
|
82
|
+
function stripAnsi(s) {
|
|
83
|
+
return s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {string} s
|
|
88
|
+
* @param {number} maxW
|
|
89
|
+
*/
|
|
90
|
+
function truncPlain(s, maxW) {
|
|
91
|
+
if (s.length <= maxW) return s;
|
|
92
|
+
return s.slice(0, Math.max(1, maxW - 1)) + "\u2026";
|
|
93
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import React, { useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { Box, Text, useApp, useInput, useStdout } from "ink";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { buildPickerVisibleStacks, pickViewingHighlightIndex } from "./stack-pick-sort.js";
|
|
5
|
+
import { buildPickStackOverviewLines } from "./stack-pick-graph.js";
|
|
6
|
+
import { buildStackBranchGraphLines, discoveryPrsToGraphRows } from "./stack-branch-graph.js";
|
|
7
|
+
import { stackPickTerminalLayout } from "./stack-pick-layout.js";
|
|
8
|
+
|
|
9
|
+
const PICKER_GRAPH_MAX = 12;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {object} props
|
|
13
|
+
* @param {{ tip_pr_number: number, tip_head_branch: string, pr_count: number, created_by: string, prs: { pr_number: number, title?: string, head_branch?: string }[], tip_updated_at?: string, inferChainIndex?: number, inferDiffAdd?: number, inferDiffDel?: number, picker_merged_stack?: boolean }[]} props.stacks
|
|
14
|
+
* @param {(stack: (typeof props.stacks)[0] | null) => void} props.onPick
|
|
15
|
+
* @param {string} [props.title]
|
|
16
|
+
* @param {() => void} [props.onRequestBack] Backspace: return to repo picker (infer flow only)
|
|
17
|
+
* @param {boolean} [props.escapeToRepo] when true with onRequestBack, Esc and q also go to repo (repo-picker loop)
|
|
18
|
+
* @param {number | null | undefined} [props.viewingTipPrNumber] tip PR# of the stack currently open in the viewer
|
|
19
|
+
* @param {string | null | undefined} [props.viewingHeadRef] tip branch ref for matching inferred / discovery rows
|
|
20
|
+
*/
|
|
21
|
+
export function StackPickInk({
|
|
22
|
+
stacks,
|
|
23
|
+
onPick,
|
|
24
|
+
title = "Choose stack to view",
|
|
25
|
+
onRequestBack,
|
|
26
|
+
escapeToRepo = false,
|
|
27
|
+
viewingTipPrNumber,
|
|
28
|
+
viewingHeadRef
|
|
29
|
+
}) {
|
|
30
|
+
const { exit } = useApp();
|
|
31
|
+
const { stdout } = useStdout();
|
|
32
|
+
const { cols, innerW } = stackPickTerminalLayout(stdout?.columns);
|
|
33
|
+
|
|
34
|
+
const openRows = useMemo(
|
|
35
|
+
() => stacks.filter((s) => s && typeof s === "object" && s.picker_merged_stack !== true),
|
|
36
|
+
[stacks]
|
|
37
|
+
);
|
|
38
|
+
const closedRows = useMemo(
|
|
39
|
+
() => stacks.filter((s) => s && typeof s === "object" && s.picker_merged_stack === true),
|
|
40
|
+
[stacks]
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const [pickerSection, setPickerSection] = useState(() =>
|
|
44
|
+
openRows.length > 0 ? "open" : "closed"
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const activeRows = useMemo(
|
|
48
|
+
() => (pickerSection === "open" ? openRows : closedRows),
|
|
49
|
+
[pickerSection, openRows, closedRows]
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const visible = useMemo(
|
|
53
|
+
() => buildPickerVisibleStacks(activeRows, { viewingTipPrNumber, viewingHeadRef }),
|
|
54
|
+
[activeRows, viewingTipPrNumber, viewingHeadRef]
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const [cursor, setCursor] = useState(0);
|
|
58
|
+
|
|
59
|
+
const stacksRef = useRef(stacks);
|
|
60
|
+
stacksRef.current = stacks;
|
|
61
|
+
|
|
62
|
+
useLayoutEffect(() => {
|
|
63
|
+
const all = stacksRef.current;
|
|
64
|
+
const openR = all.filter((s) => s && typeof s === "object" && s.picker_merged_stack !== true);
|
|
65
|
+
const closedR = all.filter((s) => s && typeof s === "object" && s.picker_merged_stack === true);
|
|
66
|
+
const act = pickerSection === "open" ? openR : closedR;
|
|
67
|
+
const vis = buildPickerVisibleStacks(act, { viewingTipPrNumber, viewingHeadRef });
|
|
68
|
+
const hi = pickViewingHighlightIndex(vis, viewingTipPrNumber, viewingHeadRef, all);
|
|
69
|
+
setCursor(hi >= 0 ? hi : 0);
|
|
70
|
+
// Only snap when switching tabs or viewer match context — not on every stacks[] identity churn (would break j/k).
|
|
71
|
+
}, [pickerSection, viewingTipPrNumber, viewingHeadRef]);
|
|
72
|
+
|
|
73
|
+
const visibleRef = useRef(visible);
|
|
74
|
+
visibleRef.current = visible;
|
|
75
|
+
|
|
76
|
+
const safe = visible.length ? Math.min(cursor, visible.length - 1) : 0;
|
|
77
|
+
const safeRef = useRef(safe);
|
|
78
|
+
safeRef.current = safe;
|
|
79
|
+
|
|
80
|
+
const viewingHighlightIdx = useMemo(
|
|
81
|
+
() => pickViewingHighlightIndex(visible, viewingTipPrNumber, viewingHeadRef, stacks),
|
|
82
|
+
[visible, viewingTipPrNumber, viewingHeadRef, stacks]
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const sectionLabel =
|
|
86
|
+
pickerSection === "open"
|
|
87
|
+
? chalk.green("Open stacks")
|
|
88
|
+
: chalk.gray.dim("Merged / closed (tip PR not open)");
|
|
89
|
+
const backHint =
|
|
90
|
+
onRequestBack && escapeToRepo
|
|
91
|
+
? " · Esc/Backspace/q: repo list"
|
|
92
|
+
: onRequestBack
|
|
93
|
+
? " · Backspace: repo list · Esc/q: cancel"
|
|
94
|
+
: " · Esc/q: cancel";
|
|
95
|
+
const hintLine = `${sectionLabel}${chalk.reset("")} · Tab: switch · Showing ${visible.length} of ${activeRows.length} in tab (${openRows.length} open / ${closedRows.length} merged) · j/k · 1-9 · Enter${backHint}`;
|
|
96
|
+
|
|
97
|
+
useInput((input, key) => {
|
|
98
|
+
const backKey = key.backspace || key.delete || input === "\x7f" || input === "\b";
|
|
99
|
+
if (backKey && onRequestBack) {
|
|
100
|
+
onRequestBack();
|
|
101
|
+
exit();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (input === "q" || key.escape) {
|
|
105
|
+
if (escapeToRepo && onRequestBack) {
|
|
106
|
+
onRequestBack();
|
|
107
|
+
} else {
|
|
108
|
+
onPick(null);
|
|
109
|
+
}
|
|
110
|
+
exit();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (key.tab) {
|
|
114
|
+
setPickerSection((sec) => {
|
|
115
|
+
if (sec === "open") {
|
|
116
|
+
return closedRows.length > 0 ? "closed" : "open";
|
|
117
|
+
}
|
|
118
|
+
return openRows.length > 0 ? "open" : "closed";
|
|
119
|
+
});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (input === "j" || key.downArrow) {
|
|
123
|
+
setCursor((c) => {
|
|
124
|
+
const vis = visibleRef.current;
|
|
125
|
+
const max = Math.max(0, vis.length - 1);
|
|
126
|
+
return Math.min(c + 1, max);
|
|
127
|
+
});
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (input === "k" || key.upArrow) {
|
|
131
|
+
setCursor((c) => Math.max(c - 1, 0));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (/^[1-9]$/.test(input)) {
|
|
135
|
+
const n = Number.parseInt(input, 10) - 1;
|
|
136
|
+
const vis = visibleRef.current;
|
|
137
|
+
if (n < vis.length) {
|
|
138
|
+
onPick(vis[n] ?? null);
|
|
139
|
+
exit();
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (key.return || input === " ") {
|
|
144
|
+
const vis = visibleRef.current;
|
|
145
|
+
if (vis.length === 0) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const i = Math.min(Math.max(0, safeRef.current), vis.length - 1);
|
|
149
|
+
onPick(vis[i] ?? null);
|
|
150
|
+
exit();
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const overview = buildPickStackOverviewLines(visible, safe, innerW, viewingTipPrNumber, viewingHeadRef, stacks);
|
|
155
|
+
|
|
156
|
+
const emptyTab =
|
|
157
|
+
visible.length === 0
|
|
158
|
+
? React.createElement(
|
|
159
|
+
Text,
|
|
160
|
+
{ color: "gray", dimColor: true },
|
|
161
|
+
"No stacks in this tab — press Tab to switch."
|
|
162
|
+
)
|
|
163
|
+
: null;
|
|
164
|
+
|
|
165
|
+
return React.createElement(
|
|
166
|
+
Box,
|
|
167
|
+
{ flexDirection: "column", padding: 1, width: cols },
|
|
168
|
+
React.createElement(Text, { color: "cyan", bold: true }, title),
|
|
169
|
+
React.createElement(Text, { color: "gray" }, hintLine),
|
|
170
|
+
React.createElement(
|
|
171
|
+
Box,
|
|
172
|
+
{ flexDirection: "column", marginY: 1, width: cols },
|
|
173
|
+
...overview.map((ln, i) => React.createElement(Text, { key: `ov-${i}` }, ln))
|
|
174
|
+
),
|
|
175
|
+
emptyTab,
|
|
176
|
+
...visible.map((s, i) => {
|
|
177
|
+
const sel = i === safe;
|
|
178
|
+
const isViewing = viewingHighlightIdx >= 0 && i === viewingHighlightIdx;
|
|
179
|
+
const mark = sel ? "\u25b6 " : " ";
|
|
180
|
+
const inferTag =
|
|
181
|
+
s && typeof s === "object" && (s.inferredOnly || s.inferredFromViewerDoc)
|
|
182
|
+
? chalk.dim(" (inferred)")
|
|
183
|
+
: "";
|
|
184
|
+
const head =
|
|
185
|
+
chalk.white(mark) +
|
|
186
|
+
chalk.yellow("[" + (i + 1) + "]") +
|
|
187
|
+
" " +
|
|
188
|
+
(sel ? chalk.yellowBright("tip #" + s.tip_pr_number) : chalk.white("tip #" + s.tip_pr_number)) +
|
|
189
|
+
inferTag;
|
|
190
|
+
const graphRows = discoveryPrsToGraphRows(s);
|
|
191
|
+
const tipIdx = graphRows.length ? graphRows.length - 1 : 0;
|
|
192
|
+
const graphW = Math.max(10, innerW - 4);
|
|
193
|
+
const graphMuted = !sel && !isViewing;
|
|
194
|
+
const graphLines =
|
|
195
|
+
graphRows.length === 0
|
|
196
|
+
? []
|
|
197
|
+
: buildStackBranchGraphLines(graphRows, tipIdx, PICKER_GRAPH_MAX, graphW, {
|
|
198
|
+
muted: graphMuted,
|
|
199
|
+
fullBranchNames: true
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
/** @type {import('react').ReactNode[]} */
|
|
203
|
+
const diffBlock =
|
|
204
|
+
typeof s.inferDiffAdd === "number" || typeof s.inferDiffDel === "number"
|
|
205
|
+
? [
|
|
206
|
+
React.createElement(
|
|
207
|
+
Text,
|
|
208
|
+
{ key: "diff", color: sel ? "white" : "gray" },
|
|
209
|
+
`${chalk.dim("Lines: ")}${chalk.green("+" + (s.inferDiffAdd ?? 0))} ${chalk.red("-" + (s.inferDiffDel ?? 0))}`
|
|
210
|
+
)
|
|
211
|
+
]
|
|
212
|
+
: [];
|
|
213
|
+
|
|
214
|
+
let core = React.createElement(
|
|
215
|
+
Box,
|
|
216
|
+
{ flexDirection: "column" },
|
|
217
|
+
React.createElement(Text, { key: "head" }, head),
|
|
218
|
+
React.createElement(Text, { key: "pc", color: sel ? "white" : "gray" }, `PR count: ${s.pr_count}`),
|
|
219
|
+
...diffBlock,
|
|
220
|
+
React.createElement(Text, { key: "br", color: sel ? "cyan" : "gray" }, `branch ${s.tip_head_branch}`),
|
|
221
|
+
React.createElement(Text, { key: "by", color: "gray" }, `by ${s.created_by}`),
|
|
222
|
+
React.createElement(Text, { key: "lbl", color: sel ? "white" : "gray", dimColor: !sel }, "Branch"),
|
|
223
|
+
...graphLines.map((ln, gi) => React.createElement(Text, { key: `br-${gi}` }, ln))
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
if (isViewing) {
|
|
227
|
+
core = React.createElement(
|
|
228
|
+
Box,
|
|
229
|
+
{
|
|
230
|
+
borderStyle: "round",
|
|
231
|
+
borderColor: "cyan",
|
|
232
|
+
paddingLeft: 1,
|
|
233
|
+
paddingRight: 1,
|
|
234
|
+
paddingTop: 1,
|
|
235
|
+
paddingBottom: 1,
|
|
236
|
+
flexDirection: "column"
|
|
237
|
+
},
|
|
238
|
+
React.createElement(Text, { key: "open", color: "cyan" }, " Open in viewer"),
|
|
239
|
+
core
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (sel) {
|
|
244
|
+
core = React.createElement(
|
|
245
|
+
Box,
|
|
246
|
+
{
|
|
247
|
+
borderStyle: "round",
|
|
248
|
+
borderColor: "yellow",
|
|
249
|
+
paddingLeft: 1,
|
|
250
|
+
paddingRight: 1,
|
|
251
|
+
paddingTop: 1,
|
|
252
|
+
paddingBottom: 1,
|
|
253
|
+
flexDirection: "column"
|
|
254
|
+
},
|
|
255
|
+
core
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return React.createElement(
|
|
260
|
+
Box,
|
|
261
|
+
{
|
|
262
|
+
key: String(s.tip_pr_number) + "-" + i,
|
|
263
|
+
flexDirection: "column",
|
|
264
|
+
marginBottom: 1
|
|
265
|
+
},
|
|
266
|
+
core
|
|
267
|
+
);
|
|
268
|
+
})
|
|
269
|
+
);
|
|
270
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stack picker layout derived from the TTY width.
|
|
3
|
+
*
|
|
4
|
+
* Previously `Math.max(40, cols - 4)` forced a minimum content width **wider than the
|
|
5
|
+
* terminal** when `stdout.columns < 44`. Ink then reflowed on every j/k render, so the
|
|
6
|
+
* title appeared to flash. Moving the cursor also changes which row has bordered Boxes
|
|
7
|
+
* (padding), which can shift total height slightly. A shared large `minHeight` on every card
|
|
8
|
+
* was avoided—it caused huge empty gaps between stacks in typical terminals.
|
|
9
|
+
* Ink `Static` is not used for the title: it only renders newly appended `items` by length,
|
|
10
|
+
* so a fixed single-item header would disappear after the first frame.
|
|
11
|
+
*
|
|
12
|
+
* @param {number | undefined} stdoutColumns
|
|
13
|
+
* @returns {{ cols: number, innerW: number }}
|
|
14
|
+
*/
|
|
15
|
+
export function stackPickTerminalLayout(stdoutColumns) {
|
|
16
|
+
const cols = Math.max(20, stdoutColumns ?? 80);
|
|
17
|
+
const innerW = Math.max(16, cols - 4);
|
|
18
|
+
return { cols, innerW };
|
|
19
|
+
}
|