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,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {{ tip_updated_at?: string }[]} stacks
|
|
3
|
+
* @param {number} max
|
|
4
|
+
*/
|
|
5
|
+
export function sortStacksByRecentActivity(stacks, max) {
|
|
6
|
+
const sorted = [...stacks].sort((a, b) => {
|
|
7
|
+
const ta = Date.parse(String(a.tip_updated_at || "")) || 0;
|
|
8
|
+
const tb = Date.parse(String(b.tip_updated_at || "")) || 0;
|
|
9
|
+
return tb - ta;
|
|
10
|
+
});
|
|
11
|
+
return sorted.slice(0, Math.max(1, max));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {number | null | undefined} viewingTipPrNumber
|
|
16
|
+
* @param {string | null | undefined} viewingHeadRef
|
|
17
|
+
*/
|
|
18
|
+
export function hasPickerViewingContext(viewingTipPrNumber, viewingHeadRef) {
|
|
19
|
+
if (viewingTipPrNumber != null) {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
return typeof viewingHeadRef === "string" && viewingHeadRef.trim().length > 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* True if stack row's tip branch or any PR head branch equals `href`.
|
|
27
|
+
*
|
|
28
|
+
* @param {{ tip_head_branch?: string, prs?: { head_branch?: string }[] }} s
|
|
29
|
+
* @param {string} href non-empty trimmed ref
|
|
30
|
+
*/
|
|
31
|
+
export function stackRowHeadRefMatches(s, href) {
|
|
32
|
+
if (typeof s.tip_head_branch === "string" && s.tip_head_branch.trim() === href) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
if (Array.isArray(s.prs)) {
|
|
36
|
+
return s.prs.some(
|
|
37
|
+
(p) =>
|
|
38
|
+
p &&
|
|
39
|
+
typeof p === "object" &&
|
|
40
|
+
typeof p.head_branch === "string" &&
|
|
41
|
+
p.head_branch.trim() === href
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* How many picker rows reference `href` on tip or on a PR in the chain.
|
|
49
|
+
*
|
|
50
|
+
* @param {unknown[]} stacks
|
|
51
|
+
* @param {string} href trimmed non-empty
|
|
52
|
+
*/
|
|
53
|
+
export function countStacksMatchingHeadRef(stacks, href) {
|
|
54
|
+
if (!href || !Array.isArray(stacks)) {
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
let n = 0;
|
|
58
|
+
for (const raw of stacks) {
|
|
59
|
+
if (!raw || typeof raw !== "object") {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (stackRowHeadRefMatches(/** @type {{ tip_head_branch?: string, prs?: unknown[] }} */ (raw), href)) {
|
|
63
|
+
n += 1;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return n;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Tip PR# matches row tip or appears in `prs`.
|
|
71
|
+
*
|
|
72
|
+
* @param {{ tip_pr_number: number, prs?: { pr_number?: number }[] }} s
|
|
73
|
+
* @param {number} vt
|
|
74
|
+
*/
|
|
75
|
+
function stackRowTipNumberMatches(s, vt) {
|
|
76
|
+
if (s.tip_pr_number === vt) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
if (Array.isArray(s.prs)) {
|
|
80
|
+
return s.prs.some((p) => p && typeof p === "object" && p.pr_number === vt);
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Match discovery stack row to the stack currently open in the viewer (tip PR, tip branch, or any PR in chain).
|
|
87
|
+
* Pass `allStacks` (full picker list) so a unique `viewingHeadRef` wins over a stale `viewingTipPrNumber`
|
|
88
|
+
* (avoids two "open in viewer" highlights).
|
|
89
|
+
*
|
|
90
|
+
* @param {{ tip_pr_number: number, tip_head_branch?: string, prs?: { pr_number?: number, head_branch?: string }[] }} s
|
|
91
|
+
* @param {number | null | undefined} viewingTipPrNumber
|
|
92
|
+
* @param {string | null | undefined} viewingHeadRef
|
|
93
|
+
* @param {unknown[] | null | undefined} [allStacks] full list for head-ref disambiguation
|
|
94
|
+
*/
|
|
95
|
+
/**
|
|
96
|
+
* At most one row in `visible` should show "open in viewer" when several chains share a PR# or match loosely.
|
|
97
|
+
*
|
|
98
|
+
* @param {{ tip_pr_number: number, tip_head_branch?: string, prs?: { pr_number?: number, head_branch?: string }[] }[]} visible
|
|
99
|
+
* @param {number | null | undefined} viewingTipPrNumber
|
|
100
|
+
* @param {string | null | undefined} viewingHeadRef
|
|
101
|
+
* @param {unknown[] | null | undefined} [allStacks] match context for {@link matchesPickerViewingStack}
|
|
102
|
+
* @returns {number} index in `visible`, or -1 if none
|
|
103
|
+
*/
|
|
104
|
+
export function pickViewingHighlightIndex(visible, viewingTipPrNumber, viewingHeadRef, allStacks) {
|
|
105
|
+
if (!Array.isArray(visible) || visible.length === 0) {
|
|
106
|
+
return -1;
|
|
107
|
+
}
|
|
108
|
+
const ctx = Array.isArray(allStacks) && allStacks.length > 0 ? allStacks : visible;
|
|
109
|
+
/** @type {number[]} */
|
|
110
|
+
const idxs = [];
|
|
111
|
+
for (let i = 0; i < visible.length; i++) {
|
|
112
|
+
const s = visible[i];
|
|
113
|
+
if (s && typeof s === "object" && matchesPickerViewingStack(s, viewingTipPrNumber, viewingHeadRef, ctx)) {
|
|
114
|
+
idxs.push(i);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (idxs.length === 0) {
|
|
118
|
+
return -1;
|
|
119
|
+
}
|
|
120
|
+
if (idxs.length === 1) {
|
|
121
|
+
return idxs[0];
|
|
122
|
+
}
|
|
123
|
+
const vt = viewingTipPrNumber;
|
|
124
|
+
if (vt != null) {
|
|
125
|
+
for (const i of idxs) {
|
|
126
|
+
const s = visible[i];
|
|
127
|
+
if (s && typeof s === "object" && /** @type {{ tip_pr_number?: number }} */ (s).tip_pr_number === vt) {
|
|
128
|
+
return i;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const href = typeof viewingHeadRef === "string" ? viewingHeadRef.trim() : "";
|
|
133
|
+
if (href.length > 0) {
|
|
134
|
+
for (const i of idxs) {
|
|
135
|
+
const s = visible[i];
|
|
136
|
+
if (s && typeof s === "object" && stackRowHeadRefMatches(/** @type {{ tip_head_branch?: string, prs?: unknown[] }} */ (s), href)) {
|
|
137
|
+
return i;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return idxs[0];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function matchesPickerViewingStack(s, viewingTipPrNumber, viewingHeadRef, allStacks) {
|
|
145
|
+
const href = typeof viewingHeadRef === "string" ? viewingHeadRef.trim() : "";
|
|
146
|
+
const vt = viewingTipPrNumber;
|
|
147
|
+
|
|
148
|
+
if (href.length > 0 && Array.isArray(allStacks) && allStacks.length > 0) {
|
|
149
|
+
const c = countStacksMatchingHeadRef(allStacks, href);
|
|
150
|
+
if (c === 1) {
|
|
151
|
+
return stackRowHeadRefMatches(s, href);
|
|
152
|
+
}
|
|
153
|
+
if (c === 0) {
|
|
154
|
+
if (vt == null) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
return stackRowTipNumberMatches(s, vt);
|
|
158
|
+
}
|
|
159
|
+
if (vt != null) {
|
|
160
|
+
return stackRowTipNumberMatches(s, vt) && stackRowHeadRefMatches(s, href);
|
|
161
|
+
}
|
|
162
|
+
return stackRowHeadRefMatches(s, href);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (vt != null && s.tip_pr_number === vt) {
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
if (href.length > 0 && typeof s.tip_head_branch === "string" && s.tip_head_branch.trim() === href) {
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
if (vt != null && Array.isArray(s.prs)) {
|
|
172
|
+
return s.prs.some((p) => p && typeof p === "object" && p.pr_number === vt);
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Full list when returning from viewer; top 10 on first pick.
|
|
179
|
+
*
|
|
180
|
+
* @param {{ tip_updated_at?: string }[]} stacks
|
|
181
|
+
* @param {{ viewingTipPrNumber?: number | null, viewingHeadRef?: string | null }} [opts]
|
|
182
|
+
*/
|
|
183
|
+
export function buildPickerVisibleStacks(stacks, opts) {
|
|
184
|
+
const cap = hasPickerViewingContext(opts?.viewingTipPrNumber, opts?.viewingHeadRef)
|
|
185
|
+
? Math.max(stacks.length, 1)
|
|
186
|
+
: 10;
|
|
187
|
+
return sortStacksByRecentActivity(stacks, cap);
|
|
188
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build navigable ASCII tree rows from GitHub flat git/trees entries (sorted paths).
|
|
3
|
+
* @param {{ path: string, type: string }[]} entries
|
|
4
|
+
* @returns {{ text: string, path: string | null, isFile: boolean }[]}
|
|
5
|
+
*/
|
|
6
|
+
export function buildAsciiTreeRows(entries) {
|
|
7
|
+
if (!entries.length) {
|
|
8
|
+
return [];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** @typedef {{ children: Map<string, Node>, isFile: boolean, fullPath: string }} Node */
|
|
12
|
+
/** @type {Map<string, Node>} */
|
|
13
|
+
const root = new Map();
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {Map<string, Node>} level
|
|
17
|
+
* @param {string[]} parts
|
|
18
|
+
* @param {boolean} isBlob
|
|
19
|
+
* @param {string} fullPath
|
|
20
|
+
*/
|
|
21
|
+
function ensurePath(level, parts, isBlob, fullPath) {
|
|
22
|
+
if (parts.length === 0) return;
|
|
23
|
+
const [head, ...rest] = parts;
|
|
24
|
+
if (!level.has(head)) {
|
|
25
|
+
level.set(head, { children: new Map(), isFile: false, fullPath: "" });
|
|
26
|
+
}
|
|
27
|
+
const n = /** @type {Node} */ (level.get(head));
|
|
28
|
+
if (rest.length === 0) {
|
|
29
|
+
n.isFile = isBlob;
|
|
30
|
+
n.fullPath = fullPath;
|
|
31
|
+
} else {
|
|
32
|
+
ensurePath(n.children, rest, isBlob, fullPath);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const e of entries) {
|
|
37
|
+
const parts = e.path.split("/").filter(Boolean);
|
|
38
|
+
if (parts.length === 0) continue;
|
|
39
|
+
const isBlob = e.type === "blob";
|
|
40
|
+
ensurePath(root, parts, isBlob, e.path);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @type {{ text: string, path: string | null, isFile: boolean }[]} */
|
|
44
|
+
const out = [];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {Map<string, Node>} nodeMap
|
|
48
|
+
* @param {string} prefix
|
|
49
|
+
*/
|
|
50
|
+
function walk(nodeMap, prefix) {
|
|
51
|
+
const keys = [...nodeMap.keys()].sort((a, b) => a.localeCompare(b));
|
|
52
|
+
keys.forEach((key, i) => {
|
|
53
|
+
const last = i === keys.length - 1;
|
|
54
|
+
const node = /** @type {Node} */ (nodeMap.get(key));
|
|
55
|
+
// Box-drawing: ├── / └── (tee and corner) + │ continuation
|
|
56
|
+
const branch = last ? "\u2514\u2500\u2500 " : "\u251c\u2500\u2500 ";
|
|
57
|
+
const name = node.isFile ? key : `${key}/`;
|
|
58
|
+
const line = prefix + branch + name;
|
|
59
|
+
out.push({
|
|
60
|
+
text: line,
|
|
61
|
+
path: node.isFile ? node.fullPath : null,
|
|
62
|
+
isFile: node.isFile
|
|
63
|
+
});
|
|
64
|
+
if (node.children.size > 0) {
|
|
65
|
+
const ext = last ? " " : "\u2502 ";
|
|
66
|
+
walk(node.children, prefix + ext);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
walk(root, "");
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Very small markdown → readable plain text for TUI PR bodies (no new deps).
|
|
3
|
+
* @param {string} md
|
|
4
|
+
* @returns {string}
|
|
5
|
+
*/
|
|
6
|
+
export function markdownToPlainLines(md) {
|
|
7
|
+
if (!md || typeof md !== "string") {
|
|
8
|
+
return "";
|
|
9
|
+
}
|
|
10
|
+
let s = md.replace(/\r\n/g, "\n");
|
|
11
|
+
s = s.replace(/```[\s\S]*?```/g, (block) => {
|
|
12
|
+
const inner = block.replace(/^```\w*\n?/, "").replace(/```$/, "");
|
|
13
|
+
return "\n" + inner.trim() + "\n";
|
|
14
|
+
});
|
|
15
|
+
s = s.replace(/^#{1,6}\s+/gm, "");
|
|
16
|
+
s = s.replace(/^\s*[-*+]\s+/gm, "• ");
|
|
17
|
+
s = s.replace(/`([^`]+)`/g, "$1");
|
|
18
|
+
s = s.replace(/\*\*([^*]+)\*\*/g, "$1");
|
|
19
|
+
s = s.replace(/\*([^*]+)\*/g, "$1");
|
|
20
|
+
s = s.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
|
|
21
|
+
s = s.replace(/\n{3,}/g, "\n\n");
|
|
22
|
+
return s.trim();
|
|
23
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Box, Text, useApp, useInput, render, useStdout } from "ink";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { parseSgrMouse, enableSgrMouse, disableSgrMouse, isWheelUp, isWheelDown } from "./sgr-mouse.js";
|
|
5
|
+
import { getRepoMetadata, searchRepositories } from "../api-client.js";
|
|
6
|
+
import { getRepoFullNameFromGitRoot } from "../git-info.js";
|
|
7
|
+
import { findGitRoot, parseRepoFullName } from "../nugit-stack.js";
|
|
8
|
+
import { clearInkScreen } from "./terminal-fullscreen.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {{ repo: string, ref: string }} PickedRepoRef
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Ink TUI: pick owner/repo; ref is the GitHub default branch unless you use CLI args.
|
|
16
|
+
* @returns {Promise<PickedRepoRef>}
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* @returns {Promise<PickedRepoRef | null>}
|
|
20
|
+
*/
|
|
21
|
+
export async function runRepoPickerFlow() {
|
|
22
|
+
const exitPayload = /** @type {{ result: PickedRepoRef | null }} */ ({ result: null });
|
|
23
|
+
clearInkScreen();
|
|
24
|
+
const { waitUntilExit } = render(React.createElement(ViewRepoPickerApp, { exitPayload }));
|
|
25
|
+
await waitUntilExit();
|
|
26
|
+
return exitPayload.result;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function renderViewRepoPicker() {
|
|
30
|
+
const r = await runRepoPickerFlow();
|
|
31
|
+
if (!r) {
|
|
32
|
+
throw new Error("Cancelled.");
|
|
33
|
+
}
|
|
34
|
+
return r;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {{ exitPayload: { result: PickedRepoRef | null } }} props
|
|
39
|
+
*/
|
|
40
|
+
function ViewRepoPickerApp({ exitPayload }) {
|
|
41
|
+
const { exit } = useApp();
|
|
42
|
+
const { stdout } = useStdout();
|
|
43
|
+
/** @type {React.MutableRefObject<{ firstHitRow: number, viewStart: number, windowLen: number } | null>} */
|
|
44
|
+
const resultsLayoutRef = useRef(null);
|
|
45
|
+
/** @type {React.MutableRefObject<{ t: number, idx: number } | null>} */
|
|
46
|
+
const lastResultsClickRef = useRef(null);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
enableSgrMouse(stdout);
|
|
50
|
+
return () => disableSgrMouse(stdout);
|
|
51
|
+
}, [stdout]);
|
|
52
|
+
|
|
53
|
+
const root = findGitRoot();
|
|
54
|
+
let cwdRepo = null;
|
|
55
|
+
if (root) {
|
|
56
|
+
try {
|
|
57
|
+
cwdRepo = getRepoFullNameFromGitRoot(root);
|
|
58
|
+
} catch {
|
|
59
|
+
cwdRepo = null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @type {'home' | 'search' | 'results'} */
|
|
64
|
+
const [step, setStep] = useState("home");
|
|
65
|
+
const [searchLine, setSearchLine] = useState("");
|
|
66
|
+
const [err, setErr] = useState(/** @type {string | null} */ (null));
|
|
67
|
+
const [loading, setLoading] = useState(false);
|
|
68
|
+
/** @type {{ full_name: string, description?: string }[]} */
|
|
69
|
+
const [hits, setHits] = useState([]);
|
|
70
|
+
const [cursor, setCursor] = useState(0);
|
|
71
|
+
|
|
72
|
+
const finishWith = async (repoFull) => {
|
|
73
|
+
setLoading(true);
|
|
74
|
+
setErr(null);
|
|
75
|
+
try {
|
|
76
|
+
const { owner, repo } = parseRepoFullName(repoFull);
|
|
77
|
+
const meta = await getRepoMetadata(owner, repo);
|
|
78
|
+
const ref = meta.default_branch || "main";
|
|
79
|
+
exitPayload.result = { repo: repoFull, ref };
|
|
80
|
+
exit();
|
|
81
|
+
} catch (e) {
|
|
82
|
+
setErr(String(/** @type {{ message?: string }} */ (e)?.message || e));
|
|
83
|
+
setLoading(false);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const currentHit = hits.length ? hits[Math.min(cursor, hits.length - 1)] : null;
|
|
88
|
+
|
|
89
|
+
useInput((input, key) => {
|
|
90
|
+
if (loading) return;
|
|
91
|
+
|
|
92
|
+
const mouse = parseSgrMouse(input);
|
|
93
|
+
if (mouse && step === "results" && hits.length) {
|
|
94
|
+
const L = resultsLayoutRef.current;
|
|
95
|
+
if (!L) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const { row, col, button, release } = mouse;
|
|
99
|
+
if (col < 2) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (isWheelUp(button) || isWheelDown(button)) {
|
|
103
|
+
if (isWheelDown(button)) {
|
|
104
|
+
setCursor((c) => Math.min(c + 1, Math.max(0, hits.length - 1)));
|
|
105
|
+
} else {
|
|
106
|
+
setCursor((c) => Math.max(c - 1, 0));
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (release) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const local = row - L.firstHitRow;
|
|
114
|
+
if (local >= 0 && local < L.windowLen) {
|
|
115
|
+
const i = L.viewStart + local;
|
|
116
|
+
setCursor(i);
|
|
117
|
+
const hit = hits[i];
|
|
118
|
+
if (hit) {
|
|
119
|
+
const now = Date.now();
|
|
120
|
+
const prev = lastResultsClickRef.current;
|
|
121
|
+
const dbl = prev && prev.idx === i && now - prev.t < 480;
|
|
122
|
+
lastResultsClickRef.current = { t: now, idx: i };
|
|
123
|
+
if (dbl) {
|
|
124
|
+
void finishWith(hit.full_name);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (key.escape || input === "q") {
|
|
132
|
+
exitPayload.result = null;
|
|
133
|
+
exit();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (step === "home") {
|
|
138
|
+
const homeBack =
|
|
139
|
+
key.backspace || key.delete || input === "\x7f" || input === "\b";
|
|
140
|
+
if (homeBack) {
|
|
141
|
+
exitPayload.result = null;
|
|
142
|
+
exit();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (key.return && cwdRepo) {
|
|
146
|
+
void finishWith(cwdRepo);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (input === "s" || input === "/") {
|
|
150
|
+
setStep("search");
|
|
151
|
+
setSearchLine("");
|
|
152
|
+
setErr(null);
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (step === "search") {
|
|
158
|
+
if (key.return) {
|
|
159
|
+
const q = searchLine.trim();
|
|
160
|
+
if (!q) {
|
|
161
|
+
setErr("Enter a search query (e.g. user:octocat or a project name).");
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
setLoading(true);
|
|
165
|
+
setErr(null);
|
|
166
|
+
searchRepositories(q, 15, 1)
|
|
167
|
+
.then((data) => {
|
|
168
|
+
const items = Array.isArray(data.items) ? data.items : [];
|
|
169
|
+
const mapped = items
|
|
170
|
+
.map((it) =>
|
|
171
|
+
it && typeof it === "object" && typeof it.full_name === "string"
|
|
172
|
+
? { full_name: it.full_name, description: String(it.description || "").slice(0, 72) }
|
|
173
|
+
: null
|
|
174
|
+
)
|
|
175
|
+
.filter(Boolean);
|
|
176
|
+
setHits(/** @type {{ full_name: string, description?: string }[]} */ (mapped));
|
|
177
|
+
setCursor(0);
|
|
178
|
+
setStep("results");
|
|
179
|
+
if (!mapped.length) {
|
|
180
|
+
setErr("No repositories matched.");
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
.catch((e) => {
|
|
184
|
+
setErr(String(e?.message || e));
|
|
185
|
+
setStep("search");
|
|
186
|
+
})
|
|
187
|
+
.finally(() => setLoading(false));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (key.backspace || key.delete) {
|
|
191
|
+
setSearchLine((s) => s.slice(0, -1));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (input && !key.ctrl && !key.meta) {
|
|
195
|
+
setSearchLine((s) => s + input);
|
|
196
|
+
}
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (step === "results") {
|
|
201
|
+
if (/^[1-9]$/.test(input) && hits.length) {
|
|
202
|
+
const n = Number.parseInt(input, 10) - 1;
|
|
203
|
+
if (n < hits.length) {
|
|
204
|
+
void finishWith(hits[n].full_name);
|
|
205
|
+
}
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (input === "j" || key.downArrow) {
|
|
209
|
+
setCursor((c) => Math.min(c + 1, Math.max(0, hits.length - 1)));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (input === "k" || key.upArrow) {
|
|
213
|
+
setCursor((c) => Math.max(c - 1, 0));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if ((key.return || input === " ") && currentHit) {
|
|
217
|
+
void finishWith(currentHit.full_name);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (input === "b") {
|
|
221
|
+
setStep("search");
|
|
222
|
+
setErr(null);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
if (step === "home") {
|
|
228
|
+
return React.createElement(
|
|
229
|
+
Box,
|
|
230
|
+
{ flexDirection: "column", padding: 1 },
|
|
231
|
+
React.createElement(Text, { color: "cyan", bold: true }, "nugit view — choose repository"),
|
|
232
|
+
cwdRepo
|
|
233
|
+
? React.createElement(
|
|
234
|
+
Text,
|
|
235
|
+
null,
|
|
236
|
+
chalk.whiteBright("Enter "),
|
|
237
|
+
`this directory: ${chalk.bold(cwdRepo)}`
|
|
238
|
+
)
|
|
239
|
+
: React.createElement(Text, { dimColor: true }, "(no github.com remote — pick [s] to search)"),
|
|
240
|
+
React.createElement(Text, null, chalk.gray("[s] or [/] "), "Search GitHub by user, name, or query"),
|
|
241
|
+
React.createElement(Text, { dimColor: true }, "Backspace or [q] quit"),
|
|
242
|
+
err ? React.createElement(Text, { color: "red" }, err) : null,
|
|
243
|
+
loading ? React.createElement(Text, null, chalk.yellow("Loading…")) : null
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (step === "search") {
|
|
248
|
+
return React.createElement(
|
|
249
|
+
Box,
|
|
250
|
+
{ flexDirection: "column", padding: 1 },
|
|
251
|
+
React.createElement(Text, { color: "cyan", bold: true }, "Search repositories"),
|
|
252
|
+
React.createElement(Text, { dimColor: true }, "Enter = search · Esc = cancel"),
|
|
253
|
+
React.createElement(Text, null, chalk.bold("> "), searchLine || chalk.dim("(query)")),
|
|
254
|
+
err ? React.createElement(Text, { color: "red" }, err) : null,
|
|
255
|
+
loading ? React.createElement(Text, null, chalk.yellow("Searching…")) : null
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const VIEW = 12;
|
|
260
|
+
const n = hits.length;
|
|
261
|
+
const viewStart =
|
|
262
|
+
n <= VIEW ? 0 : Math.max(0, Math.min(cursor - Math.floor(VIEW / 2), n - VIEW));
|
|
263
|
+
const windowHits = hits.slice(viewStart, viewStart + VIEW);
|
|
264
|
+
const firstHitRow = 2 + (n > VIEW ? 1 : 0);
|
|
265
|
+
resultsLayoutRef.current = { firstHitRow, viewStart, windowLen: windowHits.length };
|
|
266
|
+
|
|
267
|
+
return React.createElement(
|
|
268
|
+
Box,
|
|
269
|
+
{ flexDirection: "column", padding: 1 },
|
|
270
|
+
React.createElement(
|
|
271
|
+
Text,
|
|
272
|
+
{ color: "cyan", bold: true },
|
|
273
|
+
"Results — j/k · 1-9 open · Enter/Space · dbl-click · wheel · b back"
|
|
274
|
+
),
|
|
275
|
+
n > VIEW
|
|
276
|
+
? React.createElement(
|
|
277
|
+
Text,
|
|
278
|
+
{ dimColor: true },
|
|
279
|
+
`Showing ${viewStart + 1}–${Math.min(viewStart + VIEW, n)} of ${n}`
|
|
280
|
+
)
|
|
281
|
+
: null,
|
|
282
|
+
...windowHits.map((h, localI) => {
|
|
283
|
+
const i = viewStart + localI;
|
|
284
|
+
return React.createElement(
|
|
285
|
+
Text,
|
|
286
|
+
{ key: `${h.full_name}-${i}` },
|
|
287
|
+
`${i === cursor ? "▶ " : " "}${h.full_name} ${chalk.dim(h.description || "")}`
|
|
288
|
+
);
|
|
289
|
+
}),
|
|
290
|
+
err && !hits.length ? React.createElement(Text, { color: "yellow" }, err) : null,
|
|
291
|
+
loading ? React.createElement(Text, null, chalk.yellow("Opening…")) : null
|
|
292
|
+
);
|
|
293
|
+
}
|