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.
- package/package.json +1 -1
- package/src/api-client.js +10 -11
- 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 +114 -6
- package/src/nugit-stack.js +20 -0
- package/src/nugit-start.js +4 -4
- package/src/nugit.js +37 -22
- 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 +166 -0
- package/src/review-hub/run-review-hub.js +188 -0
- package/src/split-view/run-split.js +16 -3
- package/src/split-view/split-ink.js +2 -2
- package/src/stack-discover.js +9 -1
- 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 +70 -0
- package/src/stack-view/ink-app.js +1853 -156
- package/src/stack-view/loading-ink.js +44 -0
- package/src/stack-view/merge-alternate-pick-stacks.js +223 -0
- package/src/stack-view/patch-preview-merge.js +108 -0
- package/src/stack-view/remote-infer-doc.js +93 -0
- package/src/stack-view/repo-picker-back.js +10 -0
- package/src/stack-view/run-stack-view.js +685 -50
- package/src/stack-view/run-view-entry.js +119 -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 +270 -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/terminal-fullscreen.js +45 -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
|
@@ -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
|
+
}
|