gflows 1.0.1 → 1.0.3
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 +17 -0
- package/package.json +3 -2
- package/src/cli.ts +9 -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/prompts.ts +16 -2
- package/src/tui/HubShell.tsx +123 -27
- package/src/tui/flows.tsx +280 -35
- package/src/tui/hub.ts +7 -1
- package/src/tui/panels.tsx +111 -0
- package/src/tui/slash.ts +1 -0
- package/src/tui/stdin.ts +28 -0
- package/src/tui/views.tsx +182 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.0.3] - 2026-07-25
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Hub interactive actions no longer freeze/exit after Ink unmount (`stdin.ref` before dispatch; Clack cancel no longer `process.exit`s the hub)
|
|
15
|
+
- `/switch`, `/init`, `/bump` wizards run inside Ink (same as start/list)
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Local `bun run g` script invokes `src/cli.ts` so the hub gets a real TTY
|
|
20
|
+
|
|
21
|
+
## [1.0.2] - 2026-07-25
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- Hub read-only screens stay in Ink: `/doctor`, `/help`, `/status`, `/config`, `/version` (enter/esc return)
|
|
26
|
+
|
|
10
27
|
## [1.0.1] - 2026-07-25
|
|
11
28
|
|
|
12
29
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gflows",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"author": "Ali AlNaghmoush (https://github.com/alialnaghmoush)",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -39,7 +39,8 @@
|
|
|
39
39
|
"gflows": "bun run src/cli.ts",
|
|
40
40
|
"publish:all": "bun run scripts/publish.ts",
|
|
41
41
|
"publish:npm": "bun run scripts/publish.ts -- --npm-only",
|
|
42
|
-
"publish:jsr": "bun run scripts/publish.ts -- --jsr-only"
|
|
42
|
+
"publish:jsr": "bun run scripts/publish.ts -- --jsr-only",
|
|
43
|
+
"g": "bun run src/cli.ts"
|
|
43
44
|
},
|
|
44
45
|
"type": "module",
|
|
45
46
|
"dependencies": {
|
package/src/cli.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { EXIT_GIT, EXIT_OK, EXIT_USER } from "./constants.js";
|
|
10
10
|
import { exitCodeForError, printError } from "./errors.js";
|
|
11
11
|
import { parse } from "./parse.js";
|
|
12
|
+
import { PromptCancelledError } from "./prompts.js";
|
|
12
13
|
import type { ParsedArgs } from "./types.js";
|
|
13
14
|
|
|
14
15
|
/** Last parsed args, set at start of run(); used by catch/rejection to respect -v for stack trace. */
|
|
@@ -27,7 +28,10 @@ async function run(): Promise<void> {
|
|
|
27
28
|
await runHub(process.cwd());
|
|
28
29
|
return;
|
|
29
30
|
}
|
|
30
|
-
console.error(
|
|
31
|
+
console.error(
|
|
32
|
+
"gflows: no interactive TTY. Run `gflows` directly (or `alias g=gflows`), not via a bun/npm script that swallows stdin.",
|
|
33
|
+
);
|
|
34
|
+
console.error("Or pass a command: gflows help");
|
|
31
35
|
process.exit(EXIT_USER);
|
|
32
36
|
}
|
|
33
37
|
|
|
@@ -76,6 +80,10 @@ function main(): void {
|
|
|
76
80
|
})
|
|
77
81
|
.catch((err: unknown) => {
|
|
78
82
|
if (exitCode !== null) return;
|
|
83
|
+
if (err instanceof PromptCancelledError) {
|
|
84
|
+
process.exit(EXIT_OK);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
79
87
|
printError(err);
|
|
80
88
|
const verbose = lastParsedArgs?.verbose ?? !!process.env.GFLOWS_VERBOSE;
|
|
81
89
|
if (verbose && err instanceof Error && err.stack) {
|
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/prompts.ts
CHANGED
|
@@ -6,12 +6,26 @@
|
|
|
6
6
|
import * as clack from "@clack/prompts";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
9
|
+
* Thrown when the user cancels a Clack prompt (Ctrl+C / Escape).
|
|
10
|
+
* Callers (CLI / hub) should treat this as a soft abort — do not kill the hub loop via process.exit.
|
|
11
|
+
*/
|
|
12
|
+
export class PromptCancelledError extends Error {
|
|
13
|
+
/**
|
|
14
|
+
* Creates a cancellation error.
|
|
15
|
+
*/
|
|
16
|
+
constructor() {
|
|
17
|
+
super("Cancelled.");
|
|
18
|
+
this.name = "PromptCancelledError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Converts Clack cancel symbols into {@link PromptCancelledError}.
|
|
10
24
|
*/
|
|
11
25
|
function exitIfCancel<T>(value: T | symbol): asserts value is T {
|
|
12
26
|
if (clack.isCancel(value)) {
|
|
13
27
|
clack.cancel("Cancelled.");
|
|
14
|
-
|
|
28
|
+
throw new PromptCancelledError();
|
|
15
29
|
}
|
|
16
30
|
}
|
|
17
31
|
|
package/src/tui/HubShell.tsx
CHANGED
|
@@ -6,10 +6,19 @@
|
|
|
6
6
|
import { Box, Text, useApp, useInput } from "ink";
|
|
7
7
|
import type React from "react";
|
|
8
8
|
import { useState } from "react";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
BumpFlow,
|
|
11
|
+
FinishFlow,
|
|
12
|
+
InitFlow,
|
|
13
|
+
ListFlow,
|
|
14
|
+
StartFlow,
|
|
15
|
+
SwitchFlow,
|
|
16
|
+
SyncFlow,
|
|
17
|
+
} from "./flows.js";
|
|
10
18
|
import { HubHome } from "./HubHome.js";
|
|
11
19
|
import { WizardFrame } from "./prompts.js";
|
|
12
20
|
import { SLASH_COMMANDS } from "./slash.js";
|
|
21
|
+
import { ConfigView, DoctorView, HelpView, StatusView, VersionView } from "./views.js";
|
|
13
22
|
|
|
14
23
|
const ACCENT = "#E88C4A";
|
|
15
24
|
const MUTED = "#8A8A8A";
|
|
@@ -24,13 +33,27 @@ type Screen =
|
|
|
24
33
|
| { id: "finish" }
|
|
25
34
|
| { id: "sync" }
|
|
26
35
|
| { id: "list" }
|
|
36
|
+
| { id: "switch" }
|
|
37
|
+
| { id: "init" }
|
|
38
|
+
| { id: "bump" }
|
|
39
|
+
| { id: "doctor" }
|
|
40
|
+
| { id: "help" }
|
|
41
|
+
| { id: "status" }
|
|
42
|
+
| { id: "config" }
|
|
43
|
+
| { id: "version" }
|
|
27
44
|
| { id: "notice"; message: string };
|
|
28
45
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
46
|
+
/** Commands that leave Ink for git / side effects (no interactive Clack). */
|
|
47
|
+
const DISPATCH_COMMANDS = new Set([
|
|
48
|
+
"pr",
|
|
49
|
+
"continue",
|
|
50
|
+
"delete",
|
|
51
|
+
"undo",
|
|
52
|
+
"abort",
|
|
53
|
+
"schema",
|
|
54
|
+
"mcp",
|
|
55
|
+
"completion",
|
|
56
|
+
]);
|
|
34
57
|
|
|
35
58
|
/**
|
|
36
59
|
* Props for the hub shell.
|
|
@@ -55,6 +78,52 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
|
|
|
55
78
|
const cancelWizard = () => setScreen({ id: "home" });
|
|
56
79
|
const runArgv = (argv: string[]) => finish({ kind: "run", argv });
|
|
57
80
|
|
|
81
|
+
const openInHub = (cmd: string): boolean => {
|
|
82
|
+
switch (cmd) {
|
|
83
|
+
case "start":
|
|
84
|
+
setScreen({ id: "start" });
|
|
85
|
+
return true;
|
|
86
|
+
case "finish":
|
|
87
|
+
setScreen({ id: "finish" });
|
|
88
|
+
return true;
|
|
89
|
+
case "sync":
|
|
90
|
+
setScreen({ id: "sync" });
|
|
91
|
+
return true;
|
|
92
|
+
case "list":
|
|
93
|
+
setScreen({ id: "list" });
|
|
94
|
+
return true;
|
|
95
|
+
case "switch":
|
|
96
|
+
setScreen({ id: "switch" });
|
|
97
|
+
return true;
|
|
98
|
+
case "init":
|
|
99
|
+
setScreen({ id: "init" });
|
|
100
|
+
return true;
|
|
101
|
+
case "bump":
|
|
102
|
+
setScreen({ id: "bump" });
|
|
103
|
+
return true;
|
|
104
|
+
case "doctor":
|
|
105
|
+
setScreen({ id: "doctor" });
|
|
106
|
+
return true;
|
|
107
|
+
case "help":
|
|
108
|
+
setScreen({ id: "help" });
|
|
109
|
+
return true;
|
|
110
|
+
case "status":
|
|
111
|
+
setScreen({ id: "status" });
|
|
112
|
+
return true;
|
|
113
|
+
case "config":
|
|
114
|
+
setScreen({ id: "config" });
|
|
115
|
+
return true;
|
|
116
|
+
case "version":
|
|
117
|
+
setScreen({ id: "version" });
|
|
118
|
+
return true;
|
|
119
|
+
case "viz":
|
|
120
|
+
setScreen({ id: "home", flash: "Branch map is shown on this screen." });
|
|
121
|
+
return true;
|
|
122
|
+
default:
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
58
127
|
const handleSlash = (raw: string) => {
|
|
59
128
|
const parts = raw.slice(1).trim().split(/\s+/);
|
|
60
129
|
const cmd = (parts[0] ?? "").toLowerCase();
|
|
@@ -91,16 +160,26 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
|
|
|
91
160
|
setScreen({ id: "sync" });
|
|
92
161
|
return;
|
|
93
162
|
}
|
|
94
|
-
if (cmd === "
|
|
95
|
-
|
|
163
|
+
if (cmd === "config" && rest.length > 0) {
|
|
164
|
+
runArgv(["config", ...rest]);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (cmd === "switch" && rest.length > 0) {
|
|
168
|
+
runArgv(["switch", ...rest]);
|
|
96
169
|
return;
|
|
97
170
|
}
|
|
98
|
-
if (cmd === "
|
|
99
|
-
|
|
171
|
+
if (cmd === "bump" && rest.length > 0) {
|
|
172
|
+
runArgv(["bump", ...rest]);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (cmd === "init" && rest.length > 0) {
|
|
176
|
+
runArgv(["init", ...rest]);
|
|
100
177
|
return;
|
|
101
178
|
}
|
|
102
179
|
|
|
103
|
-
if (
|
|
180
|
+
if (openInHub(cmd)) return;
|
|
181
|
+
|
|
182
|
+
if (!DISPATCH_COMMANDS.has(cmd) && !SLASH_COMMANDS.some((c) => c.name === cmd)) {
|
|
104
183
|
setScreen({
|
|
105
184
|
id: "notice",
|
|
106
185
|
message: `Unknown /${cmd} — type / for command hints.`,
|
|
@@ -108,6 +187,14 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
|
|
|
108
187
|
return;
|
|
109
188
|
}
|
|
110
189
|
|
|
190
|
+
if (!DISPATCH_COMMANDS.has(cmd)) {
|
|
191
|
+
setScreen({
|
|
192
|
+
id: "notice",
|
|
193
|
+
message: `/${cmd} is not available from the hub yet.`,
|
|
194
|
+
});
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
111
198
|
runArgv([cmd, ...rest]);
|
|
112
199
|
};
|
|
113
200
|
|
|
@@ -116,22 +203,7 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
|
|
|
116
203
|
finish({ kind: "quit" });
|
|
117
204
|
return;
|
|
118
205
|
}
|
|
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
|
-
}
|
|
206
|
+
if (openInHub(action)) return;
|
|
135
207
|
runArgv([action]);
|
|
136
208
|
};
|
|
137
209
|
|
|
@@ -147,6 +219,30 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
|
|
|
147
219
|
if (screen.id === "list") {
|
|
148
220
|
return <ListFlow cwd={cwd} onDone={cancelWizard} />;
|
|
149
221
|
}
|
|
222
|
+
if (screen.id === "switch") {
|
|
223
|
+
return <SwitchFlow cwd={cwd} onCancel={cancelWizard} onDone={runArgv} />;
|
|
224
|
+
}
|
|
225
|
+
if (screen.id === "init") {
|
|
226
|
+
return <InitFlow onCancel={cancelWizard} onDone={runArgv} />;
|
|
227
|
+
}
|
|
228
|
+
if (screen.id === "bump") {
|
|
229
|
+
return <BumpFlow onCancel={cancelWizard} onDone={runArgv} />;
|
|
230
|
+
}
|
|
231
|
+
if (screen.id === "doctor") {
|
|
232
|
+
return <DoctorView cwd={cwd} onDone={cancelWizard} />;
|
|
233
|
+
}
|
|
234
|
+
if (screen.id === "help") {
|
|
235
|
+
return <HelpView onDone={cancelWizard} />;
|
|
236
|
+
}
|
|
237
|
+
if (screen.id === "status") {
|
|
238
|
+
return <StatusView cwd={cwd} onDone={cancelWizard} />;
|
|
239
|
+
}
|
|
240
|
+
if (screen.id === "config") {
|
|
241
|
+
return <ConfigView cwd={cwd} onDone={cancelWizard} />;
|
|
242
|
+
}
|
|
243
|
+
if (screen.id === "version") {
|
|
244
|
+
return <VersionView onDone={cancelWizard} />;
|
|
245
|
+
}
|
|
150
246
|
if (screen.id === "notice") {
|
|
151
247
|
return (
|
|
152
248
|
<PressEnter
|
package/src/tui/flows.tsx
CHANGED
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Multi-step Ink wizards for hub actions (start / finish / sync / list).
|
|
2
|
+
* Multi-step Ink wizards for hub actions (start / finish / sync / list / switch / …).
|
|
3
3
|
* @module tui/flows
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { Text } from "ink";
|
|
7
7
|
import type React from "react";
|
|
8
8
|
import { useEffect, useState } from "react";
|
|
9
|
+
import { resolveConfig } from "../config.js";
|
|
10
|
+
import { branchList, getCurrentBranch, isClean, resolveRepoRoot } from "../git.js";
|
|
9
11
|
import type { BranchType } from "../types.js";
|
|
10
12
|
import { collectVizSnapshot, type VizSnapshot } from "../viz.js";
|
|
13
|
+
import { type HubPanelLine, HubScrollPanel } from "./panels.js";
|
|
11
14
|
import { InkConfirm, InkSelect, InkText, WizardFrame } from "./prompts.js";
|
|
12
15
|
|
|
13
16
|
const MUTED = "#8A8A8A";
|
|
14
|
-
const FG = "#E6E6E6";
|
|
15
|
-
const GREEN = "#78C88C";
|
|
16
|
-
const ACCENT = "#E88C4A";
|
|
17
17
|
|
|
18
18
|
const BRANCH_TYPES: BranchType[] = ["feature", "bugfix", "chore", "release", "hotfix", "spike"];
|
|
19
19
|
|
|
20
|
+
type SwitchMode = "move" | "restore" | "clean" | "destroy" | "cancel";
|
|
21
|
+
|
|
20
22
|
/**
|
|
21
23
|
* Collects argv for `gflows start …` entirely inside Ink.
|
|
22
24
|
*/
|
|
@@ -147,22 +149,53 @@ export function SyncFlow({
|
|
|
147
149
|
}
|
|
148
150
|
|
|
149
151
|
/**
|
|
150
|
-
*
|
|
152
|
+
* Switch-branch wizard entirely inside Ink (avoids dead Clack after Ink unmount).
|
|
151
153
|
*/
|
|
152
|
-
export function
|
|
153
|
-
|
|
154
|
+
export function SwitchFlow({
|
|
155
|
+
cwd,
|
|
156
|
+
onDone,
|
|
157
|
+
onCancel,
|
|
158
|
+
}: {
|
|
159
|
+
cwd: string;
|
|
160
|
+
onDone: (argv: string[]) => void;
|
|
161
|
+
onCancel: () => void;
|
|
162
|
+
}): React.ReactElement {
|
|
163
|
+
const [step, setStep] = useState<"load" | "pick" | "dirty" | "error">("load");
|
|
164
|
+
const [choices, setChoices] = useState<{ value: string; label: string }[]>([]);
|
|
165
|
+
const [target, setTarget] = useState("");
|
|
154
166
|
const [error, setError] = useState<string | null>(null);
|
|
167
|
+
const [initialIndex, setInitialIndex] = useState(0);
|
|
155
168
|
|
|
156
169
|
useEffect(() => {
|
|
157
170
|
let cancelled = false;
|
|
158
171
|
void (async () => {
|
|
159
172
|
try {
|
|
160
|
-
const
|
|
161
|
-
|
|
173
|
+
const root = await resolveRepoRoot(cwd);
|
|
174
|
+
const config = resolveConfig(root, {}, {});
|
|
175
|
+
const allLocal = await branchList(root, { dryRun: false, verbose: false });
|
|
176
|
+
const workflow = BRANCH_TYPES.map((t) => config.prefixes[t])
|
|
177
|
+
.filter(Boolean)
|
|
178
|
+
.flatMap((prefix) => allLocal.filter((b) => b.startsWith(prefix)));
|
|
179
|
+
const mainAndDev = [config.main, config.dev].filter((b) => allLocal.includes(b));
|
|
180
|
+
const names = [...mainAndDev, ...[...new Set(workflow)].sort()];
|
|
181
|
+
const current = await getCurrentBranch(root, {});
|
|
182
|
+
if (cancelled) return;
|
|
183
|
+
if (names.length === 0) {
|
|
184
|
+
setError("No branches found.");
|
|
185
|
+
setStep("error");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const opts = names.map((b) => ({
|
|
189
|
+
value: b,
|
|
190
|
+
label: current && b === current ? `${b} (current)` : b,
|
|
191
|
+
}));
|
|
192
|
+
setChoices(opts);
|
|
193
|
+
setInitialIndex(Math.max(0, current ? names.indexOf(current) : 0));
|
|
194
|
+
setStep("pick");
|
|
162
195
|
} catch (err) {
|
|
163
196
|
if (!cancelled) {
|
|
164
197
|
setError(err instanceof Error ? err.message : String(err));
|
|
165
|
-
|
|
198
|
+
setStep("error");
|
|
166
199
|
}
|
|
167
200
|
}
|
|
168
201
|
})();
|
|
@@ -171,36 +204,248 @@ export function ListFlow({ cwd, onDone }: { cwd: string; onDone: () => void }):
|
|
|
171
204
|
};
|
|
172
205
|
}, [cwd]);
|
|
173
206
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
207
|
+
if (step === "load") {
|
|
208
|
+
return (
|
|
209
|
+
<WizardFrame title="Switch branch">
|
|
210
|
+
<Text color={MUTED}>Loading…</Text>
|
|
211
|
+
</WizardFrame>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
177
214
|
|
|
215
|
+
if (step === "error") {
|
|
216
|
+
return (
|
|
217
|
+
<HubScrollPanel
|
|
218
|
+
title="Switch branch"
|
|
219
|
+
lines={[{ id: "e", text: error ?? "Unknown error", tone: "bad" }]}
|
|
220
|
+
onDone={onCancel}
|
|
221
|
+
/>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (step === "pick") {
|
|
226
|
+
return (
|
|
227
|
+
<WizardFrame title="Switch branch">
|
|
228
|
+
<InkSelect
|
|
229
|
+
message="Switch to branch"
|
|
230
|
+
options={choices}
|
|
231
|
+
initialIndex={initialIndex}
|
|
232
|
+
onCancel={onCancel}
|
|
233
|
+
onSubmit={(branch) => {
|
|
234
|
+
void (async () => {
|
|
235
|
+
setTarget(branch);
|
|
236
|
+
try {
|
|
237
|
+
const root = await resolveRepoRoot(cwd);
|
|
238
|
+
const clean = await isClean(root, {});
|
|
239
|
+
if (clean) {
|
|
240
|
+
onDone(["switch", branch]);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
setStep("dirty");
|
|
244
|
+
} catch (err) {
|
|
245
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
246
|
+
setStep("error");
|
|
247
|
+
}
|
|
248
|
+
})();
|
|
249
|
+
}}
|
|
250
|
+
/>
|
|
251
|
+
</WizardFrame>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// dirty
|
|
178
256
|
return (
|
|
179
|
-
<WizardFrame title=
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
{
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
);
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
<Text color={ACCENT}> █</Text>
|
|
199
|
-
</Box>
|
|
257
|
+
<WizardFrame title={`Switch · ${target}`}>
|
|
258
|
+
<InkSelect<SwitchMode>
|
|
259
|
+
message="Working tree has uncommitted changes"
|
|
260
|
+
options={[
|
|
261
|
+
{ value: "move", label: "Move", hint: "carry changes to target" },
|
|
262
|
+
{ value: "restore", label: "Restore", hint: "stash here, restore target" },
|
|
263
|
+
{ value: "clean", label: "Clean", hint: "discard changes" },
|
|
264
|
+
{ value: "destroy", label: "Destroy", hint: "delete current branch" },
|
|
265
|
+
{ value: "cancel", label: "Cancel", hint: "abort switch" },
|
|
266
|
+
]}
|
|
267
|
+
onCancel={onCancel}
|
|
268
|
+
onSubmit={(mode) => {
|
|
269
|
+
if (mode === "cancel") {
|
|
270
|
+
onCancel();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
onDone(["switch", target, `--${mode}`]);
|
|
274
|
+
}}
|
|
275
|
+
/>
|
|
200
276
|
</WizardFrame>
|
|
201
277
|
);
|
|
202
278
|
}
|
|
203
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Init wizard inside Ink; dispatches with -y so CLI skips Clack prompts.
|
|
282
|
+
*/
|
|
283
|
+
export function InitFlow({
|
|
284
|
+
onDone,
|
|
285
|
+
onCancel,
|
|
286
|
+
}: {
|
|
287
|
+
onDone: (argv: string[]) => void;
|
|
288
|
+
onCancel: () => void;
|
|
289
|
+
}): React.ReactElement {
|
|
290
|
+
const [step, setStep] = useState<"main" | "dev" | "remote" | "alias">("main");
|
|
291
|
+
const [main, setMain] = useState("main");
|
|
292
|
+
const [dev, setDev] = useState("dev");
|
|
293
|
+
const [remote, setRemote] = useState("origin");
|
|
294
|
+
|
|
295
|
+
if (step === "main") {
|
|
296
|
+
return (
|
|
297
|
+
<WizardFrame title="Initialize repo">
|
|
298
|
+
<InkText
|
|
299
|
+
message="Main branch name"
|
|
300
|
+
placeholder="main"
|
|
301
|
+
initialValue="main"
|
|
302
|
+
onCancel={onCancel}
|
|
303
|
+
onSubmit={(v) => {
|
|
304
|
+
setMain(v || "main");
|
|
305
|
+
setStep("dev");
|
|
306
|
+
}}
|
|
307
|
+
/>
|
|
308
|
+
</WizardFrame>
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
if (step === "dev") {
|
|
312
|
+
return (
|
|
313
|
+
<WizardFrame title="Initialize repo">
|
|
314
|
+
<InkText
|
|
315
|
+
message="Dev branch name"
|
|
316
|
+
placeholder="dev"
|
|
317
|
+
initialValue="dev"
|
|
318
|
+
onCancel={onCancel}
|
|
319
|
+
onSubmit={(v) => {
|
|
320
|
+
setDev(v || "dev");
|
|
321
|
+
setStep("remote");
|
|
322
|
+
}}
|
|
323
|
+
/>
|
|
324
|
+
</WizardFrame>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
if (step === "remote") {
|
|
328
|
+
return (
|
|
329
|
+
<WizardFrame title="Initialize repo">
|
|
330
|
+
<InkText
|
|
331
|
+
message="Remote name"
|
|
332
|
+
placeholder="origin"
|
|
333
|
+
initialValue="origin"
|
|
334
|
+
onCancel={onCancel}
|
|
335
|
+
onSubmit={(v) => {
|
|
336
|
+
setRemote(v || "origin");
|
|
337
|
+
setStep("alias");
|
|
338
|
+
}}
|
|
339
|
+
/>
|
|
340
|
+
</WizardFrame>
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return (
|
|
345
|
+
<WizardFrame title="Initialize repo">
|
|
346
|
+
<InkSelect
|
|
347
|
+
message="Add package.json script alias?"
|
|
348
|
+
options={[
|
|
349
|
+
{ value: "g", label: 'script "g"', hint: "prefer: alias g=gflows in shell" },
|
|
350
|
+
{ value: "gflows", label: 'script "gflows"' },
|
|
351
|
+
{ value: "skip", label: "Skip", hint: "shell alias is best for hub TTY" },
|
|
352
|
+
]}
|
|
353
|
+
onCancel={onCancel}
|
|
354
|
+
onSubmit={(alias) => {
|
|
355
|
+
const argv = ["init", "-y", "-P", "--main", main, "--dev", dev, "--remote", remote];
|
|
356
|
+
if (alias === "skip") argv.push("--no-script-alias");
|
|
357
|
+
else argv.push("--script-alias", alias);
|
|
358
|
+
onDone(argv);
|
|
359
|
+
}}
|
|
360
|
+
/>
|
|
361
|
+
</WizardFrame>
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Bump wizard inside Ink.
|
|
367
|
+
*/
|
|
368
|
+
export function BumpFlow({
|
|
369
|
+
onDone,
|
|
370
|
+
onCancel,
|
|
371
|
+
}: {
|
|
372
|
+
onDone: (argv: string[]) => void;
|
|
373
|
+
onCancel: () => void;
|
|
374
|
+
}): React.ReactElement {
|
|
375
|
+
const [step, setStep] = useState<"dir" | "type">("dir");
|
|
376
|
+
const [direction, setDirection] = useState<"up" | "down">("up");
|
|
377
|
+
|
|
378
|
+
if (step === "dir") {
|
|
379
|
+
return (
|
|
380
|
+
<WizardFrame title="Bump version">
|
|
381
|
+
<InkSelect<"up" | "down">
|
|
382
|
+
message="Direction"
|
|
383
|
+
options={[
|
|
384
|
+
{ value: "up", label: "Up (bump)" },
|
|
385
|
+
{ value: "down", label: "Down (rollback)" },
|
|
386
|
+
]}
|
|
387
|
+
onCancel={onCancel}
|
|
388
|
+
onSubmit={(d) => {
|
|
389
|
+
setDirection(d);
|
|
390
|
+
setStep("type");
|
|
391
|
+
}}
|
|
392
|
+
/>
|
|
393
|
+
</WizardFrame>
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return (
|
|
398
|
+
<WizardFrame title={`Bump · ${direction}`}>
|
|
399
|
+
<InkSelect<"patch" | "minor" | "major">
|
|
400
|
+
message="Semver part"
|
|
401
|
+
options={[
|
|
402
|
+
{ value: "patch", label: "patch" },
|
|
403
|
+
{ value: "minor", label: "minor" },
|
|
404
|
+
{ value: "major", label: "major" },
|
|
405
|
+
]}
|
|
406
|
+
onCancel={onCancel}
|
|
407
|
+
onSubmit={(type) => onDone(["bump", direction, type])}
|
|
408
|
+
/>
|
|
409
|
+
</WizardFrame>
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Styled branch list inside the hub (no drop-out to plain stdout).
|
|
415
|
+
*/
|
|
416
|
+
export function ListFlow({ cwd, onDone }: { cwd: string; onDone: () => void }): React.ReactElement {
|
|
417
|
+
const [lines, setLines] = useState<HubPanelLine[] | null>(null);
|
|
418
|
+
const [error, setError] = useState<string | null>(null);
|
|
419
|
+
|
|
420
|
+
useEffect(() => {
|
|
421
|
+
let cancelled = false;
|
|
422
|
+
void (async () => {
|
|
423
|
+
try {
|
|
424
|
+
const snap = await collectVizSnapshot(cwd);
|
|
425
|
+
if (!cancelled) {
|
|
426
|
+
setLines(
|
|
427
|
+
formatListLines(snap).map((text, i) => ({
|
|
428
|
+
id: `b${i}`,
|
|
429
|
+
text,
|
|
430
|
+
tone: text.includes("●") ? ("ok" as const) : ("default" as const),
|
|
431
|
+
})),
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
} catch (err) {
|
|
435
|
+
if (!cancelled) {
|
|
436
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
437
|
+
setLines([]);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
})();
|
|
441
|
+
return () => {
|
|
442
|
+
cancelled = true;
|
|
443
|
+
};
|
|
444
|
+
}, [cwd]);
|
|
445
|
+
|
|
446
|
+
return <HubScrollPanel title="Branches" lines={lines} error={error} onDone={onDone} />;
|
|
447
|
+
}
|
|
448
|
+
|
|
204
449
|
/**
|
|
205
450
|
* Formats a viz snapshot into hub list rows.
|
|
206
451
|
*/
|
package/src/tui/hub.ts
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
import { render } from "ink";
|
|
7
7
|
import React from "react";
|
|
8
8
|
import { dispatch } from "../dispatch.js";
|
|
9
|
+
import { PromptCancelledError } from "../prompts.js";
|
|
9
10
|
import { type HubSessionResult, HubShell } from "./HubShell.js";
|
|
11
|
+
import { prepareStdinAfterInk } from "./stdin.js";
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* Whether stdin/stdout can host the Ink hub.
|
|
@@ -27,11 +29,15 @@ export async function runTuiHub(cwd: string): Promise<boolean> {
|
|
|
27
29
|
if (result.kind === "quit") break;
|
|
28
30
|
|
|
29
31
|
if (result.kind === "run") {
|
|
32
|
+
// Ink unrefs stdin on unmount — re-arm before Clack / git prompts.
|
|
33
|
+
prepareStdinAfterInk();
|
|
30
34
|
console.log("");
|
|
31
35
|
try {
|
|
32
36
|
await dispatch(cwd, result.argv);
|
|
33
37
|
} catch (err) {
|
|
34
|
-
|
|
38
|
+
if (!(err instanceof PromptCancelledError)) {
|
|
39
|
+
console.error("gflows:", err instanceof Error ? err.message : String(err));
|
|
40
|
+
}
|
|
35
41
|
}
|
|
36
42
|
console.log("");
|
|
37
43
|
await waitEnterRaw("Press enter to return to hub…");
|
|
@@ -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" },
|
package/src/tui/stdin.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stdin helpers for surviving Ink teardown before Clack / dispatch.
|
|
3
|
+
* @module tui/stdin
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Re-arm process.stdin after Ink unmounts (Ink calls `stdin.unref()`).
|
|
8
|
+
* Without this, interactive Clack prompts paint once then the process exits.
|
|
9
|
+
*/
|
|
10
|
+
export function prepareStdinAfterInk(): void {
|
|
11
|
+
const stdin = process.stdin;
|
|
12
|
+
if (typeof stdin.setRawMode === "function") {
|
|
13
|
+
try {
|
|
14
|
+
stdin.setRawMode(false);
|
|
15
|
+
} catch {
|
|
16
|
+
// ignore — may already be non-raw / closed
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
stdin.resume();
|
|
20
|
+
stdin.ref();
|
|
21
|
+
|
|
22
|
+
if (typeof stdin.read === "function") {
|
|
23
|
+
for (;;) {
|
|
24
|
+
const pending = stdin.read();
|
|
25
|
+
if (pending === null) break;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -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
|
+
}
|