nugit-cli 0.0.1 → 0.1.0

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 (39) hide show
  1. package/package.json +1 -1
  2. package/src/api-client.js +10 -11
  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 +114 -6
  7. package/src/nugit-stack.js +20 -0
  8. package/src/nugit-start.js +4 -4
  9. package/src/nugit.js +37 -22
  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 +166 -0
  13. package/src/review-hub/run-review-hub.js +188 -0
  14. package/src/split-view/run-split.js +16 -3
  15. package/src/split-view/split-ink.js +2 -2
  16. package/src/stack-discover.js +9 -1
  17. package/src/stack-infer-from-prs.js +71 -0
  18. package/src/stack-view/diff-line-map.js +62 -0
  19. package/src/stack-view/fetch-pr-data.js +104 -4
  20. package/src/stack-view/infer-chains-to-pick-stacks.js +70 -0
  21. package/src/stack-view/ink-app.js +1853 -156
  22. package/src/stack-view/loading-ink.js +44 -0
  23. package/src/stack-view/merge-alternate-pick-stacks.js +223 -0
  24. package/src/stack-view/patch-preview-merge.js +108 -0
  25. package/src/stack-view/remote-infer-doc.js +93 -0
  26. package/src/stack-view/repo-picker-back.js +10 -0
  27. package/src/stack-view/run-stack-view.js +685 -50
  28. package/src/stack-view/run-view-entry.js +119 -0
  29. package/src/stack-view/sgr-mouse.js +56 -0
  30. package/src/stack-view/stack-branch-graph.js +95 -0
  31. package/src/stack-view/stack-pick-graph.js +93 -0
  32. package/src/stack-view/stack-pick-ink.js +270 -0
  33. package/src/stack-view/stack-pick-layout.js +19 -0
  34. package/src/stack-view/stack-pick-sort.js +188 -0
  35. package/src/stack-view/terminal-fullscreen.js +45 -0
  36. package/src/stack-view/tree-ascii.js +73 -0
  37. package/src/stack-view/view-md-plain.js +23 -0
  38. package/src/stack-view/view-repo-picker-ink.js +293 -0
  39. package/src/stack-view/view-tui-sequential.js +126 -0
@@ -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,45 @@
1
+ /**
2
+ * Alternate screen buffer for a cleaner fullscreen TUI.
3
+ * Disable with NUGIT_NO_FULLSCREEN=1 if your terminal misbehaves.
4
+ */
5
+
6
+ /**
7
+ * @returns {boolean}
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
+ }
@@ -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
+ }