gflows 1.0.1 → 1.0.2
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/CHANGELOG.md +6 -0
- package/package.json +1 -1
- package/src/commands/doctor.ts +45 -21
- package/src/commands/help.ts +11 -5
- package/src/commands/status-lines.ts +92 -0
- package/src/tui/HubShell.tsx +84 -26
- package/src/tui/flows.tsx +12 -36
- package/src/tui/panels.tsx +111 -0
- package/src/tui/slash.ts +1 -0
- package/src/tui/views.tsx +182 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.0.2] - 2026-07-25
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Hub read-only screens stay in Ink: `/doctor`, `/help`, `/status`, `/config`, `/version` (enter/esc return)
|
|
15
|
+
|
|
10
16
|
## [1.0.1] - 2026-07-25
|
|
11
17
|
|
|
12
18
|
### Added
|
package/package.json
CHANGED
package/src/commands/doctor.ts
CHANGED
|
@@ -17,34 +17,30 @@ import { hint, success } from "../out.js";
|
|
|
17
17
|
import { readActiveRun } from "../run-state.js";
|
|
18
18
|
import type { ParsedArgs } from "../types.js";
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
/** One doctor health check row. */
|
|
21
|
+
export interface DoctorCheck {
|
|
21
22
|
ok: boolean;
|
|
22
23
|
name: string;
|
|
23
24
|
detail: string;
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
/** Structured doctor report (CLI + hub). */
|
|
28
|
+
export interface DoctorReport {
|
|
29
|
+
ok: boolean;
|
|
30
|
+
checks: DoctorCheck[];
|
|
31
|
+
}
|
|
32
|
+
|
|
26
33
|
/**
|
|
27
|
-
*
|
|
34
|
+
* Collects repo health checks without printing or exiting.
|
|
28
35
|
*/
|
|
29
|
-
export async function
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
console.log(JSON.stringify({ ok: false, error: String(err) }, null, 2));
|
|
36
|
-
process.exit(2);
|
|
37
|
-
}
|
|
38
|
-
throw err;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const config = resolveConfig(
|
|
42
|
-
repoRoot,
|
|
43
|
-
{ main: args.main, dev: args.dev, remote: args.remote },
|
|
44
|
-
{ verbose: args.verbose },
|
|
45
|
-
);
|
|
36
|
+
export async function collectDoctorReport(
|
|
37
|
+
cwd: string,
|
|
38
|
+
overrides?: { main?: string; dev?: string; remote?: string },
|
|
39
|
+
): Promise<DoctorReport> {
|
|
40
|
+
const repoRoot = await resolveRepoRoot(cwd);
|
|
41
|
+
const config = resolveConfig(repoRoot, overrides ?? {}, {});
|
|
46
42
|
const cfgRead = readConfigFile(repoRoot);
|
|
47
|
-
const checks:
|
|
43
|
+
const checks: DoctorCheck[] = [];
|
|
48
44
|
|
|
49
45
|
checks.push({
|
|
50
46
|
ok: true,
|
|
@@ -121,9 +117,37 @@ export async function run(args: ParsedArgs): Promise<void> {
|
|
|
121
117
|
detail: clean ? "clean" : "dirty (uncommitted changes)",
|
|
122
118
|
});
|
|
123
119
|
|
|
124
|
-
|
|
120
|
+
return { ok: checks.every((c) => c.ok), checks };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Runs health checks and prints a report (or JSON).
|
|
125
|
+
*/
|
|
126
|
+
export async function run(args: ParsedArgs): Promise<void> {
|
|
127
|
+
let report: DoctorReport;
|
|
128
|
+
try {
|
|
129
|
+
report = await collectDoctorReport(args.cwd, {
|
|
130
|
+
main: args.main,
|
|
131
|
+
dev: args.dev,
|
|
132
|
+
remote: args.remote,
|
|
133
|
+
});
|
|
134
|
+
} catch (err) {
|
|
135
|
+
if (args.json) {
|
|
136
|
+
console.log(JSON.stringify({ ok: false, error: String(err) }, null, 2));
|
|
137
|
+
process.exit(2);
|
|
138
|
+
}
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const { ok, checks } = report;
|
|
125
143
|
|
|
126
144
|
if (args.json) {
|
|
145
|
+
const repoRoot = await resolveRepoRoot(args.cwd);
|
|
146
|
+
const config = resolveConfig(
|
|
147
|
+
repoRoot,
|
|
148
|
+
{ main: args.main, dev: args.dev, remote: args.remote },
|
|
149
|
+
{ verbose: args.verbose },
|
|
150
|
+
);
|
|
127
151
|
console.log(JSON.stringify({ ok, config, checks }, null, 2));
|
|
128
152
|
if (!ok) process.exit(2);
|
|
129
153
|
return;
|
package/src/commands/help.ts
CHANGED
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
import type { ParsedArgs } from "../types.js";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
9
|
+
* Full help text (CLI stdout + hub scroll panel).
|
|
10
10
|
*/
|
|
11
|
-
export
|
|
12
|
-
|
|
11
|
+
export function getHelpText(): string {
|
|
12
|
+
return `
|
|
13
13
|
gflows — Modern Git branching workflow CLI
|
|
14
14
|
|
|
15
15
|
Usage: gflows <command> [type] [name] [flags]
|
|
@@ -68,6 +68,12 @@ Exit codes: 0 success, 1 usage/validation, 2 Git or system error.
|
|
|
68
68
|
|
|
69
69
|
Stuck? gflows continue | gflows abort | gflows undo | gflows doctor
|
|
70
70
|
Agents: see AGENTS.md, gflows schema, gflows mcp
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
`.trim();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Runs the help command: prints usage, commands, types, flags, and exit codes to stdout.
|
|
76
|
+
*/
|
|
77
|
+
export async function run(_args: ParsedArgs): Promise<void> {
|
|
78
|
+
console.log(getHelpText());
|
|
73
79
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured status lines for CLI-adjacent hub panels.
|
|
3
|
+
* Kept separate so `status.ts` stays focused on CLI printing.
|
|
4
|
+
* @module commands/status-lines
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { resolveConfig } from "../config.js";
|
|
8
|
+
import {
|
|
9
|
+
classifyBranch,
|
|
10
|
+
formatMergeTarget,
|
|
11
|
+
getBaseBranchName,
|
|
12
|
+
resolveMergeTarget,
|
|
13
|
+
} from "../flow.js";
|
|
14
|
+
import { getAheadBehind, getCurrentBranch, resolveRepoRoot } from "../git.js";
|
|
15
|
+
import { readActiveRun } from "../run-state.js";
|
|
16
|
+
|
|
17
|
+
/** One status line with optional tone for the hub panel. */
|
|
18
|
+
export interface StatusLine {
|
|
19
|
+
text: string;
|
|
20
|
+
tone?: "default" | "ok" | "bad" | "muted" | "accent";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Collects human-readable status rows for the current branch.
|
|
25
|
+
*/
|
|
26
|
+
export async function collectStatusLines(cwd: string): Promise<StatusLine[]> {
|
|
27
|
+
const root = await resolveRepoRoot(cwd);
|
|
28
|
+
const config = resolveConfig(root, {}, {});
|
|
29
|
+
const current = await getCurrentBranch(root, {});
|
|
30
|
+
const active = readActiveRun(root);
|
|
31
|
+
const lines: StatusLine[] = [];
|
|
32
|
+
|
|
33
|
+
if (active) {
|
|
34
|
+
lines.push({
|
|
35
|
+
text: `Suspended: ${active.command} (${active.status})`,
|
|
36
|
+
tone: "bad",
|
|
37
|
+
});
|
|
38
|
+
lines.push({
|
|
39
|
+
text: "Run /continue after conflicts, or gflows abort / undo",
|
|
40
|
+
tone: "muted",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (current === null) {
|
|
45
|
+
lines.push({ text: "HEAD is detached.", tone: "bad" });
|
|
46
|
+
lines.push({ text: "Try: git checkout dev", tone: "muted" });
|
|
47
|
+
return lines;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
lines.push({ text: `Branch: ${current}`, tone: "accent" });
|
|
51
|
+
|
|
52
|
+
const classification = classifyBranch(current, config);
|
|
53
|
+
|
|
54
|
+
if (classification === "main") {
|
|
55
|
+
lines.push({ text: "Type: long-lived (main)" });
|
|
56
|
+
lines.push({ text: "Next: /start feature <name> or hotfix", tone: "muted" });
|
|
57
|
+
return lines;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (classification === "dev") {
|
|
61
|
+
lines.push({ text: "Type: long-lived (dev)" });
|
|
62
|
+
lines.push({ text: "Next: /start feature <name>", tone: "muted" });
|
|
63
|
+
return lines;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (classification === null) {
|
|
67
|
+
lines.push({ text: "Type: unknown" });
|
|
68
|
+
lines.push({ text: "Use a typed prefix or /start …", tone: "muted" });
|
|
69
|
+
return lines;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const mergeTarget = await resolveMergeTarget(root, current, classification, config, {});
|
|
73
|
+
const baseBranch = getBaseBranchName(
|
|
74
|
+
classification,
|
|
75
|
+
mergeTarget === "main-then-dev" && classification === "bugfix",
|
|
76
|
+
config,
|
|
77
|
+
);
|
|
78
|
+
const mergeTargetDisplay = formatMergeTarget(mergeTarget, config);
|
|
79
|
+
const { ahead, behind } = await getAheadBehind(root, baseBranch, current, {});
|
|
80
|
+
|
|
81
|
+
lines.push({ text: `Type: ${classification}` });
|
|
82
|
+
lines.push({ text: `Base: ${baseBranch}` });
|
|
83
|
+
lines.push({ text: `Merge target(s): ${mergeTargetDisplay}` });
|
|
84
|
+
lines.push({ text: `Ahead/behind: ${ahead} ahead, ${behind} behind` });
|
|
85
|
+
lines.push({
|
|
86
|
+
text:
|
|
87
|
+
ahead === 0 ? "No commits to finish yet — commit first" : `Next: /finish ${classification}`,
|
|
88
|
+
tone: ahead === 0 ? "muted" : "ok",
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return lines;
|
|
92
|
+
}
|
package/src/tui/HubShell.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import { FinishFlow, ListFlow, StartFlow, SyncFlow } from "./flows.js";
|
|
|
10
10
|
import { HubHome } from "./HubHome.js";
|
|
11
11
|
import { WizardFrame } from "./prompts.js";
|
|
12
12
|
import { SLASH_COMMANDS } from "./slash.js";
|
|
13
|
+
import { ConfigView, DoctorView, HelpView, StatusView, VersionView } from "./views.js";
|
|
13
14
|
|
|
14
15
|
const ACCENT = "#E88C4A";
|
|
15
16
|
const MUTED = "#8A8A8A";
|
|
@@ -24,13 +25,27 @@ type Screen =
|
|
|
24
25
|
| { id: "finish" }
|
|
25
26
|
| { id: "sync" }
|
|
26
27
|
| { id: "list" }
|
|
28
|
+
| { id: "doctor" }
|
|
29
|
+
| { id: "help" }
|
|
30
|
+
| { id: "status" }
|
|
31
|
+
| { id: "config" }
|
|
32
|
+
| { id: "version" }
|
|
27
33
|
| { id: "notice"; message: string };
|
|
28
34
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
/** Commands that leave Ink for git / side effects. */
|
|
36
|
+
const DISPATCH_COMMANDS = new Set([
|
|
37
|
+
"init",
|
|
38
|
+
"pr",
|
|
39
|
+
"continue",
|
|
40
|
+
"switch",
|
|
41
|
+
"bump",
|
|
42
|
+
"delete",
|
|
43
|
+
"undo",
|
|
44
|
+
"abort",
|
|
45
|
+
"schema",
|
|
46
|
+
"mcp",
|
|
47
|
+
"completion",
|
|
48
|
+
]);
|
|
34
49
|
|
|
35
50
|
/**
|
|
36
51
|
* Props for the hub shell.
|
|
@@ -55,6 +70,43 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
|
|
|
55
70
|
const cancelWizard = () => setScreen({ id: "home" });
|
|
56
71
|
const runArgv = (argv: string[]) => finish({ kind: "run", argv });
|
|
57
72
|
|
|
73
|
+
const openInHub = (cmd: string): boolean => {
|
|
74
|
+
switch (cmd) {
|
|
75
|
+
case "start":
|
|
76
|
+
setScreen({ id: "start" });
|
|
77
|
+
return true;
|
|
78
|
+
case "finish":
|
|
79
|
+
setScreen({ id: "finish" });
|
|
80
|
+
return true;
|
|
81
|
+
case "sync":
|
|
82
|
+
setScreen({ id: "sync" });
|
|
83
|
+
return true;
|
|
84
|
+
case "list":
|
|
85
|
+
setScreen({ id: "list" });
|
|
86
|
+
return true;
|
|
87
|
+
case "doctor":
|
|
88
|
+
setScreen({ id: "doctor" });
|
|
89
|
+
return true;
|
|
90
|
+
case "help":
|
|
91
|
+
setScreen({ id: "help" });
|
|
92
|
+
return true;
|
|
93
|
+
case "status":
|
|
94
|
+
setScreen({ id: "status" });
|
|
95
|
+
return true;
|
|
96
|
+
case "config":
|
|
97
|
+
setScreen({ id: "config" });
|
|
98
|
+
return true;
|
|
99
|
+
case "version":
|
|
100
|
+
setScreen({ id: "version" });
|
|
101
|
+
return true;
|
|
102
|
+
case "viz":
|
|
103
|
+
setScreen({ id: "home", flash: "Branch map is shown on this screen." });
|
|
104
|
+
return true;
|
|
105
|
+
default:
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
58
110
|
const handleSlash = (raw: string) => {
|
|
59
111
|
const parts = raw.slice(1).trim().split(/\s+/);
|
|
60
112
|
const cmd = (parts[0] ?? "").toLowerCase();
|
|
@@ -91,19 +143,25 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
|
|
|
91
143
|
setScreen({ id: "sync" });
|
|
92
144
|
return;
|
|
93
145
|
}
|
|
94
|
-
if (cmd === "
|
|
95
|
-
|
|
146
|
+
if (cmd === "config" && rest.length > 0) {
|
|
147
|
+
runArgv(["config", ...rest]);
|
|
96
148
|
return;
|
|
97
149
|
}
|
|
98
|
-
|
|
99
|
-
|
|
150
|
+
|
|
151
|
+
if (openInHub(cmd)) return;
|
|
152
|
+
|
|
153
|
+
if (!DISPATCH_COMMANDS.has(cmd) && !SLASH_COMMANDS.some((c) => c.name === cmd)) {
|
|
154
|
+
setScreen({
|
|
155
|
+
id: "notice",
|
|
156
|
+
message: `Unknown /${cmd} — type / for command hints.`,
|
|
157
|
+
});
|
|
100
158
|
return;
|
|
101
159
|
}
|
|
102
160
|
|
|
103
161
|
if (!DISPATCH_COMMANDS.has(cmd)) {
|
|
104
162
|
setScreen({
|
|
105
163
|
id: "notice",
|
|
106
|
-
message:
|
|
164
|
+
message: `/${cmd} is not available from the hub yet.`,
|
|
107
165
|
});
|
|
108
166
|
return;
|
|
109
167
|
}
|
|
@@ -116,22 +174,7 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
|
|
|
116
174
|
finish({ kind: "quit" });
|
|
117
175
|
return;
|
|
118
176
|
}
|
|
119
|
-
if (action
|
|
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
|
-
if (action === "list") {
|
|
132
|
-
setScreen({ id: "list" });
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
177
|
+
if (openInHub(action)) return;
|
|
135
178
|
runArgv([action]);
|
|
136
179
|
};
|
|
137
180
|
|
|
@@ -147,6 +190,21 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
|
|
|
147
190
|
if (screen.id === "list") {
|
|
148
191
|
return <ListFlow cwd={cwd} onDone={cancelWizard} />;
|
|
149
192
|
}
|
|
193
|
+
if (screen.id === "doctor") {
|
|
194
|
+
return <DoctorView cwd={cwd} onDone={cancelWizard} />;
|
|
195
|
+
}
|
|
196
|
+
if (screen.id === "help") {
|
|
197
|
+
return <HelpView onDone={cancelWizard} />;
|
|
198
|
+
}
|
|
199
|
+
if (screen.id === "status") {
|
|
200
|
+
return <StatusView cwd={cwd} onDone={cancelWizard} />;
|
|
201
|
+
}
|
|
202
|
+
if (screen.id === "config") {
|
|
203
|
+
return <ConfigView cwd={cwd} onDone={cancelWizard} />;
|
|
204
|
+
}
|
|
205
|
+
if (screen.id === "version") {
|
|
206
|
+
return <VersionView onDone={cancelWizard} />;
|
|
207
|
+
}
|
|
150
208
|
if (screen.id === "notice") {
|
|
151
209
|
return (
|
|
152
210
|
<PressEnter
|
package/src/tui/flows.tsx
CHANGED
|
@@ -3,18 +3,13 @@
|
|
|
3
3
|
* @module tui/flows
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { Box, Text, useInput } from "ink";
|
|
7
6
|
import type React from "react";
|
|
8
7
|
import { useEffect, useState } from "react";
|
|
9
8
|
import type { BranchType } from "../types.js";
|
|
10
9
|
import { collectVizSnapshot, type VizSnapshot } from "../viz.js";
|
|
10
|
+
import { type HubPanelLine, HubScrollPanel } from "./panels.js";
|
|
11
11
|
import { InkConfirm, InkSelect, InkText, WizardFrame } from "./prompts.js";
|
|
12
12
|
|
|
13
|
-
const MUTED = "#8A8A8A";
|
|
14
|
-
const FG = "#E6E6E6";
|
|
15
|
-
const GREEN = "#78C88C";
|
|
16
|
-
const ACCENT = "#E88C4A";
|
|
17
|
-
|
|
18
13
|
const BRANCH_TYPES: BranchType[] = ["feature", "bugfix", "chore", "release", "hotfix", "spike"];
|
|
19
14
|
|
|
20
15
|
/**
|
|
@@ -150,7 +145,7 @@ export function SyncFlow({
|
|
|
150
145
|
* Styled branch list inside the hub (no drop-out to plain stdout).
|
|
151
146
|
*/
|
|
152
147
|
export function ListFlow({ cwd, onDone }: { cwd: string; onDone: () => void }): React.ReactElement {
|
|
153
|
-
const [lines, setLines] = useState<
|
|
148
|
+
const [lines, setLines] = useState<HubPanelLine[] | null>(null);
|
|
154
149
|
const [error, setError] = useState<string | null>(null);
|
|
155
150
|
|
|
156
151
|
useEffect(() => {
|
|
@@ -158,7 +153,15 @@ export function ListFlow({ cwd, onDone }: { cwd: string; onDone: () => void }):
|
|
|
158
153
|
void (async () => {
|
|
159
154
|
try {
|
|
160
155
|
const snap = await collectVizSnapshot(cwd);
|
|
161
|
-
if (!cancelled)
|
|
156
|
+
if (!cancelled) {
|
|
157
|
+
setLines(
|
|
158
|
+
formatListLines(snap).map((text, i) => ({
|
|
159
|
+
id: `b${i}`,
|
|
160
|
+
text,
|
|
161
|
+
tone: text.includes("●") ? ("ok" as const) : ("default" as const),
|
|
162
|
+
})),
|
|
163
|
+
);
|
|
164
|
+
}
|
|
162
165
|
} catch (err) {
|
|
163
166
|
if (!cancelled) {
|
|
164
167
|
setError(err instanceof Error ? err.message : String(err));
|
|
@@ -171,34 +174,7 @@ export function ListFlow({ cwd, onDone }: { cwd: string; onDone: () => void }):
|
|
|
171
174
|
};
|
|
172
175
|
}, [cwd]);
|
|
173
176
|
|
|
174
|
-
|
|
175
|
-
if (key.return || key.escape || (key.ctrl && _ch === "c")) onDone();
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
return (
|
|
179
|
-
<WizardFrame title="Branches">
|
|
180
|
-
{lines === null ? (
|
|
181
|
-
<Text color={MUTED}>Loading…</Text>
|
|
182
|
-
) : error ? (
|
|
183
|
-
<Text color={FG}>{error}</Text>
|
|
184
|
-
) : (
|
|
185
|
-
<Box flexDirection="column">
|
|
186
|
-
{lines.map((line) => {
|
|
187
|
-
const current = line.includes("●");
|
|
188
|
-
return (
|
|
189
|
-
<Text key={line} color={current ? GREEN : FG} bold={current}>
|
|
190
|
-
{line}
|
|
191
|
-
</Text>
|
|
192
|
-
);
|
|
193
|
-
})}
|
|
194
|
-
</Box>
|
|
195
|
-
)}
|
|
196
|
-
<Box marginTop={1}>
|
|
197
|
-
<Text color={MUTED}>enter / esc return to hub</Text>
|
|
198
|
-
<Text color={ACCENT}> █</Text>
|
|
199
|
-
</Box>
|
|
200
|
-
</WizardFrame>
|
|
201
|
-
);
|
|
177
|
+
return <HubScrollPanel title="Branches" lines={lines} error={error} onDone={onDone} />;
|
|
202
178
|
}
|
|
203
179
|
|
|
204
180
|
/**
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Ink panels for in-hub result screens (doctor / help / status / …).
|
|
3
|
+
* @module tui/panels
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Box, Text, useInput, useStdout } from "ink";
|
|
7
|
+
import type React from "react";
|
|
8
|
+
import { useState } from "react";
|
|
9
|
+
import { WizardFrame } from "./prompts.js";
|
|
10
|
+
|
|
11
|
+
const ACCENT = "#E88C4A";
|
|
12
|
+
const MUTED = "#8A8A8A";
|
|
13
|
+
const FG = "#E6E6E6";
|
|
14
|
+
const GREEN = "#78C88C";
|
|
15
|
+
const RED = "#E06C75";
|
|
16
|
+
|
|
17
|
+
/** One styled line for {@link HubScrollPanel}. */
|
|
18
|
+
export interface HubPanelLine {
|
|
19
|
+
/** Stable React key for the row. */
|
|
20
|
+
id: string;
|
|
21
|
+
text: string;
|
|
22
|
+
tone?: "default" | "ok" | "bad" | "muted" | "accent";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Scrollable framed panel that returns to the hub on enter/esc.
|
|
27
|
+
*/
|
|
28
|
+
export function HubScrollPanel({
|
|
29
|
+
title,
|
|
30
|
+
lines,
|
|
31
|
+
loading,
|
|
32
|
+
error,
|
|
33
|
+
onDone,
|
|
34
|
+
}: {
|
|
35
|
+
title: string;
|
|
36
|
+
lines: HubPanelLine[] | null;
|
|
37
|
+
loading?: boolean;
|
|
38
|
+
error?: string | null;
|
|
39
|
+
onDone: () => void;
|
|
40
|
+
}): React.ReactElement {
|
|
41
|
+
const { stdout } = useStdout();
|
|
42
|
+
const rows = stdout?.rows ?? 24;
|
|
43
|
+
const lineCount = lines?.length ?? 0;
|
|
44
|
+
const budget = Math.max(6, Math.min(lineCount || 6, rows - 8));
|
|
45
|
+
const [offset, setOffset] = useState(0);
|
|
46
|
+
const maxOffset = Math.max(0, lineCount - budget);
|
|
47
|
+
const safeOffset = Math.min(offset, maxOffset);
|
|
48
|
+
const visible = lines?.slice(safeOffset, safeOffset + budget) ?? [];
|
|
49
|
+
|
|
50
|
+
useInput((ch, key) => {
|
|
51
|
+
if (key.return || key.escape || (key.ctrl && ch === "c")) {
|
|
52
|
+
onDone();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (key.upArrow || ch === "k") {
|
|
56
|
+
setOffset((o) => Math.max(0, o - 1));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (key.downArrow || ch === "j") {
|
|
60
|
+
setOffset((o) => Math.min(maxOffset, o + 1));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (key.pageUp) {
|
|
64
|
+
setOffset((o) => Math.max(0, o - budget));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (key.pageDown) {
|
|
68
|
+
setOffset((o) => Math.min(maxOffset, o + budget));
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<WizardFrame title={title}>
|
|
74
|
+
{loading || lines === null ? (
|
|
75
|
+
<Text color={MUTED}>Loading…</Text>
|
|
76
|
+
) : error ? (
|
|
77
|
+
<Text color={RED}>{error}</Text>
|
|
78
|
+
) : (
|
|
79
|
+
<Box flexDirection="column">
|
|
80
|
+
{visible.map((line) => (
|
|
81
|
+
<Text key={line.id} color={toneColor(line.tone)} bold={line.tone === "ok"}>
|
|
82
|
+
{line.text}
|
|
83
|
+
</Text>
|
|
84
|
+
))}
|
|
85
|
+
</Box>
|
|
86
|
+
)}
|
|
87
|
+
<Box marginTop={1}>
|
|
88
|
+
<Text color={MUTED}>
|
|
89
|
+
{maxOffset > 0 ? "↑↓ scroll · " : ""}
|
|
90
|
+
enter / esc return to hub
|
|
91
|
+
</Text>
|
|
92
|
+
<Text color={ACCENT}> █</Text>
|
|
93
|
+
</Box>
|
|
94
|
+
</WizardFrame>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function toneColor(tone: HubPanelLine["tone"]): string {
|
|
99
|
+
switch (tone) {
|
|
100
|
+
case "ok":
|
|
101
|
+
return GREEN;
|
|
102
|
+
case "bad":
|
|
103
|
+
return RED;
|
|
104
|
+
case "muted":
|
|
105
|
+
return MUTED;
|
|
106
|
+
case "accent":
|
|
107
|
+
return ACCENT;
|
|
108
|
+
default:
|
|
109
|
+
return FG;
|
|
110
|
+
}
|
|
111
|
+
}
|
package/src/tui/slash.ts
CHANGED
|
@@ -27,6 +27,7 @@ export const SLASH_COMMANDS: readonly SlashCommand[] = [
|
|
|
27
27
|
{ name: "help", hint: "Show help" },
|
|
28
28
|
{ name: "status", hint: "Repo status" },
|
|
29
29
|
{ name: "config", hint: "Show config" },
|
|
30
|
+
{ name: "version", hint: "Show version" },
|
|
30
31
|
{ name: "bump", hint: "Bump version" },
|
|
31
32
|
{ name: "viz", hint: "Branch map (on home)" },
|
|
32
33
|
{ name: "quit", hint: "Leave hub" },
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only Ink screens that stay inside the hub (doctor / help / status / config).
|
|
3
|
+
* @module tui/views
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type React from "react";
|
|
7
|
+
import { useEffect, useState } from "react";
|
|
8
|
+
import { collectDoctorReport } from "../commands/doctor.js";
|
|
9
|
+
import { getHelpText } from "../commands/help.js";
|
|
10
|
+
import { collectStatusLines } from "../commands/status-lines.js";
|
|
11
|
+
import { resolveConfig } from "../config.js";
|
|
12
|
+
import { resolveRepoRoot } from "../git.js";
|
|
13
|
+
import { getVersion } from "../version.js";
|
|
14
|
+
import { type HubPanelLine, HubScrollPanel } from "./panels.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Doctor checks inside the hub.
|
|
18
|
+
*/
|
|
19
|
+
export function DoctorView({
|
|
20
|
+
cwd,
|
|
21
|
+
onDone,
|
|
22
|
+
}: {
|
|
23
|
+
cwd: string;
|
|
24
|
+
onDone: () => void;
|
|
25
|
+
}): React.ReactElement {
|
|
26
|
+
const [lines, setLines] = useState<HubPanelLine[] | null>(null);
|
|
27
|
+
const [error, setError] = useState<string | null>(null);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
let cancelled = false;
|
|
31
|
+
void (async () => {
|
|
32
|
+
try {
|
|
33
|
+
const report = await collectDoctorReport(cwd);
|
|
34
|
+
if (cancelled) return;
|
|
35
|
+
const rows: HubPanelLine[] = report.checks.map((c) => ({
|
|
36
|
+
id: c.name,
|
|
37
|
+
text: `${c.ok ? "✓" : "✗"} ${c.name}: ${c.detail}`,
|
|
38
|
+
tone: c.ok ? "ok" : "bad",
|
|
39
|
+
}));
|
|
40
|
+
rows.push({
|
|
41
|
+
id: "summary",
|
|
42
|
+
text: report.ok
|
|
43
|
+
? "gflows doctor: all critical checks passed."
|
|
44
|
+
: "gflows doctor: some checks failed.",
|
|
45
|
+
tone: report.ok ? "ok" : "bad",
|
|
46
|
+
});
|
|
47
|
+
setLines(rows);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
if (!cancelled) {
|
|
50
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
51
|
+
setLines([]);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
})();
|
|
55
|
+
return () => {
|
|
56
|
+
cancelled = true;
|
|
57
|
+
};
|
|
58
|
+
}, [cwd]);
|
|
59
|
+
|
|
60
|
+
return <HubScrollPanel title="Doctor" lines={lines} error={error} onDone={onDone} />;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Scrollable help inside the hub.
|
|
65
|
+
*/
|
|
66
|
+
export function HelpView({ onDone }: { onDone: () => void }): React.ReactElement {
|
|
67
|
+
const lines: HubPanelLine[] = getHelpText()
|
|
68
|
+
.split("\n")
|
|
69
|
+
.map((text, i) => ({
|
|
70
|
+
id: `h${i}`,
|
|
71
|
+
text: text.length === 0 ? " " : text,
|
|
72
|
+
tone: text.endsWith(":") || text.startsWith("gflows") ? "accent" : "default",
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
return <HubScrollPanel title="Help" lines={lines} onDone={onDone} />;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Branch status inside the hub.
|
|
80
|
+
*/
|
|
81
|
+
export function StatusView({
|
|
82
|
+
cwd,
|
|
83
|
+
onDone,
|
|
84
|
+
}: {
|
|
85
|
+
cwd: string;
|
|
86
|
+
onDone: () => void;
|
|
87
|
+
}): React.ReactElement {
|
|
88
|
+
const [lines, setLines] = useState<HubPanelLine[] | null>(null);
|
|
89
|
+
const [error, setError] = useState<string | null>(null);
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
let cancelled = false;
|
|
93
|
+
void (async () => {
|
|
94
|
+
try {
|
|
95
|
+
const rows = await collectStatusLines(cwd);
|
|
96
|
+
if (!cancelled) {
|
|
97
|
+
setLines(
|
|
98
|
+
rows.map((row, i) => ({
|
|
99
|
+
id: `s${i}`,
|
|
100
|
+
text: row.text,
|
|
101
|
+
tone: row.tone,
|
|
102
|
+
})),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
} catch (err) {
|
|
106
|
+
if (!cancelled) {
|
|
107
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
108
|
+
setLines([]);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
})();
|
|
112
|
+
return () => {
|
|
113
|
+
cancelled = true;
|
|
114
|
+
};
|
|
115
|
+
}, [cwd]);
|
|
116
|
+
|
|
117
|
+
return <HubScrollPanel title="Status" lines={lines} error={error} onDone={onDone} />;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolved config snapshot inside the hub (read-only).
|
|
122
|
+
*/
|
|
123
|
+
export function ConfigView({
|
|
124
|
+
cwd,
|
|
125
|
+
onDone,
|
|
126
|
+
}: {
|
|
127
|
+
cwd: string;
|
|
128
|
+
onDone: () => void;
|
|
129
|
+
}): React.ReactElement {
|
|
130
|
+
const [lines, setLines] = useState<HubPanelLine[] | null>(null);
|
|
131
|
+
const [error, setError] = useState<string | null>(null);
|
|
132
|
+
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
let cancelled = false;
|
|
135
|
+
void (async () => {
|
|
136
|
+
try {
|
|
137
|
+
const root = await resolveRepoRoot(cwd);
|
|
138
|
+
const config = resolveConfig(root, {}, {});
|
|
139
|
+
if (cancelled) return;
|
|
140
|
+
setLines([
|
|
141
|
+
{ id: "main", text: `main: ${config.main}`, tone: "accent" },
|
|
142
|
+
{ id: "dev", text: `dev: ${config.dev}` },
|
|
143
|
+
{ id: "remote", text: `remote: ${config.remote}` },
|
|
144
|
+
{ id: "prefixes", text: "prefixes:", tone: "muted" },
|
|
145
|
+
...Object.entries(config.prefixes).map(([type, prefix]) => ({
|
|
146
|
+
id: `p-${type}`,
|
|
147
|
+
text: ` ${type}: ${prefix}`,
|
|
148
|
+
})),
|
|
149
|
+
{ id: "blank", text: " ", tone: "muted" },
|
|
150
|
+
{
|
|
151
|
+
id: "hint",
|
|
152
|
+
text: "Use: gflows config set <key> <value>",
|
|
153
|
+
tone: "muted",
|
|
154
|
+
},
|
|
155
|
+
]);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
if (!cancelled) {
|
|
158
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
159
|
+
setLines([]);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
})();
|
|
163
|
+
return () => {
|
|
164
|
+
cancelled = true;
|
|
165
|
+
};
|
|
166
|
+
}, [cwd]);
|
|
167
|
+
|
|
168
|
+
return <HubScrollPanel title="Config" lines={lines} error={error} onDone={onDone} />;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Version chip inside the hub.
|
|
173
|
+
*/
|
|
174
|
+
export function VersionView({ onDone }: { onDone: () => void }): React.ReactElement {
|
|
175
|
+
return (
|
|
176
|
+
<HubScrollPanel
|
|
177
|
+
title="Version"
|
|
178
|
+
lines={[{ id: "v", text: `gflows v${getVersion()}`, tone: "accent" }]}
|
|
179
|
+
onDone={onDone}
|
|
180
|
+
/>
|
|
181
|
+
);
|
|
182
|
+
}
|