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.
- package/package.json +1 -1
- package/src/api-client.js +0 -12
- package/src/github-rest.js +35 -0
- package/src/nugit-config.js +84 -0
- package/src/nugit-stack.js +29 -266
- package/src/nugit.js +103 -661
- package/src/review-hub/review-hub-ink.js +6 -3
- package/src/review-hub/run-review-hub.js +34 -91
- package/src/services/repo-branches.js +151 -0
- package/src/services/stack-inference.js +90 -0
- package/src/split-view/run-split.js +14 -89
- package/src/stack-view/infer-chains-to-pick-stacks.js +10 -0
- package/src/stack-view/ink-app.js +3 -2118
- package/src/stack-view/loader.js +19 -93
- package/src/stack-view/loading-ink.js +2 -44
- package/src/stack-view/merge-alternate-pick-stacks.js +23 -1
- package/src/stack-view/remote-infer-doc.js +28 -45
- package/src/stack-view/run-stack-view.js +249 -526
- package/src/stack-view/run-view-entry.js +14 -18
- package/src/stack-view/stack-pick-ink.js +169 -131
- package/src/stack-view/stack-picker-graph-pane.js +118 -0
- package/src/stack-view/terminal-fullscreen.js +7 -45
- 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 -292
- 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
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { findGitRoot, stackJsonPath } from "../nugit-stack.js";
|
|
1
|
+
import { findGitRoot } from "../nugit-stack.js";
|
|
3
2
|
import { runStackViewCommand } from "./run-stack-view.js";
|
|
4
3
|
import { runRepoPickerFlow } from "./view-repo-picker-ink.js";
|
|
5
4
|
import { RepoPickerBackError } from "./repo-picker-back.js";
|
|
@@ -25,7 +24,7 @@ async function withViewFullscreen(noTui, fn) {
|
|
|
25
24
|
}
|
|
26
25
|
|
|
27
26
|
/**
|
|
28
|
-
* Resolve CLI args and open the stack viewer (
|
|
27
|
+
* Resolve CLI args and open the stack viewer (remote repo by coords, repo picker, or current dir inference).
|
|
29
28
|
* @param {string | undefined} repoPos
|
|
30
29
|
* @param {string | undefined} refPos
|
|
31
30
|
* @param {{ noTui?: boolean, repo?: string, ref?: string, file?: string, reviewAutoapply?: boolean }} opts
|
|
@@ -56,32 +55,33 @@ export async function runNugitViewEntry(repoPos, refPos, opts) {
|
|
|
56
55
|
return;
|
|
57
56
|
}
|
|
58
57
|
|
|
58
|
+
// Non-TTY with no explicit repo: infer from git remote
|
|
59
59
|
const root = findGitRoot();
|
|
60
|
-
|
|
60
|
+
let inferredRepo = null;
|
|
61
|
+
if (root) {
|
|
62
|
+
try { inferredRepo = getRepoFullNameFromGitRoot(root); } catch { inferredRepo = null; }
|
|
63
|
+
}
|
|
64
|
+
if (inferredRepo) {
|
|
61
65
|
await withViewFullscreen(!!opts.noTui, () =>
|
|
62
|
-
runStackViewCommand({ noTui: opts.noTui, reviewAutoapply: opts.reviewAutoapply })
|
|
66
|
+
runStackViewCommand({ repo: inferredRepo, noTui: opts.noTui, reviewAutoapply: opts.reviewAutoapply })
|
|
63
67
|
);
|
|
64
68
|
return;
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
throw new Error(
|
|
68
|
-
"nugit view: pass owner/repo and optional ref,
|
|
72
|
+
"nugit view: pass owner/repo and optional ref, or run inside a git clone with a github.com remote. " +
|
|
69
73
|
"Sign in with `nugit auth login` or set NUGIT_USER_TOKEN when GitHub returns 401."
|
|
70
74
|
);
|
|
71
75
|
}
|
|
72
76
|
|
|
73
|
-
|
|
77
|
+
// TTY: use the repo picker flow (or jump straight to current dir)
|
|
74
78
|
let autoRepo = null;
|
|
75
79
|
if (explicitRepo) {
|
|
76
80
|
autoRepo = explicitRepo;
|
|
77
81
|
} else {
|
|
78
82
|
const root = findGitRoot();
|
|
79
83
|
if (root) {
|
|
80
|
-
try {
|
|
81
|
-
autoRepo = getRepoFullNameFromGitRoot(root);
|
|
82
|
-
} catch {
|
|
83
|
-
autoRepo = null;
|
|
84
|
-
}
|
|
84
|
+
try { autoRepo = getRepoFullNameFromGitRoot(root); } catch { autoRepo = null; }
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
@@ -93,9 +93,7 @@ export async function runNugitViewEntry(repoPos, refPos, opts) {
|
|
|
93
93
|
useRepo = autoRepo;
|
|
94
94
|
} else {
|
|
95
95
|
const picked = await runRepoPickerFlow();
|
|
96
|
-
if (!picked)
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
96
|
+
if (!picked) return;
|
|
99
97
|
useRepo = picked.repo;
|
|
100
98
|
}
|
|
101
99
|
firstRun = false;
|
|
@@ -109,9 +107,7 @@ export async function runNugitViewEntry(repoPos, refPos, opts) {
|
|
|
109
107
|
});
|
|
110
108
|
break;
|
|
111
109
|
} catch (e) {
|
|
112
|
-
if (e instanceof RepoPickerBackError)
|
|
113
|
-
continue;
|
|
114
|
-
}
|
|
110
|
+
if (e instanceof RepoPickerBackError) continue;
|
|
115
111
|
throw e;
|
|
116
112
|
}
|
|
117
113
|
}
|
|
@@ -2,21 +2,30 @@ import React, { useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
|
2
2
|
import { Box, Text, useApp, useInput, useStdout } from "ink";
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import { buildPickerVisibleStacks, pickViewingHighlightIndex } from "./stack-pick-sort.js";
|
|
5
|
-
import {
|
|
6
|
-
import { buildStackBranchGraphLines, discoveryPrsToGraphRows } from "./stack-branch-graph.js";
|
|
5
|
+
import { buildSplitGraphPane } from "./stack-picker-graph-pane.js";
|
|
7
6
|
import { stackPickTerminalLayout } from "./stack-pick-layout.js";
|
|
8
7
|
|
|
9
|
-
|
|
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
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
10
19
|
|
|
11
20
|
/**
|
|
12
21
|
* @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
|
|
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
|
|
14
23
|
* @param {(stack: (typeof props.stacks)[0] | null) => void} props.onPick
|
|
15
24
|
* @param {string} [props.title]
|
|
16
|
-
* @param {() => void} [props.onRequestBack]
|
|
17
|
-
* @param {boolean} [props.escapeToRepo]
|
|
18
|
-
* @param {number | null | undefined} [props.viewingTipPrNumber]
|
|
19
|
-
* @param {string | null | undefined} [props.viewingHeadRef]
|
|
25
|
+
* @param {() => void} [props.onRequestBack]
|
|
26
|
+
* @param {boolean} [props.escapeToRepo]
|
|
27
|
+
* @param {number | null | undefined} [props.viewingTipPrNumber]
|
|
28
|
+
* @param {string | null | undefined} [props.viewingHeadRef]
|
|
20
29
|
*/
|
|
21
30
|
export function StackPickInk({
|
|
22
31
|
stacks,
|
|
@@ -30,6 +39,7 @@ export function StackPickInk({
|
|
|
30
39
|
const { exit } = useApp();
|
|
31
40
|
const { stdout } = useStdout();
|
|
32
41
|
const { cols, innerW } = stackPickTerminalLayout(stdout?.columns);
|
|
42
|
+
const ttyRows = stdout?.rows ?? 24;
|
|
33
43
|
|
|
34
44
|
const openRows = useMemo(
|
|
35
45
|
() => stacks.filter((s) => s && typeof s === "object" && s.picker_merged_stack !== true),
|
|
@@ -67,7 +77,6 @@ export function StackPickInk({
|
|
|
67
77
|
const vis = buildPickerVisibleStacks(act, { viewingTipPrNumber, viewingHeadRef });
|
|
68
78
|
const hi = pickViewingHighlightIndex(vis, viewingTipPrNumber, viewingHeadRef, all);
|
|
69
79
|
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
80
|
}, [pickerSection, viewingTipPrNumber, viewingHeadRef]);
|
|
72
81
|
|
|
73
82
|
const visibleRef = useRef(visible);
|
|
@@ -85,14 +94,14 @@ export function StackPickInk({
|
|
|
85
94
|
const sectionLabel =
|
|
86
95
|
pickerSection === "open"
|
|
87
96
|
? chalk.green("Open stacks")
|
|
88
|
-
: chalk.
|
|
97
|
+
: chalk.dim("Merged / closed");
|
|
89
98
|
const backHint =
|
|
90
99
|
onRequestBack && escapeToRepo
|
|
91
100
|
? " · Esc/Backspace/q: repo list"
|
|
92
101
|
: onRequestBack
|
|
93
|
-
? " · Backspace:
|
|
102
|
+
? " · Backspace: back · Esc/q: cancel"
|
|
94
103
|
: " · Esc/q: cancel";
|
|
95
|
-
const hintLine = `${sectionLabel}${chalk.reset("")} · Tab
|
|
104
|
+
const hintLine = `${sectionLabel}${chalk.reset("")} · Tab · j/k · 1-9 · Enter${backHint}`;
|
|
96
105
|
|
|
97
106
|
useInput((input, key) => {
|
|
98
107
|
const backKey = key.backspace || key.delete || input === "\x7f" || input === "\b";
|
|
@@ -112,19 +121,13 @@ export function StackPickInk({
|
|
|
112
121
|
}
|
|
113
122
|
if (key.tab) {
|
|
114
123
|
setPickerSection((sec) => {
|
|
115
|
-
if (sec === "open")
|
|
116
|
-
return closedRows.length > 0 ? "closed" : "open";
|
|
117
|
-
}
|
|
124
|
+
if (sec === "open") return closedRows.length > 0 ? "closed" : "open";
|
|
118
125
|
return openRows.length > 0 ? "open" : "closed";
|
|
119
126
|
});
|
|
120
127
|
return;
|
|
121
128
|
}
|
|
122
129
|
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
|
-
});
|
|
130
|
+
setCursor((c) => Math.min(c + 1, Math.max(0, visibleRef.current.length - 1)));
|
|
128
131
|
return;
|
|
129
132
|
}
|
|
130
133
|
if (input === "k" || key.upArrow) {
|
|
@@ -133,138 +136,173 @@ export function StackPickInk({
|
|
|
133
136
|
}
|
|
134
137
|
if (/^[1-9]$/.test(input)) {
|
|
135
138
|
const n = Number.parseInt(input, 10) - 1;
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
onPick(vis[n] ?? null);
|
|
139
|
+
if (n < visibleRef.current.length) {
|
|
140
|
+
onPick(visibleRef.current[n] ?? null);
|
|
139
141
|
exit();
|
|
140
142
|
}
|
|
141
143
|
return;
|
|
142
144
|
}
|
|
143
145
|
if (key.return || input === " ") {
|
|
144
146
|
const vis = visibleRef.current;
|
|
145
|
-
if (vis.length
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
const i = Math.min(Math.max(0, safeRef.current), vis.length - 1);
|
|
149
|
-
onPick(vis[i] ?? null);
|
|
147
|
+
if (!vis.length) return;
|
|
148
|
+
onPick(vis[Math.min(Math.max(0, safeRef.current), vis.length - 1)] ?? null);
|
|
150
149
|
exit();
|
|
151
150
|
}
|
|
152
151
|
});
|
|
153
152
|
|
|
154
|
-
const
|
|
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);
|
|
155
156
|
|
|
156
157
|
const emptyTab =
|
|
157
158
|
visible.length === 0
|
|
158
|
-
? React.createElement(
|
|
159
|
-
Text,
|
|
160
|
-
{ color: "gray", dimColor: true },
|
|
161
|
-
"No stacks in this tab — press Tab to switch."
|
|
162
|
-
)
|
|
159
|
+
? React.createElement(Text, { dimColor: true }, "No stacks in this tab — press Tab to switch.")
|
|
163
160
|
: null;
|
|
164
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
|
|
165
196
|
return React.createElement(
|
|
166
197
|
Box,
|
|
167
198
|
{ flexDirection: "column", padding: 1, width: cols },
|
|
168
199
|
React.createElement(Text, { color: "cyan", bold: true }, title),
|
|
169
|
-
React.createElement(Text, {
|
|
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
|
-
),
|
|
200
|
+
React.createElement(Text, { dimColor: true }, hintLine),
|
|
175
201
|
emptyTab,
|
|
176
|
-
...visible.map((s, i) => {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
}
|
|
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
|
+
}
|
|
242
210
|
|
|
243
|
-
|
|
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
|
-
}
|
|
211
|
+
// ─── Stack card builder ───────────────────────────────────────────────────────
|
|
258
212
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
|
269
307
|
);
|
|
270
308
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build ASCII railroad graph lines for the split-pane stack picker left column.
|
|
5
|
+
*
|
|
6
|
+
* Structure (tip-first per stack, dots connected by explicit vertical lines):
|
|
7
|
+
*
|
|
8
|
+
* main ← base_ref label
|
|
9
|
+
* │
|
|
10
|
+
* ├─● feat/tip ← fork row: tip PR (selected: yellow, others: dim)
|
|
11
|
+
* │ │ ← connecting line between consecutive PR nodes
|
|
12
|
+
* │ ● feat/base ← cont row: bottom PR
|
|
13
|
+
* │
|
|
14
|
+
* ├─◯ feat/b-tip ← another stack (dim)
|
|
15
|
+
* └─◯ feat/c ← last stack (dim)
|
|
16
|
+
*
|
|
17
|
+
* The branch `│` connector at col 3 runs through all PR nodes of the same
|
|
18
|
+
* stack so dots appear visually linked. The trunk `│` at col 1 connects
|
|
19
|
+
* stacks back to the base branch.
|
|
20
|
+
*
|
|
21
|
+
* @param {Array<{
|
|
22
|
+
* tip_pr_number: number,
|
|
23
|
+
* tip_head_branch?: string,
|
|
24
|
+
* pr_count?: number,
|
|
25
|
+
* base_ref?: string,
|
|
26
|
+
* prs?: Array<{ pr_number: number, head_branch?: string, title?: string }>
|
|
27
|
+
* }>} stacks
|
|
28
|
+
* @param {number} selectedIndex cursor (0-based into stacks)
|
|
29
|
+
* @param {number} paneW total character width of the left column
|
|
30
|
+
* @param {number} paneH total character height (rows) available
|
|
31
|
+
* @returns {string[]} exactly paneH lines
|
|
32
|
+
*/
|
|
33
|
+
export function buildSplitGraphPane(stacks, selectedIndex, paneW, paneH) {
|
|
34
|
+
if (!stacks.length) {
|
|
35
|
+
return Array.from({ length: paneH }, () => "");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const n = stacks.length;
|
|
39
|
+
const baseRef = (stacks[0]?.base_ref || "main").slice(0, paneW - 2);
|
|
40
|
+
|
|
41
|
+
/** @type {string[]} */
|
|
42
|
+
const rows = [];
|
|
43
|
+
|
|
44
|
+
// Header: base branch label + initial trunk
|
|
45
|
+
rows.push(chalk.dim.gray(` ${baseRef}`));
|
|
46
|
+
rows.push(chalk.dim.gray(" │"));
|
|
47
|
+
|
|
48
|
+
// Available rows for all stacks (min 3 per stack for fork+conn+node)
|
|
49
|
+
const available = Math.max(n * 3, paneH - 2);
|
|
50
|
+
const rowsPerStack = Math.max(3, Math.floor(available / n));
|
|
51
|
+
|
|
52
|
+
const labelW = Math.max(4, paneW - 6);
|
|
53
|
+
|
|
54
|
+
for (let si = 0; si < n; si++) {
|
|
55
|
+
const stack = stacks[si];
|
|
56
|
+
const sel = si === selectedIndex;
|
|
57
|
+
const isLast = si === n - 1;
|
|
58
|
+
|
|
59
|
+
// PRs ordered tip-first (tip = highest = top of branch in graph)
|
|
60
|
+
const rawPrs = Array.isArray(stack.prs) && stack.prs.length > 0
|
|
61
|
+
? [...stack.prs].reverse()
|
|
62
|
+
: [{ pr_number: stack.tip_pr_number, head_branch: stack.tip_head_branch }];
|
|
63
|
+
|
|
64
|
+
const forkPrefix = isLast ? " └─" : " ├─"; // 3 chars; node lands at col 3
|
|
65
|
+
const trunkCont = isLast ? " " : " │ "; // 3 chars; keeps trunk + branch lane aligned
|
|
66
|
+
|
|
67
|
+
// Color helpers
|
|
68
|
+
const nodeChar = sel ? chalk.yellow("●") : chalk.dim.gray("○");
|
|
69
|
+
const connChar = sel ? chalk.yellow("│") : chalk.dim.gray("│");
|
|
70
|
+
const forkCol = sel ? chalk.yellow : chalk.dim.gray;
|
|
71
|
+
const labelCol = sel ? chalk.yellowBright : chalk.dim.gray;
|
|
72
|
+
|
|
73
|
+
/** @param {number} idx index into reversed prs array */
|
|
74
|
+
const getLabel = (idx) => {
|
|
75
|
+
const pr = rawPrs[idx];
|
|
76
|
+
if (!pr) return "";
|
|
77
|
+
const name = typeof pr.head_branch === "string" && pr.head_branch
|
|
78
|
+
? pr.head_branch
|
|
79
|
+
: `#${pr.pr_number}`;
|
|
80
|
+
return name.slice(0, labelW);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
let pushed = 0; // rows pushed for this stack
|
|
84
|
+
|
|
85
|
+
// Fork row (tip PR)
|
|
86
|
+
if (rows.length < paneH) {
|
|
87
|
+
rows.push(forkCol(forkPrefix) + nodeChar + " " + labelCol(getLabel(0)));
|
|
88
|
+
pushed++;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Additional PR nodes, each preceded by an explicit connecting line
|
|
92
|
+
let prIdx = 1;
|
|
93
|
+
while (prIdx < rawPrs.length && pushed < rowsPerStack - 1 && rows.length < paneH) {
|
|
94
|
+
// Connecting vertical line between previous node and this one
|
|
95
|
+
if (pushed < rowsPerStack - 1 && rows.length < paneH) {
|
|
96
|
+
rows.push(chalk.dim.gray(trunkCont) + connChar);
|
|
97
|
+
pushed++;
|
|
98
|
+
}
|
|
99
|
+
// PR node
|
|
100
|
+
if (pushed < rowsPerStack && rows.length < paneH) {
|
|
101
|
+
const nd = sel ? chalk.yellow("●") : chalk.dim.gray("○");
|
|
102
|
+
rows.push(chalk.dim.gray(trunkCont) + nd + " " + labelCol(getLabel(prIdx)));
|
|
103
|
+
pushed++;
|
|
104
|
+
prIdx++;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Padding rows: trunk continues between stacks; last stack pads with empty
|
|
109
|
+
while (pushed < rowsPerStack && rows.length < paneH) {
|
|
110
|
+
rows.push(isLast ? "" : chalk.dim.gray(" │"));
|
|
111
|
+
pushed++;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Fill remaining rows to exactly paneH
|
|
116
|
+
while (rows.length < paneH) rows.push("");
|
|
117
|
+
return rows.slice(0, paneH);
|
|
118
|
+
}
|
|
@@ -1,45 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
*/
|
|
9
|
-
export function isAlternateScreenDisabled() {
|
|
10
|
-
const v = process.env.NUGIT_NO_FULLSCREEN;
|
|
11
|
-
return v === "1" || v === "true";
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* @param {import('node:stream').Writable | undefined} stdout
|
|
16
|
-
*/
|
|
17
|
-
export function enterAlternateScreen(stdout) {
|
|
18
|
-
if (!stdout?.isTTY || isAlternateScreenDisabled()) {
|
|
19
|
-
return;
|
|
20
|
-
}
|
|
21
|
-
stdout.write("\x1b[?1049h\x1b[2J\x1b[H");
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* @param {import('node:stream').Writable | undefined} stdout
|
|
26
|
-
*/
|
|
27
|
-
export function leaveAlternateScreen(stdout) {
|
|
28
|
-
if (!stdout?.isTTY || isAlternateScreenDisabled()) {
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
stdout.write("\x1b[?1049l");
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Erase and home the cursor on the TTY Ink uses (stdout). Use before mounting a new Ink tree
|
|
36
|
-
* so a previous fullscreen app does not leave ghost lines (e.g. shell footer over stack picker).
|
|
37
|
-
*
|
|
38
|
-
* @param {import('node:stream').Writable} [out] defaults to process.stdout
|
|
39
|
-
*/
|
|
40
|
-
export function clearInkScreen(out = process.stdout) {
|
|
41
|
-
if (!out?.isTTY || isAlternateScreenDisabled()) {
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
|
-
out.write("\x1b[2J\x1b[H");
|
|
45
|
-
}
|
|
1
|
+
// Re-exported from cli/src/utilities/terminal.js — import from there for new code.
|
|
2
|
+
export {
|
|
3
|
+
isAlternateScreenDisabled,
|
|
4
|
+
enterAlternateScreen,
|
|
5
|
+
leaveAlternateScreen,
|
|
6
|
+
clearInkScreen
|
|
7
|
+
} from "../utilities/terminal.js";
|