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.
Files changed (58) hide show
  1. package/package.json +1 -1
  2. package/src/api-client.js +10 -23
  3. package/src/github-device-flow.js +1 -1
  4. package/src/github-oauth-client-id.js +11 -0
  5. package/src/github-pr-social.js +42 -0
  6. package/src/github-rest.js +149 -6
  7. package/src/nugit-config.js +84 -0
  8. package/src/nugit-stack.js +40 -257
  9. package/src/nugit.js +104 -647
  10. package/src/review-hub/review-autoapprove.js +95 -0
  11. package/src/review-hub/review-hub-back.js +10 -0
  12. package/src/review-hub/review-hub-ink.js +169 -0
  13. package/src/review-hub/run-review-hub.js +131 -0
  14. package/src/services/repo-branches.js +151 -0
  15. package/src/services/stack-inference.js +90 -0
  16. package/src/split-view/run-split.js +14 -76
  17. package/src/split-view/split-ink.js +2 -2
  18. package/src/stack-infer-from-prs.js +71 -0
  19. package/src/stack-view/diff-line-map.js +62 -0
  20. package/src/stack-view/fetch-pr-data.js +104 -4
  21. package/src/stack-view/infer-chains-to-pick-stacks.js +80 -0
  22. package/src/stack-view/ink-app.js +3 -421
  23. package/src/stack-view/loader.js +19 -93
  24. package/src/stack-view/loading-ink.js +2 -0
  25. package/src/stack-view/merge-alternate-pick-stacks.js +245 -0
  26. package/src/stack-view/patch-preview-merge.js +108 -0
  27. package/src/stack-view/remote-infer-doc.js +76 -0
  28. package/src/stack-view/repo-picker-back.js +10 -0
  29. package/src/stack-view/run-stack-view.js +508 -150
  30. package/src/stack-view/run-view-entry.js +115 -0
  31. package/src/stack-view/sgr-mouse.js +56 -0
  32. package/src/stack-view/stack-branch-graph.js +95 -0
  33. package/src/stack-view/stack-pick-graph.js +93 -0
  34. package/src/stack-view/stack-pick-ink.js +308 -0
  35. package/src/stack-view/stack-pick-layout.js +19 -0
  36. package/src/stack-view/stack-pick-sort.js +188 -0
  37. package/src/stack-view/stack-picker-graph-pane.js +118 -0
  38. package/src/stack-view/terminal-fullscreen.js +7 -0
  39. package/src/stack-view/tree-ascii.js +73 -0
  40. package/src/stack-view/view-md-plain.js +23 -0
  41. package/src/stack-view/view-repo-picker-ink.js +293 -0
  42. package/src/stack-view/view-tui-sequential.js +126 -0
  43. package/src/tui/pages/home.js +122 -0
  44. package/src/tui/pages/repo-actions.js +81 -0
  45. package/src/tui/pages/repo-branches.js +259 -0
  46. package/src/tui/pages/viewer.js +2129 -0
  47. package/src/tui/router.js +40 -0
  48. package/src/tui/run-tui.js +281 -0
  49. package/src/utilities/loading.js +37 -0
  50. package/src/utilities/terminal.js +31 -0
  51. package/src/cli-output.js +0 -228
  52. package/src/nugit-start.js +0 -211
  53. package/src/stack-discover.js +0 -284
  54. package/src/stack-discovery-config.js +0 -91
  55. package/src/stack-extra-commands.js +0 -353
  56. package/src/stack-graph.js +0 -214
  57. package/src/stack-helpers.js +0 -58
  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,7 @@
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";
@@ -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
+ }