nugit-cli 0.0.1-alpha
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 +30 -0
- package/src/api-client.js +182 -0
- package/src/auth-token.js +14 -0
- package/src/cli-output.js +228 -0
- package/src/git-info.js +60 -0
- package/src/github-device-flow.js +64 -0
- package/src/github-pr-social.js +126 -0
- package/src/github-rest.js +212 -0
- package/src/nugit-stack.js +289 -0
- package/src/nugit-start.js +211 -0
- package/src/nugit.js +829 -0
- package/src/open-browser.js +21 -0
- package/src/split-view/run-split.js +181 -0
- package/src/split-view/split-git.js +88 -0
- package/src/split-view/split-ink.js +104 -0
- package/src/stack-discover.js +284 -0
- package/src/stack-discovery-config.js +91 -0
- package/src/stack-extra-commands.js +353 -0
- package/src/stack-graph.js +214 -0
- package/src/stack-helpers.js +58 -0
- package/src/stack-propagate.js +422 -0
- package/src/stack-view/fetch-pr-data.js +126 -0
- package/src/stack-view/ink-app.js +421 -0
- package/src/stack-view/loader.js +101 -0
- package/src/stack-view/open-url.js +18 -0
- package/src/stack-view/prompt-line.js +47 -0
- package/src/stack-view/run-stack-view.js +366 -0
- package/src/stack-view/static-render.js +98 -0
- package/src/token-store.js +45 -0
- package/src/user-config.js +169 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import React, { useMemo, useState } from "react";
|
|
2
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { openUrl } from "./open-url.js";
|
|
5
|
+
|
|
6
|
+
/** @returns {{ next: null | Record<string, unknown> }} */
|
|
7
|
+
export function createExitPayload() {
|
|
8
|
+
return { next: null };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {object} props
|
|
13
|
+
* @param {Awaited<ReturnType<import('./fetch-pr-data.js').fetchStackPrDetails>>} props.rows
|
|
14
|
+
* @param {{ next: null | Record<string, unknown> }} props.exitPayload
|
|
15
|
+
*/
|
|
16
|
+
export function StackInkApp({ rows, exitPayload }) {
|
|
17
|
+
const { exit } = useApp();
|
|
18
|
+
const [prIndex, setPrIndex] = useState(0);
|
|
19
|
+
const [tab, setTab] = useState(0);
|
|
20
|
+
const [lineIndex, setLineIndex] = useState(0);
|
|
21
|
+
const [fileIndex, setFileIndex] = useState(0);
|
|
22
|
+
const [filePatchOffset, setFilePatchOffset] = useState(0);
|
|
23
|
+
const [fileCommentIndex, setFileCommentIndex] = useState(0);
|
|
24
|
+
|
|
25
|
+
const len = rows?.length ?? 0;
|
|
26
|
+
const safePr = len === 0 ? 0 : Math.min(prIndex, len - 1);
|
|
27
|
+
const row = len ? rows[safePr] : null;
|
|
28
|
+
const issueList = row?.issueComments || [];
|
|
29
|
+
const reviewList = row?.reviewComments || [];
|
|
30
|
+
const fileList = row?.files || [];
|
|
31
|
+
|
|
32
|
+
const listLen = useMemo(() => {
|
|
33
|
+
if (tab === 1) {
|
|
34
|
+
return issueList.length;
|
|
35
|
+
}
|
|
36
|
+
if (tab === 2) {
|
|
37
|
+
return reviewList.length;
|
|
38
|
+
}
|
|
39
|
+
if (tab === 3) {
|
|
40
|
+
return fileList.length;
|
|
41
|
+
}
|
|
42
|
+
return 0;
|
|
43
|
+
}, [tab, issueList.length, reviewList.length, fileList.length]);
|
|
44
|
+
|
|
45
|
+
const safeLine = Math.min(lineIndex, Math.max(0, listLen - 1));
|
|
46
|
+
const safeFile = fileList.length === 0 ? 0 : Math.min(fileIndex, fileList.length - 1);
|
|
47
|
+
const selectedFile = fileList[safeFile] || null;
|
|
48
|
+
const patchLines =
|
|
49
|
+
selectedFile && typeof selectedFile.patch === "string"
|
|
50
|
+
? String(selectedFile.patch).split("\n")
|
|
51
|
+
: [];
|
|
52
|
+
const patchPageSize = 12;
|
|
53
|
+
const maxPatchOffset = Math.max(0, patchLines.length - patchPageSize);
|
|
54
|
+
const fileComments = useMemo(() => {
|
|
55
|
+
if (!selectedFile) return [];
|
|
56
|
+
const fileName = String(selectedFile.filename || "");
|
|
57
|
+
return reviewList.filter((c) => String(c?.path || "") === fileName);
|
|
58
|
+
}, [selectedFile, reviewList]);
|
|
59
|
+
const safeFileComment = fileComments.length
|
|
60
|
+
? Math.min(fileCommentIndex, fileComments.length - 1)
|
|
61
|
+
: 0;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Best-effort patch scroll target for a code line.
|
|
65
|
+
* @param {string[]} lines
|
|
66
|
+
* @param {number} lineNo
|
|
67
|
+
*/
|
|
68
|
+
const patchOffsetForLine = (lines, lineNo) => {
|
|
69
|
+
if (!lineNo || !Number.isInteger(lineNo) || lineNo < 1) return 0;
|
|
70
|
+
let newLine = 0;
|
|
71
|
+
for (let i = 0; i < lines.length; i++) {
|
|
72
|
+
const t = lines[i];
|
|
73
|
+
if (t.startsWith("@@")) {
|
|
74
|
+
const m = t.match(/\+(\d+)/);
|
|
75
|
+
if (m) newLine = Number.parseInt(m[1], 10) - 1;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (t.startsWith("+") || t.startsWith(" ")) {
|
|
79
|
+
newLine += 1;
|
|
80
|
+
}
|
|
81
|
+
if (newLine >= lineNo) {
|
|
82
|
+
return Math.max(0, i - 2);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return 0;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
useInput((input, key) => {
|
|
89
|
+
if (input === "q" || key.escape) {
|
|
90
|
+
exitPayload.next = { type: "quit" };
|
|
91
|
+
exit();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (input === "u") {
|
|
95
|
+
exitPayload.next = { type: "refresh" };
|
|
96
|
+
exit();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (key.tab) {
|
|
100
|
+
setTab((t) => (t + 1) % 4);
|
|
101
|
+
setLineIndex(0);
|
|
102
|
+
setFilePatchOffset(0);
|
|
103
|
+
setFileCommentIndex(0);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (input === "[") {
|
|
107
|
+
setTab((t) => (t + 3) % 4);
|
|
108
|
+
setLineIndex(0);
|
|
109
|
+
setFilePatchOffset(0);
|
|
110
|
+
setFileCommentIndex(0);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (input === "]") {
|
|
114
|
+
setTab((t) => (t + 1) % 4);
|
|
115
|
+
setLineIndex(0);
|
|
116
|
+
setFilePatchOffset(0);
|
|
117
|
+
setFileCommentIndex(0);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (input === "j" || key.downArrow) {
|
|
122
|
+
if (tab === 0 && len > 0) {
|
|
123
|
+
setPrIndex((i) => Math.min(i + 1, len - 1));
|
|
124
|
+
} else if (tab === 3) {
|
|
125
|
+
setFilePatchOffset((i) => Math.min(i + 1, maxPatchOffset));
|
|
126
|
+
} else {
|
|
127
|
+
setLineIndex((i) => Math.min(i + 1, Math.max(0, listLen - 1)));
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (input === "k" || key.upArrow) {
|
|
132
|
+
if (tab === 0 && len > 0) {
|
|
133
|
+
setPrIndex((i) => Math.max(i - 1, 0));
|
|
134
|
+
} else if (tab === 3) {
|
|
135
|
+
setFilePatchOffset((i) => Math.max(i - 1, 0));
|
|
136
|
+
} else {
|
|
137
|
+
setLineIndex((i) => Math.max(i - 1, 0));
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (input === "o" && row && !row.error) {
|
|
143
|
+
setTab(3);
|
|
144
|
+
setFileIndex(0);
|
|
145
|
+
setFilePatchOffset(0);
|
|
146
|
+
setFileCommentIndex(0);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (tab === 3 && input === "n") {
|
|
151
|
+
setFileIndex((i) => Math.min(i + 1, Math.max(0, fileList.length - 1)));
|
|
152
|
+
setFilePatchOffset(0);
|
|
153
|
+
setFileCommentIndex(0);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (tab === 3 && input === "p") {
|
|
157
|
+
setFileIndex((i) => Math.max(i - 1, 0));
|
|
158
|
+
setFilePatchOffset(0);
|
|
159
|
+
setFileCommentIndex(0);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (tab === 3 && input === "m" && fileComments.length) {
|
|
163
|
+
setFileCommentIndex((i) => (i + 1) % fileComments.length);
|
|
164
|
+
const c = fileComments[(safeFileComment + 1) % fileComments.length];
|
|
165
|
+
const lineNo = c?.line ?? c?.original_line ?? 0;
|
|
166
|
+
setFilePatchOffset(Math.min(maxPatchOffset, patchOffsetForLine(patchLines, Number(lineNo) || 0)));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (tab === 3 && input === "M" && fileComments.length) {
|
|
170
|
+
const next = (safeFileComment - 1 + fileComments.length) % fileComments.length;
|
|
171
|
+
setFileCommentIndex(next);
|
|
172
|
+
const c = fileComments[next];
|
|
173
|
+
const lineNo = c?.line ?? c?.original_line ?? 0;
|
|
174
|
+
setFilePatchOffset(Math.min(maxPatchOffset, patchOffsetForLine(patchLines, Number(lineNo) || 0)));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (input === "l" && tab === 2) {
|
|
179
|
+
const c = reviewList[safeLine];
|
|
180
|
+
if (c?.html_url) {
|
|
181
|
+
openUrl(c.html_url);
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (input === "g" && tab === 2 && row && !row.error) {
|
|
186
|
+
const c = reviewList[safeLine];
|
|
187
|
+
const targetPath = String(c?.path || "");
|
|
188
|
+
if (targetPath) {
|
|
189
|
+
const idx = fileList.findIndex((f) => String(f?.filename || "") === targetPath);
|
|
190
|
+
if (idx >= 0) {
|
|
191
|
+
setTab(3);
|
|
192
|
+
setFileIndex(idx);
|
|
193
|
+
const selected = fileList[idx];
|
|
194
|
+
const lines = typeof selected?.patch === "string" ? String(selected.patch).split("\n") : [];
|
|
195
|
+
const lineNo = c?.line ?? c?.original_line ?? 0;
|
|
196
|
+
const off = patchOffsetForLine(lines, Number(lineNo) || 0);
|
|
197
|
+
setFilePatchOffset(off);
|
|
198
|
+
const fcIdx = reviewList
|
|
199
|
+
.filter((rc) => String(rc?.path || "") === targetPath)
|
|
200
|
+
.findIndex((rc) => rc?.id === c?.id);
|
|
201
|
+
setFileCommentIndex(fcIdx >= 0 ? fcIdx : 0);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (input === "S" && tab === 0 && row && !row.error) {
|
|
208
|
+
exitPayload.next = {
|
|
209
|
+
type: "split",
|
|
210
|
+
prNumber: row.entry.pr_number
|
|
211
|
+
};
|
|
212
|
+
exit();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (input === "r" && row && !row.error) {
|
|
217
|
+
exitPayload.next = {
|
|
218
|
+
type: "issue_comment",
|
|
219
|
+
prNumber: row.entry.pr_number
|
|
220
|
+
};
|
|
221
|
+
exit();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (input === "R" && row && !row.error) {
|
|
226
|
+
exitPayload.next = {
|
|
227
|
+
type: "request_reviewers",
|
|
228
|
+
prNumber: row.entry.pr_number
|
|
229
|
+
};
|
|
230
|
+
exit();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (input === "t" && tab === 2 && row && !row.error) {
|
|
235
|
+
const c = reviewList[safeLine];
|
|
236
|
+
if (c?.id != null) {
|
|
237
|
+
exitPayload.next = {
|
|
238
|
+
type: "review_reply",
|
|
239
|
+
commentId: c.id
|
|
240
|
+
};
|
|
241
|
+
exit();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
if (len === 0) {
|
|
247
|
+
return React.createElement(
|
|
248
|
+
Box,
|
|
249
|
+
{ flexDirection: "column", padding: 1 },
|
|
250
|
+
React.createElement(Text, { color: "red" }, "No PRs in stack."),
|
|
251
|
+
React.createElement(Text, { dimColor: true }, "Press q to quit.")
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const ladder = rows.map((r, i) => {
|
|
256
|
+
const mark = i === safePr ? "▶" : " ";
|
|
257
|
+
const err = r.error ? ` ${r.error}` : "";
|
|
258
|
+
const title = r.pull?.title || err || "(loading)";
|
|
259
|
+
const num = r.entry.pr_number;
|
|
260
|
+
const st = r.pull?.draft ? "draft" : r.pull?.state || "?";
|
|
261
|
+
const reviewState = String(r.reviewSummary || "none");
|
|
262
|
+
const badge =
|
|
263
|
+
reviewState === "approved"
|
|
264
|
+
? chalk.green("A")
|
|
265
|
+
: reviewState === "changes_requested"
|
|
266
|
+
? chalk.red("CR")
|
|
267
|
+
: reviewState === "commented" || (r.reviewComments?.length || 0) > 0
|
|
268
|
+
? chalk.yellow("C")
|
|
269
|
+
: chalk.dim("-");
|
|
270
|
+
return `${mark} #${num} [${st}] ${badge} ${title.slice(0, 56)}`;
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const tabName = ["overview", "conversation", "review", "files"][tab];
|
|
274
|
+
let bodyLines = [];
|
|
275
|
+
if (row?.error) {
|
|
276
|
+
bodyLines = [row.error];
|
|
277
|
+
} else if (tab === 0 && row?.pull) {
|
|
278
|
+
const p = row.pull;
|
|
279
|
+
bodyLines = [
|
|
280
|
+
`Title: ${p.title || ""}`,
|
|
281
|
+
`Head: ${p.head?.ref || ""} Base: ${p.base?.ref || ""}`,
|
|
282
|
+
`Comments: ${issueList.length} conv / ${reviewList.length} review (line)`
|
|
283
|
+
];
|
|
284
|
+
const rs = String(row.reviewSummary || "none");
|
|
285
|
+
bodyLines.push(
|
|
286
|
+
`Review: ${
|
|
287
|
+
rs === "approved"
|
|
288
|
+
? chalk.green("approved")
|
|
289
|
+
: rs === "changes_requested"
|
|
290
|
+
? chalk.red("changes requested")
|
|
291
|
+
: rs === "commented"
|
|
292
|
+
? chalk.yellow("commented")
|
|
293
|
+
: chalk.dim("no review state")
|
|
294
|
+
}`
|
|
295
|
+
);
|
|
296
|
+
} else if (tab === 1) {
|
|
297
|
+
issueList.forEach((c, i) => {
|
|
298
|
+
const mark = i === safeLine ? ">" : " ";
|
|
299
|
+
const who = c.user?.login || "?";
|
|
300
|
+
const one = (c.body || "").split("\n")[0].slice(0, 70);
|
|
301
|
+
bodyLines.push(`${mark} @${who}: ${one}`);
|
|
302
|
+
});
|
|
303
|
+
if (bodyLines.length === 0) {
|
|
304
|
+
bodyLines.push("(no issue comments)");
|
|
305
|
+
}
|
|
306
|
+
} else if (tab === 2) {
|
|
307
|
+
reviewList.forEach((c, i) => {
|
|
308
|
+
const mark = i === safeLine ? ">" : " ";
|
|
309
|
+
const path = c.path || "?";
|
|
310
|
+
const ln = c.line ?? c.original_line ?? "?";
|
|
311
|
+
const one = (c.body || "").split("\n")[0].slice(0, 50);
|
|
312
|
+
bodyLines.push(`${mark} ${path}:${ln} ${one}`);
|
|
313
|
+
});
|
|
314
|
+
if (bodyLines.length === 0) {
|
|
315
|
+
bodyLines.push("(no review comments)");
|
|
316
|
+
bodyLines.push(chalk.dim("Tip: press g on a review comment to jump to file diff"));
|
|
317
|
+
}
|
|
318
|
+
} else if (tab === 3) {
|
|
319
|
+
const start = Math.max(0, safeFile - 2);
|
|
320
|
+
const end = Math.min(fileList.length, start + 5);
|
|
321
|
+
for (let i = start; i < end; i++) {
|
|
322
|
+
const f = fileList[i];
|
|
323
|
+
const mark = i === safeFile ? ">" : " ";
|
|
324
|
+
const name = String(f.filename || "?");
|
|
325
|
+
const st = String(f.status || "?");
|
|
326
|
+
const statusColor =
|
|
327
|
+
st === "added"
|
|
328
|
+
? chalk.green
|
|
329
|
+
: st === "removed"
|
|
330
|
+
? chalk.red
|
|
331
|
+
: st === "renamed"
|
|
332
|
+
? chalk.yellow
|
|
333
|
+
: chalk.cyan;
|
|
334
|
+
const ch = `${chalk.green("+" + String(f.additions ?? 0))} ${chalk.red("-" + String(f.deletions ?? 0))}`;
|
|
335
|
+
bodyLines.push(`${mark} ${statusColor(name)} ${chalk.dim("[" + st + "]")} ${ch}`);
|
|
336
|
+
}
|
|
337
|
+
if (fileList.length === 0) {
|
|
338
|
+
bodyLines.push("(no changed files)");
|
|
339
|
+
} else if (selectedFile) {
|
|
340
|
+
bodyLines.push("");
|
|
341
|
+
bodyLines.push(
|
|
342
|
+
`${chalk.bold("Patch:")} ${String(selectedFile.filename || "?")} ` +
|
|
343
|
+
chalk.dim(`(${filePatchOffset + 1}-${Math.min(filePatchOffset + patchPageSize, patchLines.length)} / ${patchLines.length || 0})`)
|
|
344
|
+
);
|
|
345
|
+
const patch = typeof selectedFile.patch === "string" ? selectedFile.patch : "";
|
|
346
|
+
if (!patch) {
|
|
347
|
+
bodyLines.push("(patch not available from GitHub API for this file)");
|
|
348
|
+
} else {
|
|
349
|
+
const page = patchLines.slice(filePatchOffset, filePatchOffset + patchPageSize);
|
|
350
|
+
for (const p of page) {
|
|
351
|
+
let line = p;
|
|
352
|
+
if (line.startsWith("+++ ") || line.startsWith("--- ")) {
|
|
353
|
+
line = chalk.bold(line);
|
|
354
|
+
} else if (line.startsWith("@@")) {
|
|
355
|
+
line = chalk.cyan(line);
|
|
356
|
+
} else if (line.startsWith("+")) {
|
|
357
|
+
line = chalk.green(line);
|
|
358
|
+
} else if (line.startsWith("-")) {
|
|
359
|
+
line = chalk.red(line);
|
|
360
|
+
} else {
|
|
361
|
+
line = chalk.dim(line);
|
|
362
|
+
}
|
|
363
|
+
bodyLines.push(line.slice(0, 130));
|
|
364
|
+
}
|
|
365
|
+
if (filePatchOffset + patchPageSize < patchLines.length) {
|
|
366
|
+
bodyLines.push(chalk.dim("... more below (j/k scroll)"));
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (fileComments.length) {
|
|
370
|
+
bodyLines.push("");
|
|
371
|
+
const c = fileComments[safeFileComment];
|
|
372
|
+
bodyLines.push(
|
|
373
|
+
`${chalk.bold("Comment")} ${safeFileComment + 1}/${fileComments.length} ` +
|
|
374
|
+
chalk.dim(
|
|
375
|
+
`line ${c?.line ?? c?.original_line ?? "?"} by @${c?.user?.login || "?"}`
|
|
376
|
+
)
|
|
377
|
+
);
|
|
378
|
+
bodyLines.push(String(c?.body || "").split("\n")[0].slice(0, 100));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const selectedReview = tab === 2 ? reviewList[safeLine] : null;
|
|
384
|
+
const helpParts = ["Tab/[] tabs", "o files view"];
|
|
385
|
+
if (tab === 0 || tab === 1 || tab === 2) {
|
|
386
|
+
helpParts.unshift("j/k PR or line");
|
|
387
|
+
}
|
|
388
|
+
if (tab === 3) {
|
|
389
|
+
helpParts.unshift("j/k scroll patch", "n/p file", "m/M comment");
|
|
390
|
+
}
|
|
391
|
+
if (row && !row.error) {
|
|
392
|
+
if (tab === 0) {
|
|
393
|
+
helpParts.push("S split PR");
|
|
394
|
+
}
|
|
395
|
+
helpParts.push("r comment", "R Assign Reviewers");
|
|
396
|
+
}
|
|
397
|
+
if (tab === 2 && selectedReview?.html_url) {
|
|
398
|
+
helpParts.push("l open line");
|
|
399
|
+
}
|
|
400
|
+
if (tab === 2 && selectedReview?.id != null && row && !row.error) {
|
|
401
|
+
helpParts.push("t reply thread", "g jump to file");
|
|
402
|
+
}
|
|
403
|
+
helpParts.push("u refresh", "q quit");
|
|
404
|
+
const help = helpParts.join(" | ");
|
|
405
|
+
|
|
406
|
+
return React.createElement(
|
|
407
|
+
Box,
|
|
408
|
+
{ flexDirection: "column", padding: 1 },
|
|
409
|
+
React.createElement(Text, { color: "cyan", bold: true }, "nugit stack view"),
|
|
410
|
+
React.createElement(Text, { dimColor: true }, ladder.join("\n")),
|
|
411
|
+
React.createElement(Text, { color: "magenta" }, `Tab: ${tabName}`),
|
|
412
|
+
React.createElement(
|
|
413
|
+
Box,
|
|
414
|
+
{ marginTop: 1, flexDirection: "column" },
|
|
415
|
+
...bodyLines.slice(0, 14).map((line, idx) =>
|
|
416
|
+
React.createElement(Text, { key: String(idx) }, line)
|
|
417
|
+
)
|
|
418
|
+
),
|
|
419
|
+
React.createElement(Text, { dimColor: true, marginTop: 1 }, help)
|
|
420
|
+
);
|
|
421
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import { decodeGithubFileContent } from "../api-client.js";
|
|
3
|
+
import { githubGetContents } from "../github-rest.js";
|
|
4
|
+
import {
|
|
5
|
+
findGitRoot,
|
|
6
|
+
parseRepoFullName,
|
|
7
|
+
readStackFile,
|
|
8
|
+
stackJsonPath,
|
|
9
|
+
validateStackDoc
|
|
10
|
+
} from "../nugit-stack.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Load stack.json from GitHub Contents API.
|
|
14
|
+
* @param {string} repoFull owner/repo
|
|
15
|
+
* @param {string} ref branch or sha
|
|
16
|
+
*/
|
|
17
|
+
export async function fetchStackDocFromGithub(repoFull, ref) {
|
|
18
|
+
const { owner, repo } = parseRepoFullName(repoFull);
|
|
19
|
+
const item = await githubGetContents(owner, repo, ".nugit/stack.json", ref);
|
|
20
|
+
const text = decodeGithubFileContent(item);
|
|
21
|
+
if (!text) {
|
|
22
|
+
throw new Error("Could not decode .nugit/stack.json from GitHub");
|
|
23
|
+
}
|
|
24
|
+
const doc = JSON.parse(text);
|
|
25
|
+
validateStackDoc(doc);
|
|
26
|
+
return doc;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* If document is a propagated prefix, fetch full stack from layer.tip.head_branch.
|
|
31
|
+
* @param {Record<string, unknown>} doc
|
|
32
|
+
*/
|
|
33
|
+
export async function expandStackDocIfPrefix(doc) {
|
|
34
|
+
const layer = doc.layer;
|
|
35
|
+
if (!layer || typeof layer !== "object" || !layer.tip || typeof layer.tip !== "object") {
|
|
36
|
+
return doc;
|
|
37
|
+
}
|
|
38
|
+
const tip = /** @type {{ head_branch?: string }} */ (layer.tip);
|
|
39
|
+
const stackSize = layer.stack_size;
|
|
40
|
+
const prs = doc.prs;
|
|
41
|
+
if (
|
|
42
|
+
typeof stackSize !== "number" ||
|
|
43
|
+
!Array.isArray(prs) ||
|
|
44
|
+
prs.length >= stackSize ||
|
|
45
|
+
typeof tip.head_branch !== "string" ||
|
|
46
|
+
!tip.head_branch.trim()
|
|
47
|
+
) {
|
|
48
|
+
return doc;
|
|
49
|
+
}
|
|
50
|
+
const { owner, repo } = parseRepoFullName(doc.repo_full_name);
|
|
51
|
+
const item = await githubGetContents(
|
|
52
|
+
owner,
|
|
53
|
+
repo,
|
|
54
|
+
".nugit/stack.json",
|
|
55
|
+
tip.head_branch.trim()
|
|
56
|
+
);
|
|
57
|
+
const text = decodeGithubFileContent(item);
|
|
58
|
+
if (!text) {
|
|
59
|
+
return doc;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const full = JSON.parse(text);
|
|
63
|
+
validateStackDoc(full);
|
|
64
|
+
return full;
|
|
65
|
+
} catch {
|
|
66
|
+
return doc;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {{ root?: string | null, repo?: string, ref?: string, file?: string }} opts
|
|
72
|
+
*/
|
|
73
|
+
export async function loadStackDocForView(opts) {
|
|
74
|
+
let doc = null;
|
|
75
|
+
const root = opts.root ?? findGitRoot();
|
|
76
|
+
|
|
77
|
+
if (opts.file) {
|
|
78
|
+
const raw = fs.readFileSync(opts.file, "utf8");
|
|
79
|
+
doc = JSON.parse(raw);
|
|
80
|
+
validateStackDoc(doc);
|
|
81
|
+
} else if (opts.repo && opts.ref) {
|
|
82
|
+
doc = await fetchStackDocFromGithub(opts.repo, opts.ref);
|
|
83
|
+
} else if (root) {
|
|
84
|
+
const p = stackJsonPath(root);
|
|
85
|
+
if (!fs.existsSync(p)) {
|
|
86
|
+
throw new Error(`No ${p}; run nugit init or pass --repo OWNER/REPO --ref BRANCH`);
|
|
87
|
+
}
|
|
88
|
+
doc = readStackFile(root);
|
|
89
|
+
if (!doc) {
|
|
90
|
+
throw new Error("Empty stack file");
|
|
91
|
+
}
|
|
92
|
+
validateStackDoc(doc);
|
|
93
|
+
} else {
|
|
94
|
+
throw new Error(
|
|
95
|
+
"Not in a git repo: pass --file path/to/stack.json or --repo owner/repo --ref branch"
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
doc = await expandStackDocIfPrefix(doc);
|
|
100
|
+
return { doc, root: root || null };
|
|
101
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {string | undefined} href
|
|
5
|
+
*/
|
|
6
|
+
export function openUrl(href) {
|
|
7
|
+
if (!href || typeof href !== "string") {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
const plat = process.platform;
|
|
11
|
+
if (plat === "darwin") {
|
|
12
|
+
spawn("open", [href], { detached: true, stdio: "ignore" }).unref();
|
|
13
|
+
} else if (plat === "win32") {
|
|
14
|
+
spawn("cmd", ["/c", "start", "", href], { detached: true, stdio: "ignore" }).unref();
|
|
15
|
+
} else {
|
|
16
|
+
spawn("xdg-open", [href], { detached: true, stdio: "ignore" }).unref();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import readline from "readline";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @param {string} prompt
|
|
6
|
+
*/
|
|
7
|
+
export function questionLine(prompt) {
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
let input = process.stdin;
|
|
10
|
+
let output = process.stdout;
|
|
11
|
+
let closeTty = null;
|
|
12
|
+
// Prefer direct TTY streams so prompts remain stable after Ink unmount/mode switches.
|
|
13
|
+
try {
|
|
14
|
+
if (process.stdin.isTTY) {
|
|
15
|
+
const inFd = fs.openSync("/dev/tty", "r");
|
|
16
|
+
const outFd = fs.openSync("/dev/tty", "w");
|
|
17
|
+
input = fs.createReadStream("", { fd: inFd, autoClose: true });
|
|
18
|
+
output = fs.createWriteStream("", { fd: outFd, autoClose: true });
|
|
19
|
+
closeTty = () => {
|
|
20
|
+
try {
|
|
21
|
+
input.destroy();
|
|
22
|
+
} catch {}
|
|
23
|
+
try {
|
|
24
|
+
output.end();
|
|
25
|
+
} catch {}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
} catch {
|
|
29
|
+
// Fallback to process stdio.
|
|
30
|
+
}
|
|
31
|
+
const rl = readline.createInterface({
|
|
32
|
+
input,
|
|
33
|
+
output,
|
|
34
|
+
terminal: true
|
|
35
|
+
});
|
|
36
|
+
if (input && typeof input.resume === "function") {
|
|
37
|
+
input.resume();
|
|
38
|
+
}
|
|
39
|
+
rl.question(prompt, (ans) => {
|
|
40
|
+
rl.close();
|
|
41
|
+
if (closeTty) {
|
|
42
|
+
closeTty();
|
|
43
|
+
}
|
|
44
|
+
resolve(ans);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|