gflows 0.1.17 → 1.0.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/AGENTS.md +78 -0
- package/CHANGELOG.md +40 -0
- package/README.md +279 -502
- package/llms.txt +22 -0
- package/package.json +30 -23
- package/src/cli.ts +21 -364
- package/src/commands/abort.ts +39 -0
- package/src/commands/bump.ts +10 -10
- package/src/commands/completion.ts +14 -4
- package/src/commands/config.ts +123 -0
- package/src/commands/continue.ts +40 -0
- package/src/commands/delete.ts +4 -4
- package/src/commands/doctor.ts +143 -0
- package/src/commands/finish.ts +363 -137
- package/src/commands/help.ts +29 -20
- package/src/commands/init.ts +99 -18
- package/src/commands/list.ts +6 -1
- package/src/commands/mcp.ts +238 -0
- package/src/commands/pr.ts +120 -0
- package/src/commands/schema.ts +98 -0
- package/src/commands/start.ts +33 -31
- package/src/commands/status.ts +70 -51
- package/src/commands/switch.ts +50 -20
- package/src/commands/sync.ts +160 -0
- package/src/commands/undo.ts +78 -0
- package/src/commands/version.ts +3 -15
- package/src/commands/viz.ts +18 -0
- package/src/dispatch.ts +21 -0
- package/src/errors.ts +55 -12
- package/src/flow.ts +157 -0
- package/src/git.ts +135 -8
- package/src/index.ts +24 -1
- package/src/interactive.ts +209 -0
- package/src/out.ts +11 -4
- package/src/package-scripts.ts +73 -0
- package/src/parse.ts +430 -0
- package/src/prompts.ts +89 -0
- package/src/recommend.ts +132 -0
- package/src/run-state.ts +165 -0
- package/src/tui/HubHome.tsx +343 -0
- package/src/tui/HubShell.tsx +186 -0
- package/src/tui/flows.tsx +140 -0
- package/src/tui/hub.ts +89 -0
- package/src/tui/prompts.tsx +207 -0
- package/src/types.ts +63 -4
- package/src/ui.ts +132 -0
- package/src/version.ts +24 -0
- package/src/viz.ts +294 -0
package/src/run-state.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persist multi-step gflows operations so continue / undo / abort can recover.
|
|
3
|
+
* State lives under .git/gflows/ (gitignored by nature of .git).
|
|
4
|
+
* @module run-state
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
/** One planned step in a multi-step operation. */
|
|
11
|
+
export interface RunStep {
|
|
12
|
+
/** Step id for resume (e.g. merge-main, tag, merge-dev, delete, push). */
|
|
13
|
+
id: string;
|
|
14
|
+
/** Human description. */
|
|
15
|
+
label: string;
|
|
16
|
+
/** Opaque payload for the step executor. */
|
|
17
|
+
data?: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Suspended or completed run record. */
|
|
21
|
+
export interface GflowsRunState {
|
|
22
|
+
/** Command that created this run (finish, sync, …). */
|
|
23
|
+
command: string;
|
|
24
|
+
/** ISO timestamp when started. */
|
|
25
|
+
startedAt: string;
|
|
26
|
+
/** Status of the run. */
|
|
27
|
+
status: "running" | "suspended" | "completed";
|
|
28
|
+
/** Index of next step to execute. */
|
|
29
|
+
nextStep: number;
|
|
30
|
+
/** Planned steps. */
|
|
31
|
+
steps: RunStep[];
|
|
32
|
+
/** Snapshot for undo (branch tips, tag created, previous HEAD, etc.). */
|
|
33
|
+
undo: Record<string, unknown>;
|
|
34
|
+
/** Extra context (branch, type, config names, flags). */
|
|
35
|
+
context: Record<string, unknown>;
|
|
36
|
+
/** Last error message when suspended. */
|
|
37
|
+
lastError?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const STATE_DIR = "gflows";
|
|
41
|
+
const ACTIVE_FILE = "run.json";
|
|
42
|
+
const LAST_FILE = "last.json";
|
|
43
|
+
|
|
44
|
+
function gitGflowsDir(repoRoot: string): string {
|
|
45
|
+
return join(repoRoot, ".git", STATE_DIR);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function activePath(repoRoot: string): string {
|
|
49
|
+
return join(gitGflowsDir(repoRoot), ACTIVE_FILE);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function lastPath(repoRoot: string): string {
|
|
53
|
+
return join(gitGflowsDir(repoRoot), LAST_FILE);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ensureDir(repoRoot: string): void {
|
|
57
|
+
const dir = gitGflowsDir(repoRoot);
|
|
58
|
+
if (!existsSync(dir)) {
|
|
59
|
+
mkdirSync(dir, { recursive: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Reads the active (suspended/running) run state, or null.
|
|
65
|
+
*/
|
|
66
|
+
export function readActiveRun(repoRoot: string): GflowsRunState | null {
|
|
67
|
+
const path = activePath(repoRoot);
|
|
68
|
+
if (!existsSync(path)) return null;
|
|
69
|
+
try {
|
|
70
|
+
const raw = readFileSync(path, "utf-8");
|
|
71
|
+
return JSON.parse(raw) as GflowsRunState;
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Reads the last completed run (for undo), or null.
|
|
79
|
+
*/
|
|
80
|
+
export function readLastRun(repoRoot: string): GflowsRunState | null {
|
|
81
|
+
const path = lastPath(repoRoot);
|
|
82
|
+
if (!existsSync(path)) return null;
|
|
83
|
+
try {
|
|
84
|
+
const raw = readFileSync(path, "utf-8");
|
|
85
|
+
return JSON.parse(raw) as GflowsRunState;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Writes active run state.
|
|
93
|
+
*/
|
|
94
|
+
export function writeActiveRun(repoRoot: string, state: GflowsRunState): void {
|
|
95
|
+
ensureDir(repoRoot);
|
|
96
|
+
writeFileSync(activePath(repoRoot), `${JSON.stringify(state, null, 2)}\n`, "utf-8");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Clears active run state (after abort or successful complete move to last).
|
|
101
|
+
*/
|
|
102
|
+
export function clearActiveRun(repoRoot: string): void {
|
|
103
|
+
const path = activePath(repoRoot);
|
|
104
|
+
if (existsSync(path)) {
|
|
105
|
+
unlinkSync(path);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Marks run completed and moves it to last.json for undo; clears active.
|
|
111
|
+
*/
|
|
112
|
+
export function completeRun(repoRoot: string, state: GflowsRunState): void {
|
|
113
|
+
ensureDir(repoRoot);
|
|
114
|
+
const done: GflowsRunState = {
|
|
115
|
+
...state,
|
|
116
|
+
status: "completed",
|
|
117
|
+
nextStep: state.steps.length,
|
|
118
|
+
};
|
|
119
|
+
writeFileSync(lastPath(repoRoot), `${JSON.stringify(done, null, 2)}\n`, "utf-8");
|
|
120
|
+
clearActiveRun(repoRoot);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Clears last completed run (after undo).
|
|
125
|
+
*/
|
|
126
|
+
export function clearLastRun(repoRoot: string): void {
|
|
127
|
+
const path = lastPath(repoRoot);
|
|
128
|
+
if (existsSync(path)) {
|
|
129
|
+
unlinkSync(path);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Creates a new running state and persists it.
|
|
135
|
+
*/
|
|
136
|
+
export function startRun(
|
|
137
|
+
repoRoot: string,
|
|
138
|
+
command: string,
|
|
139
|
+
steps: RunStep[],
|
|
140
|
+
context: Record<string, unknown>,
|
|
141
|
+
undo: Record<string, unknown>,
|
|
142
|
+
): GflowsRunState {
|
|
143
|
+
const state: GflowsRunState = {
|
|
144
|
+
command,
|
|
145
|
+
startedAt: new Date().toISOString(),
|
|
146
|
+
status: "running",
|
|
147
|
+
nextStep: 0,
|
|
148
|
+
steps,
|
|
149
|
+
undo,
|
|
150
|
+
context,
|
|
151
|
+
};
|
|
152
|
+
writeActiveRun(repoRoot, state);
|
|
153
|
+
return state;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Suspends the active run after a failure (e.g. merge conflict).
|
|
158
|
+
*/
|
|
159
|
+
export function suspendRun(repoRoot: string, state: GflowsRunState, lastError: string): void {
|
|
160
|
+
writeActiveRun(repoRoot, {
|
|
161
|
+
...state,
|
|
162
|
+
status: "suspended",
|
|
163
|
+
lastError,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub home screen — status map, actions, slash prompt (Ink).
|
|
3
|
+
* @module tui/HubHome
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { Box, Text, useInput, useStdout } from "ink";
|
|
8
|
+
import type React from "react";
|
|
9
|
+
import { useEffect, useMemo, useState } from "react";
|
|
10
|
+
import { getCurrentBranch, resolveRepoRoot } from "../git.js";
|
|
11
|
+
import { type RecommendAction, recommend } from "../recommend.js";
|
|
12
|
+
import { getVersion } from "../version.js";
|
|
13
|
+
import { collectVizSnapshot, type VizSnapshot } from "../viz.js";
|
|
14
|
+
|
|
15
|
+
const ACCENT = "#E88C4A";
|
|
16
|
+
const MUTED = "#8A8A8A";
|
|
17
|
+
const FG = "#E6E6E6";
|
|
18
|
+
const GREEN = "#78C88C";
|
|
19
|
+
const YELLOW = "#DCB450";
|
|
20
|
+
|
|
21
|
+
type ActionItem = { id: string; label: string; hint?: string };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Props for the hub home view.
|
|
25
|
+
*/
|
|
26
|
+
export interface HubHomeProps {
|
|
27
|
+
cwd: string;
|
|
28
|
+
flash?: string;
|
|
29
|
+
onAction: (id: string) => void;
|
|
30
|
+
onSlash: (command: string) => void;
|
|
31
|
+
onQuit: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Main hub dashboard.
|
|
36
|
+
*/
|
|
37
|
+
export function HubHome({
|
|
38
|
+
cwd,
|
|
39
|
+
flash,
|
|
40
|
+
onAction,
|
|
41
|
+
onSlash,
|
|
42
|
+
onQuit,
|
|
43
|
+
}: HubHomeProps): React.ReactElement {
|
|
44
|
+
const { stdout } = useStdout();
|
|
45
|
+
const cols = stdout?.columns ?? 80;
|
|
46
|
+
const rows = stdout?.rows ?? 24;
|
|
47
|
+
|
|
48
|
+
const [snap, setSnap] = useState<VizSnapshot | null>(null);
|
|
49
|
+
const [branch, setBranch] = useState("—");
|
|
50
|
+
const [repoPath, setRepoPath] = useState(cwd);
|
|
51
|
+
const [selected, setSelected] = useState(0);
|
|
52
|
+
const [input, setInput] = useState("");
|
|
53
|
+
const [showHelp, setShowHelp] = useState(false);
|
|
54
|
+
const [loading, setLoading] = useState(true);
|
|
55
|
+
const version = useMemo(() => getVersion(), []);
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
let cancelled = false;
|
|
59
|
+
void (async () => {
|
|
60
|
+
try {
|
|
61
|
+
const root = await resolveRepoRoot(cwd);
|
|
62
|
+
const b = (await getCurrentBranch(root, {})) ?? "detached";
|
|
63
|
+
const s = await collectVizSnapshot(cwd);
|
|
64
|
+
if (!cancelled) {
|
|
65
|
+
setRepoPath(root);
|
|
66
|
+
setBranch(b);
|
|
67
|
+
setSnap(s);
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
if (!cancelled) {
|
|
71
|
+
setSnap(null);
|
|
72
|
+
setBranch("—");
|
|
73
|
+
}
|
|
74
|
+
} finally {
|
|
75
|
+
if (!cancelled) setLoading(false);
|
|
76
|
+
}
|
|
77
|
+
})();
|
|
78
|
+
return () => {
|
|
79
|
+
cancelled = true;
|
|
80
|
+
};
|
|
81
|
+
}, [cwd]);
|
|
82
|
+
|
|
83
|
+
const rec = snap ? recommend(snap) : null;
|
|
84
|
+
const actions = useMemo(() => buildActions(snap, rec?.action ?? null), [snap, rec?.action]);
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (selected >= actions.length) setSelected(Math.max(0, actions.length - 1));
|
|
88
|
+
}, [actions.length, selected]);
|
|
89
|
+
|
|
90
|
+
useInput((ch, key) => {
|
|
91
|
+
if (key.ctrl && ch === "c") {
|
|
92
|
+
onQuit();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (key.escape) {
|
|
96
|
+
if (input || showHelp) {
|
|
97
|
+
setInput("");
|
|
98
|
+
setShowHelp(false);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
onQuit();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (ch === "?" && input === "") {
|
|
105
|
+
setShowHelp((v) => !v);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (ch === "q" && input === "") {
|
|
109
|
+
onQuit();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (key.upArrow) {
|
|
113
|
+
setSelected((i) => Math.max(0, i - 1));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (key.downArrow) {
|
|
117
|
+
setSelected((i) => Math.min(actions.length - 1, i + 1));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (key.backspace || key.delete) {
|
|
121
|
+
setInput((s) => [...s].slice(0, -1).join(""));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (key.return) {
|
|
125
|
+
const cmd = input.trim();
|
|
126
|
+
if (cmd.startsWith("/")) {
|
|
127
|
+
setInput("");
|
|
128
|
+
onSlash(cmd);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const action = actions[selected];
|
|
132
|
+
if (!action || action.id === "quit") {
|
|
133
|
+
onQuit();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
onAction(action.id);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (ch && !key.ctrl && !key.meta && ch >= " ") {
|
|
140
|
+
setInput((s) => s + ch);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const pathShort = shortPath(repoPath);
|
|
145
|
+
const tips = tipLines(snap);
|
|
146
|
+
const next = nextLines(rec, snap);
|
|
147
|
+
const branches = branchLines(snap);
|
|
148
|
+
const listBudget = Math.max(3, Math.min(8, rows - 18));
|
|
149
|
+
const start = Math.max(0, Math.min(selected - listBudget + 2, actions.length - listBudget));
|
|
150
|
+
const visible = actions.slice(start, start + listBudget);
|
|
151
|
+
const frameWidth = Math.max(40, cols - 2);
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<Box flexDirection="column" width={cols} height={rows}>
|
|
155
|
+
<Box
|
|
156
|
+
flexDirection="column"
|
|
157
|
+
borderStyle="round"
|
|
158
|
+
borderColor={ACCENT}
|
|
159
|
+
width={frameWidth}
|
|
160
|
+
paddingX={1}
|
|
161
|
+
>
|
|
162
|
+
<Text bold color={FG}>
|
|
163
|
+
Welcome to gflows{" "}
|
|
164
|
+
<Text color={MUTED} dimColor>
|
|
165
|
+
v{version}
|
|
166
|
+
</Text>
|
|
167
|
+
</Text>
|
|
168
|
+
<Text color={MUTED}>Git branching workflow · main + dev + short-lived branches</Text>
|
|
169
|
+
<Text color={MUTED}>{pathShort}</Text>
|
|
170
|
+
|
|
171
|
+
{flash ? (
|
|
172
|
+
<Box marginTop={1}>
|
|
173
|
+
<Text color={YELLOW}>│ {flash}</Text>
|
|
174
|
+
</Box>
|
|
175
|
+
) : null}
|
|
176
|
+
|
|
177
|
+
<Box marginTop={1} flexDirection="row">
|
|
178
|
+
<Box width="50%" flexDirection="column" paddingRight={1}>
|
|
179
|
+
<Text bold color={ACCENT}>
|
|
180
|
+
Tips for getting started
|
|
181
|
+
</Text>
|
|
182
|
+
{tips.map((line) => (
|
|
183
|
+
<Text key={line} color={MUTED}>
|
|
184
|
+
{line}
|
|
185
|
+
</Text>
|
|
186
|
+
))}
|
|
187
|
+
</Box>
|
|
188
|
+
<Box width="50%" flexDirection="column">
|
|
189
|
+
<Text bold color={ACCENT}>
|
|
190
|
+
What's next
|
|
191
|
+
</Text>
|
|
192
|
+
{next.map((line) => (
|
|
193
|
+
<Text
|
|
194
|
+
key={line}
|
|
195
|
+
color={line.startsWith("→") ? GREEN : MUTED}
|
|
196
|
+
bold={line.startsWith("→")}
|
|
197
|
+
>
|
|
198
|
+
{line}
|
|
199
|
+
</Text>
|
|
200
|
+
))}
|
|
201
|
+
</Box>
|
|
202
|
+
</Box>
|
|
203
|
+
|
|
204
|
+
<Box marginY={1} flexDirection="column">
|
|
205
|
+
<Text bold color={FG}>
|
|
206
|
+
Branches
|
|
207
|
+
</Text>
|
|
208
|
+
{loading ? (
|
|
209
|
+
<Text color={MUTED}>Loading…</Text>
|
|
210
|
+
) : (
|
|
211
|
+
branches.slice(0, 7).map((line) => (
|
|
212
|
+
<Text key={line} color={MUTED}>
|
|
213
|
+
{line}
|
|
214
|
+
</Text>
|
|
215
|
+
))
|
|
216
|
+
)}
|
|
217
|
+
</Box>
|
|
218
|
+
|
|
219
|
+
<Text color={MUTED}>Actions · ↑↓ select · enter · /command (wizards stay in-app)</Text>
|
|
220
|
+
{visible.map((a, i) => {
|
|
221
|
+
const abs = start + i;
|
|
222
|
+
const active = abs === selected;
|
|
223
|
+
return (
|
|
224
|
+
<Text key={a.id} color={active ? FG : MUTED} bold={active}>
|
|
225
|
+
{active ? <Text color={ACCENT}>❯ </Text> : " "}
|
|
226
|
+
{a.label}
|
|
227
|
+
{a.hint ? (
|
|
228
|
+
<Text color={MUTED}>
|
|
229
|
+
{" "}
|
|
230
|
+
{a.hint}
|
|
231
|
+
</Text>
|
|
232
|
+
) : null}
|
|
233
|
+
</Text>
|
|
234
|
+
);
|
|
235
|
+
})}
|
|
236
|
+
|
|
237
|
+
<Box marginTop={1} flexDirection="column">
|
|
238
|
+
<Text>
|
|
239
|
+
<Text color={ACCENT}>❯ </Text>
|
|
240
|
+
{input || <Text color={MUTED}>type /start /finish /sync …</Text>}
|
|
241
|
+
{input ? <Text color={ACCENT}>█</Text> : null}
|
|
242
|
+
</Text>
|
|
243
|
+
<Text color={MUTED}>
|
|
244
|
+
{showHelp
|
|
245
|
+
? "/init /start /sync /pr /finish /doctor /help · esc clear · ctrl+c quit"
|
|
246
|
+
: "? for shortcuts"}
|
|
247
|
+
</Text>
|
|
248
|
+
</Box>
|
|
249
|
+
</Box>
|
|
250
|
+
|
|
251
|
+
<Box width={cols} justifyContent="space-between">
|
|
252
|
+
<Text backgroundColor="#282828" color={MUTED}>
|
|
253
|
+
{" "}
|
|
254
|
+
<Text color={ACCENT}>/init</Text> start sync pr finish{" "}
|
|
255
|
+
</Text>
|
|
256
|
+
<Text backgroundColor="#282828" color={MUTED}>
|
|
257
|
+
{" "}
|
|
258
|
+
{pathShort} <Text color={ACCENT}></Text> <Text color={FG}>{branch}</Text>{" "}
|
|
259
|
+
</Text>
|
|
260
|
+
</Box>
|
|
261
|
+
{rec?.action === "init" ? <Text color={YELLOW}> Setup incomplete — run /init</Text> : null}
|
|
262
|
+
</Box>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function shortPath(p: string): string {
|
|
267
|
+
const home = homedir();
|
|
268
|
+
if (p.startsWith(home)) return `~${p.slice(home.length)}`;
|
|
269
|
+
return p;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function tipLines(snap: VizSnapshot | null): string[] {
|
|
273
|
+
if (!snap || snap.needsInit) {
|
|
274
|
+
return [
|
|
275
|
+
"1. /init or select Initialize",
|
|
276
|
+
"2. /start (wizard in hub)",
|
|
277
|
+
"3. commit → /sync · /pr · /finish",
|
|
278
|
+
];
|
|
279
|
+
}
|
|
280
|
+
return [
|
|
281
|
+
"• /start — wizard stays in this UI",
|
|
282
|
+
"• /sync — update from base",
|
|
283
|
+
"• /pr then /finish when ready",
|
|
284
|
+
];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function nextLines(rec: ReturnType<typeof recommend> | null, snap: VizSnapshot | null): string[] {
|
|
288
|
+
if (!rec) return ["Not a git repo", "cd into a repo, then reopen"];
|
|
289
|
+
return [`→ ${rec.label}`, rec.detail, snap?.current ? `on ${snap.current}` : ""].filter(Boolean);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function branchLines(snap: VizSnapshot | null): string[] {
|
|
293
|
+
if (!snap) return ["(no repo)"];
|
|
294
|
+
const lines: string[] = [];
|
|
295
|
+
if (snap.suspended) lines.push(`⚠ suspended: ${snap.suspended}`);
|
|
296
|
+
for (const row of snap.rows) {
|
|
297
|
+
if (row.type !== "main" && row.type !== "dev") continue;
|
|
298
|
+
const mark = row.current ? "●" : "○";
|
|
299
|
+
lines.push(`${mark} ${row.name} [${row.type}]`);
|
|
300
|
+
}
|
|
301
|
+
const workflow = snap.rows.filter((r) => r.type !== "main" && r.type !== "dev");
|
|
302
|
+
if (workflow.length === 0) {
|
|
303
|
+
lines.push(" (no workflow branches)");
|
|
304
|
+
} else {
|
|
305
|
+
for (let i = 0; i < workflow.length; i++) {
|
|
306
|
+
const row = workflow[i];
|
|
307
|
+
if (!row) continue;
|
|
308
|
+
const elbow = i === workflow.length - 1 ? "└─" : "├─";
|
|
309
|
+
const mark = row.current ? "●" : "○";
|
|
310
|
+
const ab = row.ahead === 0 && row.behind === 0 ? "synced" : `+${row.ahead}/-${row.behind}`;
|
|
311
|
+
lines.push(`${elbow} ${mark} ${row.name} (${row.type}) ${ab}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return lines;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function buildActions(snap: VizSnapshot | null, recommended: RecommendAction | null): ActionItem[] {
|
|
318
|
+
const items: ActionItem[] = [];
|
|
319
|
+
const push = (id: string, label: string, hint?: string) => {
|
|
320
|
+
items.push({ id, label, hint });
|
|
321
|
+
};
|
|
322
|
+
if (snap?.needsInit) push("init", "Initialize repo", "/init");
|
|
323
|
+
push("start", "Start new work", "/start");
|
|
324
|
+
push("sync", "Sync with base", "/sync");
|
|
325
|
+
push("pr", "Open pull request", "/pr");
|
|
326
|
+
push("finish", "Finish / merge branch", "/finish");
|
|
327
|
+
if (snap?.suspended) push("continue", "Continue suspended", "/continue");
|
|
328
|
+
push("switch", "Switch branch", "/switch");
|
|
329
|
+
push("list", "List branches", "/list");
|
|
330
|
+
push("doctor", "Doctor", "/doctor");
|
|
331
|
+
push("help", "Help", "/help");
|
|
332
|
+
push("quit", "Quit", "q");
|
|
333
|
+
|
|
334
|
+
const prefer = recommended && recommended !== "commit" ? recommended : null;
|
|
335
|
+
if (prefer) {
|
|
336
|
+
const idx = items.findIndex((a) => a.id === prefer);
|
|
337
|
+
if (idx >= 0) {
|
|
338
|
+
const [item] = items.splice(idx, 1);
|
|
339
|
+
if (item) items.unshift({ ...item, label: `★ ${item.label.replace(/^★ /, "")}` });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return items;
|
|
343
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent Ink hub shell: home map + in-app wizards (no Clack drop-out for prompts).
|
|
3
|
+
* @module tui/HubShell
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
7
|
+
import type React from "react";
|
|
8
|
+
import { useState } from "react";
|
|
9
|
+
import { FinishFlow, StartFlow, SyncFlow } from "./flows.js";
|
|
10
|
+
import { HubHome } from "./HubHome.js";
|
|
11
|
+
import { WizardFrame } from "./prompts.js";
|
|
12
|
+
|
|
13
|
+
const ACCENT = "#E88C4A";
|
|
14
|
+
const MUTED = "#8A8A8A";
|
|
15
|
+
const FG = "#E6E6E6";
|
|
16
|
+
|
|
17
|
+
/** Outcome of one hub session (Ink unmounts after this). */
|
|
18
|
+
export type HubSessionResult = { kind: "quit" } | { kind: "run"; argv: string[] };
|
|
19
|
+
|
|
20
|
+
type Screen =
|
|
21
|
+
| { id: "home"; flash?: string }
|
|
22
|
+
| { id: "start" }
|
|
23
|
+
| { id: "finish" }
|
|
24
|
+
| { id: "sync" }
|
|
25
|
+
| { id: "notice"; message: string };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Props for the hub shell.
|
|
29
|
+
*/
|
|
30
|
+
export interface HubShellProps {
|
|
31
|
+
cwd: string;
|
|
32
|
+
onDone: (result: HubSessionResult) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Fullscreen hub with embedded wizards.
|
|
37
|
+
*/
|
|
38
|
+
export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
|
|
39
|
+
const { exit } = useApp();
|
|
40
|
+
const [screen, setScreen] = useState<Screen>({ id: "home" });
|
|
41
|
+
|
|
42
|
+
const finish = (result: HubSessionResult) => {
|
|
43
|
+
onDone(result);
|
|
44
|
+
exit();
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const cancelWizard = () => setScreen({ id: "home" });
|
|
48
|
+
const runArgv = (argv: string[]) => finish({ kind: "run", argv });
|
|
49
|
+
|
|
50
|
+
const handleSlash = (raw: string) => {
|
|
51
|
+
const parts = raw.slice(1).trim().split(/\s+/);
|
|
52
|
+
const cmd = (parts[0] ?? "").toLowerCase();
|
|
53
|
+
const rest = parts.slice(1);
|
|
54
|
+
|
|
55
|
+
if (!cmd || cmd === "quit" || cmd === "exit" || cmd === "q") {
|
|
56
|
+
finish({ kind: "quit" });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (cmd === "start") {
|
|
61
|
+
if (rest.length >= 2) {
|
|
62
|
+
const type = rest[0] ?? "feature";
|
|
63
|
+
const name = rest[1] ?? "";
|
|
64
|
+
runArgv(["start", type, name, "-P", ...rest.slice(2)]);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
setScreen({ id: "start" });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (cmd === "finish") {
|
|
71
|
+
if (rest.length > 0) {
|
|
72
|
+
runArgv(["finish", "-y", "-P", ...rest]);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
setScreen({ id: "finish" });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (cmd === "sync") {
|
|
79
|
+
if (rest.length > 0) {
|
|
80
|
+
runArgv(["sync", "--force", ...rest]);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
setScreen({ id: "sync" });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (cmd === "viz") {
|
|
87
|
+
setScreen({ id: "home", flash: "Branch map is shown on this screen." });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const known = new Set([
|
|
92
|
+
"init",
|
|
93
|
+
"pr",
|
|
94
|
+
"continue",
|
|
95
|
+
"switch",
|
|
96
|
+
"list",
|
|
97
|
+
"doctor",
|
|
98
|
+
"help",
|
|
99
|
+
"config",
|
|
100
|
+
"bump",
|
|
101
|
+
"status",
|
|
102
|
+
]);
|
|
103
|
+
if (!known.has(cmd)) {
|
|
104
|
+
setScreen({
|
|
105
|
+
id: "notice",
|
|
106
|
+
message: `Unknown /${cmd} — press ? on the home screen for shortcuts.`,
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
runArgv([cmd, ...rest]);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const handleHome = (action: string) => {
|
|
115
|
+
if (action === "quit") {
|
|
116
|
+
finish({ kind: "quit" });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (action === "start") {
|
|
120
|
+
setScreen({ id: "start" });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (action === "finish") {
|
|
124
|
+
setScreen({ id: "finish" });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (action === "sync") {
|
|
128
|
+
setScreen({ id: "sync" });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
runArgv([action]);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
if (screen.id === "start") {
|
|
135
|
+
return <StartFlow onCancel={cancelWizard} onDone={runArgv} />;
|
|
136
|
+
}
|
|
137
|
+
if (screen.id === "finish") {
|
|
138
|
+
return <FinishFlow onCancel={cancelWizard} onDone={runArgv} />;
|
|
139
|
+
}
|
|
140
|
+
if (screen.id === "sync") {
|
|
141
|
+
return <SyncFlow onCancel={cancelWizard} onDone={runArgv} />;
|
|
142
|
+
}
|
|
143
|
+
if (screen.id === "notice") {
|
|
144
|
+
return (
|
|
145
|
+
<PressEnter
|
|
146
|
+
title="Notice"
|
|
147
|
+
message={screen.message}
|
|
148
|
+
onDone={() => setScreen({ id: "home" })}
|
|
149
|
+
/>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<HubHome
|
|
155
|
+
cwd={cwd}
|
|
156
|
+
flash={screen.flash}
|
|
157
|
+
onAction={handleHome}
|
|
158
|
+
onSlash={handleSlash}
|
|
159
|
+
onQuit={() => finish({ kind: "quit" })}
|
|
160
|
+
/>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function PressEnter({
|
|
165
|
+
title,
|
|
166
|
+
message,
|
|
167
|
+
onDone,
|
|
168
|
+
}: {
|
|
169
|
+
title: string;
|
|
170
|
+
message: string;
|
|
171
|
+
onDone: () => void;
|
|
172
|
+
}): React.ReactElement {
|
|
173
|
+
useInput((_ch, key) => {
|
|
174
|
+
if (key.return || key.escape) onDone();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<WizardFrame title={title}>
|
|
179
|
+
<Text color={FG}>{message}</Text>
|
|
180
|
+
<Box marginTop={1}>
|
|
181
|
+
<Text color={MUTED}>Press enter to continue</Text>
|
|
182
|
+
<Text color={ACCENT}> █</Text>
|
|
183
|
+
</Box>
|
|
184
|
+
</WizardFrame>
|
|
185
|
+
);
|
|
186
|
+
}
|