nugit-cli 0.0.1 → 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.
- package/package.json +1 -1
- package/src/api-client.js +10 -23
- 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 +149 -6
- package/src/nugit-config.js +84 -0
- package/src/nugit-stack.js +40 -257
- package/src/nugit.js +104 -647
- 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 +169 -0
- package/src/review-hub/run-review-hub.js +131 -0
- package/src/services/repo-branches.js +151 -0
- package/src/services/stack-inference.js +90 -0
- package/src/split-view/run-split.js +14 -76
- package/src/split-view/split-ink.js +2 -2
- 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 +80 -0
- package/src/stack-view/ink-app.js +3 -421
- package/src/stack-view/loader.js +19 -93
- package/src/stack-view/loading-ink.js +2 -0
- package/src/stack-view/merge-alternate-pick-stacks.js +245 -0
- package/src/stack-view/patch-preview-merge.js +108 -0
- package/src/stack-view/remote-infer-doc.js +76 -0
- package/src/stack-view/repo-picker-back.js +10 -0
- package/src/stack-view/run-stack-view.js +508 -150
- package/src/stack-view/run-view-entry.js +115 -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 +308 -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/stack-picker-graph-pane.js +118 -0
- package/src/stack-view/terminal-fullscreen.js +7 -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
- package/src/tui/pages/home.js +122 -0
- package/src/tui/pages/repo-actions.js +81 -0
- package/src/tui/pages/repo-branches.js +259 -0
- package/src/tui/pages/viewer.js +2129 -0
- package/src/tui/router.js +40 -0
- package/src/tui/run-tui.js +281 -0
- package/src/utilities/loading.js +37 -0
- package/src/utilities/terminal.js +31 -0
- package/src/cli-output.js +0 -228
- package/src/nugit-start.js +0 -211
- package/src/stack-discover.js +0 -284
- package/src/stack-discovery-config.js +0 -91
- package/src/stack-extra-commands.js +0 -353
- package/src/stack-graph.js +0 -214
- package/src/stack-helpers.js +0 -58
- package/src/stack-propagate.js +0 -422
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { findGitRoot } from "../nugit-stack.js";
|
|
2
|
+
import { runStackViewCommand } from "./run-stack-view.js";
|
|
3
|
+
import { runRepoPickerFlow } from "./view-repo-picker-ink.js";
|
|
4
|
+
import { RepoPickerBackError } from "./repo-picker-back.js";
|
|
5
|
+
import { enterAlternateScreen, leaveAlternateScreen } from "./terminal-fullscreen.js";
|
|
6
|
+
import { getRepoFullNameFromGitRoot } from "../git-info.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {boolean} noTui
|
|
10
|
+
* @param {() => Promise<void>} fn
|
|
11
|
+
*/
|
|
12
|
+
async function withViewFullscreen(noTui, fn) {
|
|
13
|
+
const tty = process.stdin.isTTY && process.stdout.isTTY;
|
|
14
|
+
if (tty && !noTui) {
|
|
15
|
+
enterAlternateScreen(process.stdout);
|
|
16
|
+
try {
|
|
17
|
+
await fn();
|
|
18
|
+
} finally {
|
|
19
|
+
leaveAlternateScreen(process.stdout);
|
|
20
|
+
}
|
|
21
|
+
} else {
|
|
22
|
+
await fn();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve CLI args and open the stack viewer (remote repo by coords, repo picker, or current dir inference).
|
|
28
|
+
* @param {string | undefined} repoPos
|
|
29
|
+
* @param {string | undefined} refPos
|
|
30
|
+
* @param {{ noTui?: boolean, repo?: string, ref?: string, file?: string, reviewAutoapply?: boolean }} opts
|
|
31
|
+
*/
|
|
32
|
+
export async function runNugitViewEntry(repoPos, refPos, opts) {
|
|
33
|
+
if (opts.file) {
|
|
34
|
+
await withViewFullscreen(!!opts.noTui, () =>
|
|
35
|
+
runStackViewCommand({ file: opts.file, noTui: opts.noTui })
|
|
36
|
+
);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const explicitRepo = (repoPos && String(repoPos).trim()) || (opts.repo && String(opts.repo).trim()) || "";
|
|
41
|
+
const explicitRef = (refPos && String(refPos).trim()) || (opts.ref && String(opts.ref).trim()) || "";
|
|
42
|
+
|
|
43
|
+
const tty = process.stdin.isTTY && process.stdout.isTTY;
|
|
44
|
+
|
|
45
|
+
if (!tty || opts.noTui) {
|
|
46
|
+
if (explicitRepo) {
|
|
47
|
+
await withViewFullscreen(!!opts.noTui, () =>
|
|
48
|
+
runStackViewCommand({
|
|
49
|
+
repo: explicitRepo,
|
|
50
|
+
ref: explicitRef || undefined,
|
|
51
|
+
noTui: opts.noTui,
|
|
52
|
+
reviewAutoapply: opts.reviewAutoapply
|
|
53
|
+
})
|
|
54
|
+
);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Non-TTY with no explicit repo: infer from git remote
|
|
59
|
+
const root = findGitRoot();
|
|
60
|
+
let inferredRepo = null;
|
|
61
|
+
if (root) {
|
|
62
|
+
try { inferredRepo = getRepoFullNameFromGitRoot(root); } catch { inferredRepo = null; }
|
|
63
|
+
}
|
|
64
|
+
if (inferredRepo) {
|
|
65
|
+
await withViewFullscreen(!!opts.noTui, () =>
|
|
66
|
+
runStackViewCommand({ repo: inferredRepo, noTui: opts.noTui, reviewAutoapply: opts.reviewAutoapply })
|
|
67
|
+
);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
throw new Error(
|
|
72
|
+
"nugit view: pass owner/repo and optional ref, or run inside a git clone with a github.com remote. " +
|
|
73
|
+
"Sign in with `nugit auth login` or set NUGIT_USER_TOKEN when GitHub returns 401."
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// TTY: use the repo picker flow (or jump straight to current dir)
|
|
78
|
+
let autoRepo = null;
|
|
79
|
+
if (explicitRepo) {
|
|
80
|
+
autoRepo = explicitRepo;
|
|
81
|
+
} else {
|
|
82
|
+
const root = findGitRoot();
|
|
83
|
+
if (root) {
|
|
84
|
+
try { autoRepo = getRepoFullNameFromGitRoot(root); } catch { autoRepo = null; }
|
|
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) return;
|
|
97
|
+
useRepo = picked.repo;
|
|
98
|
+
}
|
|
99
|
+
firstRun = false;
|
|
100
|
+
try {
|
|
101
|
+
await runStackViewCommand({
|
|
102
|
+
repo: useRepo,
|
|
103
|
+
ref: (useRepo === explicitRepo && explicitRef) ? explicitRef : undefined,
|
|
104
|
+
noTui: false,
|
|
105
|
+
reviewAutoapply: opts.reviewAutoapply,
|
|
106
|
+
allowBackToRepoPicker: true
|
|
107
|
+
});
|
|
108
|
+
break;
|
|
109
|
+
} catch (e) {
|
|
110
|
+
if (e instanceof RepoPickerBackError) continue;
|
|
111
|
+
throw e;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
@@ -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,308 @@
|
|
|
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 { buildSplitGraphPane } from "./stack-picker-graph-pane.js";
|
|
6
|
+
import { stackPickTerminalLayout } from "./stack-pick-layout.js";
|
|
7
|
+
|
|
8
|
+
/** Minimum terminal width to show the split-pane graph; narrower terminals fall back to text list. */
|
|
9
|
+
const SPLIT_MIN_COLS = 64;
|
|
10
|
+
const GRAPH_W = 30;
|
|
11
|
+
|
|
12
|
+
// ─── Visual design constants ──────────────────────────────────────────────────
|
|
13
|
+
// Cursor (hover) box: round yellow border.
|
|
14
|
+
// Viewing (open in viewer) box: round cyan border, nested inside cursor box
|
|
15
|
+
// when they are the same item — creating a concentric inset effect.
|
|
16
|
+
// Default text: white (not gray) so content is legible at a glance.
|
|
17
|
+
// Dim / secondary metadata: dimColor true (author, hint lines).
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {object} props
|
|
22
|
+
* @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, base_ref?: string }[]} props.stacks
|
|
23
|
+
* @param {(stack: (typeof props.stacks)[0] | null) => void} props.onPick
|
|
24
|
+
* @param {string} [props.title]
|
|
25
|
+
* @param {() => void} [props.onRequestBack]
|
|
26
|
+
* @param {boolean} [props.escapeToRepo]
|
|
27
|
+
* @param {number | null | undefined} [props.viewingTipPrNumber]
|
|
28
|
+
* @param {string | null | undefined} [props.viewingHeadRef]
|
|
29
|
+
*/
|
|
30
|
+
export function StackPickInk({
|
|
31
|
+
stacks,
|
|
32
|
+
onPick,
|
|
33
|
+
title = "Choose stack to view",
|
|
34
|
+
onRequestBack,
|
|
35
|
+
escapeToRepo = false,
|
|
36
|
+
viewingTipPrNumber,
|
|
37
|
+
viewingHeadRef
|
|
38
|
+
}) {
|
|
39
|
+
const { exit } = useApp();
|
|
40
|
+
const { stdout } = useStdout();
|
|
41
|
+
const { cols, innerW } = stackPickTerminalLayout(stdout?.columns);
|
|
42
|
+
const ttyRows = stdout?.rows ?? 24;
|
|
43
|
+
|
|
44
|
+
const openRows = useMemo(
|
|
45
|
+
() => stacks.filter((s) => s && typeof s === "object" && s.picker_merged_stack !== true),
|
|
46
|
+
[stacks]
|
|
47
|
+
);
|
|
48
|
+
const closedRows = useMemo(
|
|
49
|
+
() => stacks.filter((s) => s && typeof s === "object" && s.picker_merged_stack === true),
|
|
50
|
+
[stacks]
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const [pickerSection, setPickerSection] = useState(() =>
|
|
54
|
+
openRows.length > 0 ? "open" : "closed"
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const activeRows = useMemo(
|
|
58
|
+
() => (pickerSection === "open" ? openRows : closedRows),
|
|
59
|
+
[pickerSection, openRows, closedRows]
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const visible = useMemo(
|
|
63
|
+
() => buildPickerVisibleStacks(activeRows, { viewingTipPrNumber, viewingHeadRef }),
|
|
64
|
+
[activeRows, viewingTipPrNumber, viewingHeadRef]
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const [cursor, setCursor] = useState(0);
|
|
68
|
+
|
|
69
|
+
const stacksRef = useRef(stacks);
|
|
70
|
+
stacksRef.current = stacks;
|
|
71
|
+
|
|
72
|
+
useLayoutEffect(() => {
|
|
73
|
+
const all = stacksRef.current;
|
|
74
|
+
const openR = all.filter((s) => s && typeof s === "object" && s.picker_merged_stack !== true);
|
|
75
|
+
const closedR = all.filter((s) => s && typeof s === "object" && s.picker_merged_stack === true);
|
|
76
|
+
const act = pickerSection === "open" ? openR : closedR;
|
|
77
|
+
const vis = buildPickerVisibleStacks(act, { viewingTipPrNumber, viewingHeadRef });
|
|
78
|
+
const hi = pickViewingHighlightIndex(vis, viewingTipPrNumber, viewingHeadRef, all);
|
|
79
|
+
setCursor(hi >= 0 ? hi : 0);
|
|
80
|
+
}, [pickerSection, viewingTipPrNumber, viewingHeadRef]);
|
|
81
|
+
|
|
82
|
+
const visibleRef = useRef(visible);
|
|
83
|
+
visibleRef.current = visible;
|
|
84
|
+
|
|
85
|
+
const safe = visible.length ? Math.min(cursor, visible.length - 1) : 0;
|
|
86
|
+
const safeRef = useRef(safe);
|
|
87
|
+
safeRef.current = safe;
|
|
88
|
+
|
|
89
|
+
const viewingHighlightIdx = useMemo(
|
|
90
|
+
() => pickViewingHighlightIndex(visible, viewingTipPrNumber, viewingHeadRef, stacks),
|
|
91
|
+
[visible, viewingTipPrNumber, viewingHeadRef, stacks]
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const sectionLabel =
|
|
95
|
+
pickerSection === "open"
|
|
96
|
+
? chalk.green("Open stacks")
|
|
97
|
+
: chalk.dim("Merged / closed");
|
|
98
|
+
const backHint =
|
|
99
|
+
onRequestBack && escapeToRepo
|
|
100
|
+
? " · Esc/Backspace/q: repo list"
|
|
101
|
+
: onRequestBack
|
|
102
|
+
? " · Backspace: back · Esc/q: cancel"
|
|
103
|
+
: " · Esc/q: cancel";
|
|
104
|
+
const hintLine = `${sectionLabel}${chalk.reset("")} · Tab · j/k · 1-9 · Enter${backHint}`;
|
|
105
|
+
|
|
106
|
+
useInput((input, key) => {
|
|
107
|
+
const backKey = key.backspace || key.delete || input === "\x7f" || input === "\b";
|
|
108
|
+
if (backKey && onRequestBack) {
|
|
109
|
+
onRequestBack();
|
|
110
|
+
exit();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (input === "q" || key.escape) {
|
|
114
|
+
if (escapeToRepo && onRequestBack) {
|
|
115
|
+
onRequestBack();
|
|
116
|
+
} else {
|
|
117
|
+
onPick(null);
|
|
118
|
+
}
|
|
119
|
+
exit();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (key.tab) {
|
|
123
|
+
setPickerSection((sec) => {
|
|
124
|
+
if (sec === "open") return closedRows.length > 0 ? "closed" : "open";
|
|
125
|
+
return openRows.length > 0 ? "open" : "closed";
|
|
126
|
+
});
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (input === "j" || key.downArrow) {
|
|
130
|
+
setCursor((c) => Math.min(c + 1, Math.max(0, visibleRef.current.length - 1)));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (input === "k" || key.upArrow) {
|
|
134
|
+
setCursor((c) => Math.max(c - 1, 0));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (/^[1-9]$/.test(input)) {
|
|
138
|
+
const n = Number.parseInt(input, 10) - 1;
|
|
139
|
+
if (n < visibleRef.current.length) {
|
|
140
|
+
onPick(visibleRef.current[n] ?? null);
|
|
141
|
+
exit();
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (key.return || input === " ") {
|
|
146
|
+
const vis = visibleRef.current;
|
|
147
|
+
if (!vis.length) return;
|
|
148
|
+
onPick(vis[Math.min(Math.max(0, safeRef.current), vis.length - 1)] ?? null);
|
|
149
|
+
exit();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const useSplitPane = cols >= SPLIT_MIN_COLS && visible.length > 0;
|
|
154
|
+
const paneH = Math.max(4, ttyRows - 4);
|
|
155
|
+
const listW = Math.max(20, innerW - GRAPH_W - 2);
|
|
156
|
+
|
|
157
|
+
const emptyTab =
|
|
158
|
+
visible.length === 0
|
|
159
|
+
? React.createElement(Text, { dimColor: true }, "No stacks in this tab — press Tab to switch.")
|
|
160
|
+
: null;
|
|
161
|
+
|
|
162
|
+
if (useSplitPane) {
|
|
163
|
+
const graphLines = buildSplitGraphPane(visible, safe, GRAPH_W, paneH);
|
|
164
|
+
|
|
165
|
+
return React.createElement(
|
|
166
|
+
Box,
|
|
167
|
+
{ flexDirection: "column", width: cols },
|
|
168
|
+
React.createElement(Text, { color: "cyan", bold: true }, title),
|
|
169
|
+
React.createElement(Text, { dimColor: true }, hintLine),
|
|
170
|
+
React.createElement(
|
|
171
|
+
Box,
|
|
172
|
+
{ flexDirection: "row", marginTop: 1 },
|
|
173
|
+
// Left: graph pane
|
|
174
|
+
React.createElement(
|
|
175
|
+
Box,
|
|
176
|
+
{ flexDirection: "column", width: GRAPH_W, flexShrink: 0 },
|
|
177
|
+
...graphLines.map((ln, i) => React.createElement(Text, { key: `gp-${i}` }, ln))
|
|
178
|
+
),
|
|
179
|
+
// Right: stack list with bordered cards
|
|
180
|
+
React.createElement(
|
|
181
|
+
Box,
|
|
182
|
+
{ flexDirection: "column", flexGrow: 1, width: listW },
|
|
183
|
+
emptyTab,
|
|
184
|
+
...visible.map((s, i) => buildStackCard(s, i, {
|
|
185
|
+
sel: i === safe,
|
|
186
|
+
isViewing: viewingHighlightIdx >= 0 && i === viewingHighlightIdx,
|
|
187
|
+
listW,
|
|
188
|
+
inferTag: s && (s.inferredOnly || s.inferredFromViewerDoc)
|
|
189
|
+
}))
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Narrow terminal fallback: full-width stacked cards
|
|
196
|
+
return React.createElement(
|
|
197
|
+
Box,
|
|
198
|
+
{ flexDirection: "column", padding: 1, width: cols },
|
|
199
|
+
React.createElement(Text, { color: "cyan", bold: true }, title),
|
|
200
|
+
React.createElement(Text, { dimColor: true }, hintLine),
|
|
201
|
+
emptyTab,
|
|
202
|
+
...visible.map((s, i) => buildStackCard(s, i, {
|
|
203
|
+
sel: i === safe,
|
|
204
|
+
isViewing: viewingHighlightIdx >= 0 && i === viewingHighlightIdx,
|
|
205
|
+
listW: innerW - 4,
|
|
206
|
+
inferTag: s && (s.inferredOnly || s.inferredFromViewerDoc)
|
|
207
|
+
}))
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ─── Stack card builder ───────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Build a single stack entry with optional bordered boxes.
|
|
215
|
+
*
|
|
216
|
+
* Visual states:
|
|
217
|
+
* - Cursor (hover): round yellow border (selector box)
|
|
218
|
+
* - Viewing (in viewer): round cyan border (viewing indicator box)
|
|
219
|
+
* - Both (same item): yellow outer + cyan inner — concentric inset effect
|
|
220
|
+
* - Plain (neither): no border, white text
|
|
221
|
+
*
|
|
222
|
+
* @param {object} s stack row
|
|
223
|
+
* @param {number} i index
|
|
224
|
+
* @param {{ sel: boolean, isViewing: boolean, listW: number, inferTag: any }} opts
|
|
225
|
+
*/
|
|
226
|
+
function buildStackCard(s, i, { sel, isViewing, listW }) {
|
|
227
|
+
const mark = sel ? "▶ " : " ";
|
|
228
|
+
const maxLabelW = Math.max(8, listW - 6);
|
|
229
|
+
|
|
230
|
+
const inferred = s && (s.inferredOnly || s.inferredFromViewerDoc);
|
|
231
|
+
const inferSuffix = inferred ? chalk.dim(" *") : "";
|
|
232
|
+
|
|
233
|
+
const pcLabel = `${s.pr_count ?? 1} PR${(s.pr_count ?? 1) === 1 ? "" : "s"}`;
|
|
234
|
+
const diffPart =
|
|
235
|
+
typeof s.inferDiffAdd === "number"
|
|
236
|
+
? ` ${chalk.green("+" + s.inferDiffAdd)} ${chalk.red("-" + (s.inferDiffDel ?? 0))}`
|
|
237
|
+
: "";
|
|
238
|
+
|
|
239
|
+
// Header: marker + index + tip PR number + PR count
|
|
240
|
+
const headerColor = sel ? "yellow" : isViewing ? "cyan" : "white";
|
|
241
|
+
const header = React.createElement(
|
|
242
|
+
Text,
|
|
243
|
+
{ color: headerColor, bold: sel },
|
|
244
|
+
`${mark}[${i + 1}] #${s.tip_pr_number}`,
|
|
245
|
+
inferSuffix,
|
|
246
|
+
` · ${pcLabel}`,
|
|
247
|
+
diffPart
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// Per-PR branch name lines (tip → base)
|
|
251
|
+
const prLines = Array.isArray(s.prs) && s.prs.length > 0
|
|
252
|
+
? [...s.prs].reverse()
|
|
253
|
+
: [{ pr_number: s.tip_pr_number, head_branch: s.tip_head_branch }];
|
|
254
|
+
|
|
255
|
+
const prNameNodes = prLines.map((pr, j) => {
|
|
256
|
+
const name = typeof pr.head_branch === "string" && pr.head_branch
|
|
257
|
+
? pr.head_branch
|
|
258
|
+
: `#${pr.pr_number}`;
|
|
259
|
+
const truncated = name.slice(0, maxLabelW);
|
|
260
|
+
return React.createElement(
|
|
261
|
+
Text,
|
|
262
|
+
{ key: `pr-${j}`, color: sel ? "yellow" : isViewing ? "cyan" : "white" },
|
|
263
|
+
` ${truncated}`
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Author line (dim / secondary)
|
|
268
|
+
const authorNode = s.created_by
|
|
269
|
+
? React.createElement(
|
|
270
|
+
Text,
|
|
271
|
+
{ key: "author", dimColor: true },
|
|
272
|
+
` by ${s.created_by}`
|
|
273
|
+
)
|
|
274
|
+
: null;
|
|
275
|
+
|
|
276
|
+
// Content box (no border here — borders are added as wrappers below)
|
|
277
|
+
let content = React.createElement(
|
|
278
|
+
Box,
|
|
279
|
+
{ flexDirection: "column", paddingX: 1 },
|
|
280
|
+
header,
|
|
281
|
+
...prNameNodes,
|
|
282
|
+
authorNode
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
// Apply viewing indicator (cyan) box — inner
|
|
286
|
+
if (isViewing) {
|
|
287
|
+
content = React.createElement(
|
|
288
|
+
Box,
|
|
289
|
+
{ borderStyle: "round", borderColor: "cyan", flexDirection: "column" },
|
|
290
|
+
content
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Apply cursor (selector) box — outer yellow; wraps the cyan box when both active
|
|
295
|
+
if (sel) {
|
|
296
|
+
content = React.createElement(
|
|
297
|
+
Box,
|
|
298
|
+
{ borderStyle: "round", borderColor: "yellow", flexDirection: "column" },
|
|
299
|
+
content
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return React.createElement(
|
|
304
|
+
Box,
|
|
305
|
+
{ key: `${s.tip_pr_number}-${i}`, flexDirection: "column", marginBottom: 1 },
|
|
306
|
+
content
|
|
307
|
+
);
|
|
308
|
+
}
|
|
@@ -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
|
+
}
|