@hicoders/devkit 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/LICENSE +21 -0
- package/README.md +87 -0
- package/package.json +52 -0
- package/pkg/core/appname.ts +206 -0
- package/pkg/core/config.ts +34 -0
- package/pkg/core/killport.ts +273 -0
- package/pkg/core/launch.ts +434 -0
- package/pkg/core/manifest.ts +123 -0
- package/pkg/tui/app.tsx +33 -0
- package/pkg/tui/components/Confirm.tsx +53 -0
- package/pkg/tui/components/Header.tsx +37 -0
- package/pkg/tui/components/Help.tsx +83 -0
- package/pkg/tui/components/ListSelect.tsx +436 -0
- package/pkg/tui/components/TextPrompt.tsx +83 -0
- package/pkg/tui/components/index.ts +7 -0
- package/pkg/tui/devkit.tsx +79 -0
- package/pkg/tui/killport.tsx +369 -0
- package/pkg/tui/launch.tsx +971 -0
- package/pkg/tui/theme-context.tsx +30 -0
- package/pkg/tui/theme.ts +97 -0
- package/pkg/tui/tools.tsx +27 -0
- package/pkg/tui/winmouse.ts +70 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,971 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// launch - interactive launcher for your dev projects.
|
|
3
|
+
//
|
|
4
|
+
// launch pick a project; Enter starts its default command(s) with
|
|
5
|
+
// logs streaming to this terminal. Ctrl-C stops them.
|
|
6
|
+
// launch <name> start that project's defaults directly (scriptable).
|
|
7
|
+
//
|
|
8
|
+
// Projects come from scan roots (folders auto-scanned each run) and manually
|
|
9
|
+
// added entries, all persisted in ~/.devkit.json. Press `a` on a project to run
|
|
10
|
+
// an ad-hoc subset of its commands; `n` to add a project (auto-detect or by
|
|
11
|
+
// hand); `e`/`d` to edit/delete; `s` to manage scan roots.
|
|
12
|
+
//
|
|
13
|
+
// UI logic only; discovery/config/spawning lives in pkg/core/launch.ts.
|
|
14
|
+
|
|
15
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
16
|
+
import type { ReactNode } from "react";
|
|
17
|
+
import { basename } from "node:path";
|
|
18
|
+
import { mountScreen } from "./app";
|
|
19
|
+
import { useTheme } from "./theme-context";
|
|
20
|
+
import { Header, ListSelect, Confirm, Help, TextPrompt, type Binding } from "./components";
|
|
21
|
+
import {
|
|
22
|
+
allProjects,
|
|
23
|
+
scanProjects,
|
|
24
|
+
orderProjects,
|
|
25
|
+
loadLaunch,
|
|
26
|
+
saveLaunch,
|
|
27
|
+
addProject,
|
|
28
|
+
updateProject,
|
|
29
|
+
removeProject,
|
|
30
|
+
adoptProject,
|
|
31
|
+
excludeFromScan,
|
|
32
|
+
includeInScan,
|
|
33
|
+
scanFolder,
|
|
34
|
+
recordLastRun,
|
|
35
|
+
lastRunLabels,
|
|
36
|
+
addScanRoot,
|
|
37
|
+
removeScanRoot,
|
|
38
|
+
detectProjectCommands,
|
|
39
|
+
defaultCommands,
|
|
40
|
+
suggestName,
|
|
41
|
+
newId,
|
|
42
|
+
newCommand,
|
|
43
|
+
findProject,
|
|
44
|
+
startCommands,
|
|
45
|
+
type Command,
|
|
46
|
+
type Project,
|
|
47
|
+
type ScanRoot,
|
|
48
|
+
type SortMode,
|
|
49
|
+
type LaunchConfig,
|
|
50
|
+
} from "../core/launch";
|
|
51
|
+
|
|
52
|
+
const HELP: Binding[] = [
|
|
53
|
+
{ keys: "", desc: "Move & run" },
|
|
54
|
+
{ keys: "j / k", desc: "move the highlight (or arrow keys)" },
|
|
55
|
+
{ keys: "enter", desc: "start the project's default command(s)" },
|
|
56
|
+
{ keys: "a", desc: "pick commands to run (pre-marks your last run)" },
|
|
57
|
+
{ keys: "/", desc: "filter projects" },
|
|
58
|
+
{ keys: "", desc: "Organize the list" },
|
|
59
|
+
{ keys: "p", desc: "pin / unpin (PINNED stays on top)" },
|
|
60
|
+
{ keys: "[ ]", desc: "move project up / down (switches to manual sort)" },
|
|
61
|
+
{ keys: "o", desc: "sort: manual order or last run (recent first)" },
|
|
62
|
+
{ keys: "", desc: "Add, edit & remove projects" },
|
|
63
|
+
{ keys: "n", desc: "add a project (auto-detect a folder or by hand)" },
|
|
64
|
+
{ keys: "e", desc: "edit (rename / defaults / commands; adopts a scanned one)" },
|
|
65
|
+
{ keys: "d", desc: "delete a manual project, or hide a scanned one" },
|
|
66
|
+
{ keys: "s", desc: "scan folders - add/remove roots, un-hide hidden projects" },
|
|
67
|
+
{ keys: "r", desc: "rescan the folders now" },
|
|
68
|
+
{ keys: "", desc: "General" },
|
|
69
|
+
{ keys: "t", desc: "cycle color theme" },
|
|
70
|
+
{ keys: "h", desc: "show / hide this help" },
|
|
71
|
+
{ keys: "q / esc esc", desc: "quit (q, or Esc twice)" },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
// Help shown on the scan-roots management screen.
|
|
75
|
+
const SCAN_HELP: Binding[] = [
|
|
76
|
+
{ keys: "j / k", desc: "move (or arrow keys)" },
|
|
77
|
+
{ keys: "enter", desc: "manage this root's hidden (excluded) projects" },
|
|
78
|
+
{ keys: "n", desc: "add a scan folder (auto-lists projects under it)" },
|
|
79
|
+
{ keys: "d", desc: "remove the highlighted scan root (keeps your files)" },
|
|
80
|
+
{ keys: "h", desc: "show / hide this help" },
|
|
81
|
+
{ keys: "esc / q", desc: "back to the project list" },
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
type Mode =
|
|
85
|
+
| "list"
|
|
86
|
+
| "run"
|
|
87
|
+
| "add"
|
|
88
|
+
| "auto-path"
|
|
89
|
+
| "auto-pick"
|
|
90
|
+
| "auto-name"
|
|
91
|
+
| "auto-default"
|
|
92
|
+
| "manual-name"
|
|
93
|
+
| "manual-label"
|
|
94
|
+
| "manual-command"
|
|
95
|
+
| "manual-cwd"
|
|
96
|
+
| "manual-more"
|
|
97
|
+
| "manual-default"
|
|
98
|
+
| "edit"
|
|
99
|
+
| "edit-rename"
|
|
100
|
+
| "edit-default"
|
|
101
|
+
| "edit-remove"
|
|
102
|
+
| "delete"
|
|
103
|
+
| "scan"
|
|
104
|
+
| "scan-add"
|
|
105
|
+
| "scan-excludes";
|
|
106
|
+
|
|
107
|
+
interface Draft {
|
|
108
|
+
name: string;
|
|
109
|
+
commands: Command[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface MenuItem {
|
|
113
|
+
id: string;
|
|
114
|
+
label: string;
|
|
115
|
+
desc: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Default-command guess for the include/default pickers: dev/start commands, or
|
|
119
|
+
// everything when none look like a start script.
|
|
120
|
+
function guessDefaultIds(cmds: Command[]): string[] {
|
|
121
|
+
const ids = cmds
|
|
122
|
+
.filter((c) => {
|
|
123
|
+
const base = c.label.split(":").pop();
|
|
124
|
+
return base === "dev" || base === "start";
|
|
125
|
+
})
|
|
126
|
+
.map((c) => c.id);
|
|
127
|
+
return ids.length ? ids : cmds.map((c) => c.id);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function sectionLabelFor(id: string): string {
|
|
131
|
+
if (id === "pinned") return "* PINNED";
|
|
132
|
+
return id === "manual" ? "PROJECTS" : `SCANNED | ${basename(id) || id}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Pad/truncate to exactly `w` chars, always leaving >=1 trailing space as a gap.
|
|
136
|
+
// When truncating, "..." (3 chars) replaces the tail, kept within w-1.
|
|
137
|
+
const fit = (s: string, w: number) => {
|
|
138
|
+
if (s.length <= w - 1) return s.padEnd(w);
|
|
139
|
+
return (s.slice(0, Math.max(0, w - 4)) + "...").padEnd(w);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// A section header (PROJECTS / SCANNED | x / ★ PINNED) that also labels the
|
|
143
|
+
// scripts column on its right, aligned to where each row's "▸ scripts" begins
|
|
144
|
+
// (the 2-char row gutter + the name column width). No separate header row.
|
|
145
|
+
function sectionHeader(id: string, nameWidth: number): string {
|
|
146
|
+
const label = sectionLabelFor(id);
|
|
147
|
+
const pad = Math.max(2 + nameWidth, label.length + 2);
|
|
148
|
+
return label.padEnd(pad) + "SCRIPTS";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function ProjectRow({ p, selected, nameWidth }: { p: Project; selected: boolean; nameWidth: number }) {
|
|
152
|
+
const theme = useTheme();
|
|
153
|
+
const defs = defaultCommands(p)
|
|
154
|
+
.map((c) => c.label)
|
|
155
|
+
.join(", ");
|
|
156
|
+
return (
|
|
157
|
+
<text>
|
|
158
|
+
<span fg={selected ? theme.selFg : theme.fg}>{fit(p.name, nameWidth)}</span>
|
|
159
|
+
<span fg={theme.dim}>{`> ${defs}`}</span>
|
|
160
|
+
</text>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function CommandRow({ c, selected }: { c: Command; selected: boolean }) {
|
|
165
|
+
const theme = useTheme();
|
|
166
|
+
return (
|
|
167
|
+
<text>
|
|
168
|
+
<span fg={selected ? theme.selFg : theme.fg}>{fit(c.label, 26)}</span>
|
|
169
|
+
<span fg={theme.dim}>{c.command}</span>
|
|
170
|
+
</text>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function MenuRow({ m, selected }: { m: MenuItem; selected: boolean }) {
|
|
175
|
+
const theme = useTheme();
|
|
176
|
+
return (
|
|
177
|
+
<text>
|
|
178
|
+
<span fg={selected ? theme.selFg : theme.fg}>{m.label.padEnd(28)}</span>
|
|
179
|
+
<span fg={theme.dim}>{m.desc}</span>
|
|
180
|
+
</text>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const ADD_MENU: MenuItem[] = [
|
|
185
|
+
{ id: "auto", label: "Auto-detect from a folder", desc: "find scripts in package.json, go.mod, Cargo.toml, pyproject.toml" },
|
|
186
|
+
{ id: "manual", label: "Add manually", desc: "enter a name and commands by hand" },
|
|
187
|
+
{ id: "scan", label: "Add a scan folder", desc: "auto-list every project under a folder" },
|
|
188
|
+
];
|
|
189
|
+
|
|
190
|
+
const EDIT_MENU: MenuItem[] = [
|
|
191
|
+
{ id: "rename", label: "Rename", desc: "change the project name" },
|
|
192
|
+
{ id: "default", label: "Set default command(s)", desc: "what Enter starts" },
|
|
193
|
+
{ id: "remove", label: "Remove a command", desc: "drop one or more commands" },
|
|
194
|
+
{ id: "back", label: "Back", desc: "return to the project list" },
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
export function LaunchScreen({ onChoose }: { onChoose: (cmds: Command[] | null) => void }) {
|
|
198
|
+
const theme = useTheme();
|
|
199
|
+
const [cfg, setCfg] = useState(() => loadLaunch());
|
|
200
|
+
// Raw scanned projects (expensive filesystem read) - refreshed only on mount,
|
|
201
|
+
// `r`, and structural changes (add/edit/delete/scan roots). Pin/reorder/sort
|
|
202
|
+
// never re-scan; they just re-order this list in memory, so they're instant.
|
|
203
|
+
const [raw, setRaw] = useState<Project[]>(() => scanProjects());
|
|
204
|
+
const [mode, setMode] = useState<Mode>("list");
|
|
205
|
+
const [showHelp, setShowHelp] = useState(false);
|
|
206
|
+
const [status, setStatus] = useState("");
|
|
207
|
+
const [seq, setSeq] = useState(0); // bumped on transitions to remount TextPrompt
|
|
208
|
+
const [target, setTarget] = useState<Project | null>(null);
|
|
209
|
+
const [scanTarget, setScanTarget] = useState<ScanRoot | null>(null);
|
|
210
|
+
const [draft, setDraft] = useState<Draft>({ name: "", commands: [] });
|
|
211
|
+
const [pendingCmd, setPendingCmd] = useState({ label: "", command: "", cwd: "" });
|
|
212
|
+
|
|
213
|
+
const projects = useMemo(() => orderProjects(raw, cfg), [raw, cfg]);
|
|
214
|
+
|
|
215
|
+
// Width of the NAME column so the "▸ defaults" column lines up across all
|
|
216
|
+
// rows; sized to the longest name (capped) plus a 2-space gap.
|
|
217
|
+
const nameWidth = useMemo(
|
|
218
|
+
() => Math.min(32, Math.max(12, ...projects.map((p) => p.name.length))) + 2,
|
|
219
|
+
[projects],
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
// cfg/raw mirrored in refs so rapid keypresses read the latest value without
|
|
223
|
+
// waiting for a re-render (otherwise spamming [ / ] would use stale state).
|
|
224
|
+
const cfgRef = useRef(cfg);
|
|
225
|
+
cfgRef.current = cfg;
|
|
226
|
+
const rawRef = useRef(raw);
|
|
227
|
+
rawRef.current = raw;
|
|
228
|
+
|
|
229
|
+
// Persistence is debounced off the keypress path: pin/reorder/sort update state
|
|
230
|
+
// instantly (so the highlight follows immediately) and the JSON file is written
|
|
231
|
+
// ~400ms after the last change. Writing on every keypress is what caused the
|
|
232
|
+
// intermittent lag (Windows file scanning blocking the render thread).
|
|
233
|
+
const persistTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
234
|
+
const pending = useRef<LaunchConfig | null>(null);
|
|
235
|
+
const flushPersist = () => {
|
|
236
|
+
if (persistTimer.current) {
|
|
237
|
+
clearTimeout(persistTimer.current);
|
|
238
|
+
persistTimer.current = null;
|
|
239
|
+
}
|
|
240
|
+
if (pending.current) {
|
|
241
|
+
saveLaunch(pending.current);
|
|
242
|
+
pending.current = null;
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
const schedulePersist = (next: LaunchConfig) => {
|
|
246
|
+
pending.current = next;
|
|
247
|
+
if (persistTimer.current) clearTimeout(persistTimer.current);
|
|
248
|
+
persistTimer.current = setTimeout(flushPersist, 400);
|
|
249
|
+
};
|
|
250
|
+
// Apply an in-memory config change instantly and persist it lazily.
|
|
251
|
+
const applyCfg = (next: LaunchConfig) => {
|
|
252
|
+
cfgRef.current = next;
|
|
253
|
+
setCfg(next);
|
|
254
|
+
schedulePersist(next);
|
|
255
|
+
};
|
|
256
|
+
// Flush any pending write when the screen unmounts.
|
|
257
|
+
useEffect(() => () => flushPersist(), []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
258
|
+
|
|
259
|
+
// Expensive: re-read config AND re-scan the filesystem (structural changes).
|
|
260
|
+
// Flush first so the fresh read includes any pending in-memory edits.
|
|
261
|
+
const rescan = () => {
|
|
262
|
+
flushPersist();
|
|
263
|
+
const c = loadLaunch();
|
|
264
|
+
cfgRef.current = c;
|
|
265
|
+
setCfg(c);
|
|
266
|
+
const r = scanProjects(c);
|
|
267
|
+
rawRef.current = r;
|
|
268
|
+
setRaw(r);
|
|
269
|
+
};
|
|
270
|
+
// Run a disk-mutating core op (add/edit/delete/scan root) then re-scan.
|
|
271
|
+
const structural = (fn: () => void) => {
|
|
272
|
+
flushPersist();
|
|
273
|
+
fn();
|
|
274
|
+
rescan();
|
|
275
|
+
};
|
|
276
|
+
const go = (next: Mode) => {
|
|
277
|
+
// Persist any pending in-memory edits before a screen transition, so the
|
|
278
|
+
// disk-reading flows (add/edit/scan) below never read stale config.
|
|
279
|
+
flushPersist();
|
|
280
|
+
setSeq((s) => s + 1);
|
|
281
|
+
setMode(next);
|
|
282
|
+
};
|
|
283
|
+
const promptKey = `${mode}-${seq}`;
|
|
284
|
+
|
|
285
|
+
// ---- pin / sort / reorder: instant in-memory edits (debounced persist) ----
|
|
286
|
+
const togglePinUI = (p: Project) => {
|
|
287
|
+
const prev = cfgRef.current;
|
|
288
|
+
const pinned = new Set(prev.pinned ?? []);
|
|
289
|
+
pinned.has(p.id) ? pinned.delete(p.id) : pinned.add(p.id);
|
|
290
|
+
applyCfg({ ...prev, pinned: [...pinned] });
|
|
291
|
+
};
|
|
292
|
+
const toggleSortUI = () => {
|
|
293
|
+
const prev = cfgRef.current;
|
|
294
|
+
const next: SortMode = prev.sortMode === "recent" ? "manual" : "recent";
|
|
295
|
+
applyCfg({ ...prev, sortMode: next });
|
|
296
|
+
setStatus(`Sort: ${next === "recent" ? "last run" : "manual order"}.`);
|
|
297
|
+
};
|
|
298
|
+
// Move `item` one step in `dir` within its section. ListSelect already confined
|
|
299
|
+
// it to a section and moved the cursor; we just rewrite the saved order.
|
|
300
|
+
const reorderUI = (item: Project, dir: -1 | 1) => {
|
|
301
|
+
const prev = cfgRef.current;
|
|
302
|
+
const ordered = orderProjects(rawRef.current, prev);
|
|
303
|
+
const idx = ordered.findIndex((p) => p.id === item.id);
|
|
304
|
+
const j = idx + dir;
|
|
305
|
+
if (idx < 0 || j < 0 || j >= ordered.length) return;
|
|
306
|
+
const next = [...ordered];
|
|
307
|
+
[next[idx], next[j]] = [next[j]!, next[idx]!];
|
|
308
|
+
applyCfg({ ...prev, sortMode: "manual", order: next.map((p) => p.id) });
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
// Launch a set of commands for a project, remembering them as its last run.
|
|
312
|
+
const launch = (p: Project, cmds: Command[]) => {
|
|
313
|
+
if (!cmds.length) return;
|
|
314
|
+
const prev = cfgRef.current; // already holds any unpersisted pin/reorder/sort
|
|
315
|
+
if (persistTimer.current) clearTimeout(persistTimer.current);
|
|
316
|
+
pending.current = null;
|
|
317
|
+
saveLaunch({
|
|
318
|
+
...prev,
|
|
319
|
+
lastRun: { ...(prev.lastRun ?? {}), [p.id]: cmds.map((c) => c.label) },
|
|
320
|
+
lastRunAt: { ...(prev.lastRunAt ?? {}), [p.id]: Date.now() },
|
|
321
|
+
});
|
|
322
|
+
onChoose(cmds);
|
|
323
|
+
};
|
|
324
|
+
// ---- run a project's default command(s) ----
|
|
325
|
+
const runProject = (p: Project) => launch(p, defaultCommands(p));
|
|
326
|
+
|
|
327
|
+
// Commands to pre-mark in the `a` picker: the last run if any, else defaults.
|
|
328
|
+
const lastRunIds = (p: Project) => {
|
|
329
|
+
const labels = lastRunLabels(p.id, cfg);
|
|
330
|
+
const ids = labels.length
|
|
331
|
+
? p.commands.filter((c) => labels.includes(c.label)).map((c) => c.id)
|
|
332
|
+
: [];
|
|
333
|
+
return ids.length ? ids : defaultCommands(p).map((c) => c.id);
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// ---- help (its own full page, not an overlay) ----
|
|
337
|
+
if (showHelp) {
|
|
338
|
+
const bindings = mode === "scan" ? SCAN_HELP : HELP;
|
|
339
|
+
const title = mode === "scan" ? "scan folders - keys" : "launch - keys";
|
|
340
|
+
return <Help title={title} bindings={bindings} onClose={() => setShowHelp(false)} />;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ---- main list ----
|
|
344
|
+
if (mode === "list" || mode === "delete") {
|
|
345
|
+
return (
|
|
346
|
+
<box style={{ padding: 1, flexDirection: "column" }}>
|
|
347
|
+
<Header
|
|
348
|
+
title="launch"
|
|
349
|
+
subtitle={`sort: ${cfg.sortMode === "recent" ? "last run" : "manual order"}`}
|
|
350
|
+
hint="j/k move | enter run | a pick | p pin | [ ]/o sort | n/e/d project | s scan | h help"
|
|
351
|
+
/>
|
|
352
|
+
{status ? <text fg={theme.green}>{status}</text> : null}
|
|
353
|
+
<ListSelect
|
|
354
|
+
items={projects}
|
|
355
|
+
getKey={(p) => p.id}
|
|
356
|
+
filterText={(p) => `${p.name} ${p.commands.map((c) => c.label).join(" ")}`}
|
|
357
|
+
active={mode === "list" && !showHelp}
|
|
358
|
+
onReorder={reorderUI}
|
|
359
|
+
sectionOf={(p) => (p.pinned ? "pinned" : (p.source ?? "manual"))}
|
|
360
|
+
sectionLabel={(id) => sectionHeader(id, nameWidth)}
|
|
361
|
+
emptyText="No projects yet - press n to add one, or s to add a scan folder."
|
|
362
|
+
onSubmit={(items) => {
|
|
363
|
+
const p = items[0];
|
|
364
|
+
if (p) runProject(p);
|
|
365
|
+
}}
|
|
366
|
+
onCancel={() => {
|
|
367
|
+
flushPersist();
|
|
368
|
+
onChoose(null);
|
|
369
|
+
}}
|
|
370
|
+
onExtraKey={(name, current) => {
|
|
371
|
+
if (name === "h") {
|
|
372
|
+
setShowHelp(true);
|
|
373
|
+
} else if (name === "r") {
|
|
374
|
+
rescan();
|
|
375
|
+
setStatus("Rescanned.");
|
|
376
|
+
} else if (name === "o") {
|
|
377
|
+
toggleSortUI();
|
|
378
|
+
} else if (name === "n") {
|
|
379
|
+
go("add");
|
|
380
|
+
} else if (name === "s") {
|
|
381
|
+
go("scan");
|
|
382
|
+
} else if (name === "a" && current) {
|
|
383
|
+
setTarget(current);
|
|
384
|
+
go("run");
|
|
385
|
+
} else if (name === "p" && current) {
|
|
386
|
+
togglePinUI(current);
|
|
387
|
+
setStatus(current.pinned ? `Unpinned ${current.name}.` : `Pinned ${current.name}.`);
|
|
388
|
+
} else if (name === "e" && current) {
|
|
389
|
+
// Scanned projects are regenerated from disk each run, so adopt
|
|
390
|
+
// the project into the config first so edits persist.
|
|
391
|
+
if (current.source === "manual") {
|
|
392
|
+
setTarget(current);
|
|
393
|
+
go("edit");
|
|
394
|
+
} else {
|
|
395
|
+
let adopted: Project | null = null;
|
|
396
|
+
structural(() => {
|
|
397
|
+
adopted = adoptProject(current);
|
|
398
|
+
});
|
|
399
|
+
if (adopted) {
|
|
400
|
+
setTarget(adopted);
|
|
401
|
+
setStatus(`Adopted "${(adopted as Project).name}" into your config for editing.`);
|
|
402
|
+
go("edit");
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
} else if (name === "d" && current) {
|
|
406
|
+
setTarget(current);
|
|
407
|
+
go("delete");
|
|
408
|
+
}
|
|
409
|
+
}}
|
|
410
|
+
renderRow={(p, { selected }) => (
|
|
411
|
+
<ProjectRow p={p} selected={selected} nameWidth={nameWidth} />
|
|
412
|
+
)}
|
|
413
|
+
/>
|
|
414
|
+
{mode === "delete" && target ? (
|
|
415
|
+
<Confirm
|
|
416
|
+
message={
|
|
417
|
+
target.source && target.source !== "manual"
|
|
418
|
+
? `Hide scanned project "${target.name}"? (adds it to the scan-root exclude list - your files are untouched)`
|
|
419
|
+
: `Delete project "${target.name}"?`
|
|
420
|
+
}
|
|
421
|
+
onConfirm={() => {
|
|
422
|
+
structural(() => {
|
|
423
|
+
if (target.source && target.source !== "manual") {
|
|
424
|
+
const folder = scanFolder(target) ?? target.name;
|
|
425
|
+
excludeFromScan(target.source, folder);
|
|
426
|
+
setStatus(`Hid ${target.name} from the scan.`);
|
|
427
|
+
} else {
|
|
428
|
+
removeProject(target.id);
|
|
429
|
+
setStatus(`Deleted ${target.name}.`);
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
setTarget(null);
|
|
433
|
+
setMode("list");
|
|
434
|
+
}}
|
|
435
|
+
onCancel={() => setMode("list")}
|
|
436
|
+
/>
|
|
437
|
+
) : null}
|
|
438
|
+
</box>
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ---- ad-hoc run picker (a) - pre-marks your last run (else the defaults) ----
|
|
443
|
+
if (mode === "run" && target) {
|
|
444
|
+
const preMarked = lastRunIds(target);
|
|
445
|
+
const remembered = lastRunLabels(target.id, cfg).length > 0;
|
|
446
|
+
return (
|
|
447
|
+
<Frame
|
|
448
|
+
title={`run: ${target.name}`}
|
|
449
|
+
hint={`space/tab select | enter run | q/esc back${remembered ? " | last run pre-selected" : ""}`}
|
|
450
|
+
>
|
|
451
|
+
<ListSelect
|
|
452
|
+
items={target.commands}
|
|
453
|
+
getKey={(c) => c.id}
|
|
454
|
+
filterText={(c) => `${c.label} ${c.command}`}
|
|
455
|
+
multiSelect
|
|
456
|
+
immediateCancel
|
|
457
|
+
initialMarked={preMarked}
|
|
458
|
+
emptyText="This project has no commands."
|
|
459
|
+
onSubmit={(cmds) => launch(target, cmds)}
|
|
460
|
+
onCancel={() => go("list")}
|
|
461
|
+
renderRow={(c, { selected }) => <CommandRow c={c} selected={selected} />}
|
|
462
|
+
/>
|
|
463
|
+
</Frame>
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ---- add menu (n) ----
|
|
468
|
+
if (mode === "add") {
|
|
469
|
+
return (
|
|
470
|
+
<Frame title="add a project" hint="enter choose | q/esc back">
|
|
471
|
+
<ListSelect
|
|
472
|
+
items={ADD_MENU}
|
|
473
|
+
getKey={(m) => m.id}
|
|
474
|
+
filterText={(m) => m.label}
|
|
475
|
+
immediateCancel
|
|
476
|
+
onSubmit={(items) => {
|
|
477
|
+
const choice = items[0]?.id;
|
|
478
|
+
if (choice === "auto") {
|
|
479
|
+
setDraft({ name: "", commands: [] });
|
|
480
|
+
go("auto-path");
|
|
481
|
+
} else if (choice === "manual") {
|
|
482
|
+
setDraft({ name: "", commands: [] });
|
|
483
|
+
go("manual-name");
|
|
484
|
+
} else if (choice === "scan") {
|
|
485
|
+
go("scan-add");
|
|
486
|
+
}
|
|
487
|
+
}}
|
|
488
|
+
onCancel={() => go("list")}
|
|
489
|
+
renderRow={(m, { selected }) => <MenuRow m={m} selected={selected} />}
|
|
490
|
+
/>
|
|
491
|
+
</Frame>
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ---- auto-detect flow ----
|
|
496
|
+
if (mode === "auto-path") {
|
|
497
|
+
return (
|
|
498
|
+
<Frame title="auto-detect a project">
|
|
499
|
+
<TextPrompt
|
|
500
|
+
key={promptKey}
|
|
501
|
+
label="Folder to scan for a project"
|
|
502
|
+
placeholder="C:\\path\\to\\project"
|
|
503
|
+
onSubmit={(path) => {
|
|
504
|
+
const dir = path.trim();
|
|
505
|
+
if (!dir) return;
|
|
506
|
+
const cmds = detectProjectCommands(dir);
|
|
507
|
+
if (!cmds.length) {
|
|
508
|
+
setStatus(`No runnable manifest found in ${dir}.`);
|
|
509
|
+
go("list");
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
setDraft({ name: suggestName(dir), commands: cmds });
|
|
513
|
+
go("auto-pick");
|
|
514
|
+
}}
|
|
515
|
+
onCancel={() => go("add")}
|
|
516
|
+
/>
|
|
517
|
+
</Frame>
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (mode === "auto-pick") {
|
|
522
|
+
return (
|
|
523
|
+
<Frame title="pick commands to include" hint="space/tab toggle | enter keep | q/esc back">
|
|
524
|
+
<ListSelect
|
|
525
|
+
items={draft.commands}
|
|
526
|
+
getKey={(c) => c.id}
|
|
527
|
+
filterText={(c) => `${c.label} ${c.command}`}
|
|
528
|
+
multiSelect
|
|
529
|
+
immediateCancel
|
|
530
|
+
initialMarked={draft.commands.map((c) => c.id)}
|
|
531
|
+
onSubmit={(cmds) => {
|
|
532
|
+
setDraft((d) => ({ ...d, commands: cmds }));
|
|
533
|
+
go("auto-name");
|
|
534
|
+
}}
|
|
535
|
+
onCancel={() => go("add")}
|
|
536
|
+
renderRow={(c, { selected }) => <CommandRow c={c} selected={selected} />}
|
|
537
|
+
/>
|
|
538
|
+
</Frame>
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (mode === "auto-name") {
|
|
543
|
+
return (
|
|
544
|
+
<Frame title="project name">
|
|
545
|
+
<TextPrompt
|
|
546
|
+
key={promptKey}
|
|
547
|
+
label="Project name"
|
|
548
|
+
initial={draft.name}
|
|
549
|
+
onSubmit={(name) => {
|
|
550
|
+
setDraft((d) => ({ ...d, name: name.trim() || d.name }));
|
|
551
|
+
go("auto-default");
|
|
552
|
+
}}
|
|
553
|
+
onCancel={() => go("auto-pick")}
|
|
554
|
+
/>
|
|
555
|
+
</Frame>
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (mode === "auto-default") {
|
|
560
|
+
return (
|
|
561
|
+
<Frame title="default command(s) - what Enter starts" hint="space/tab toggle | enter save | q/esc back">
|
|
562
|
+
<ListSelect
|
|
563
|
+
items={draft.commands}
|
|
564
|
+
getKey={(c) => c.id}
|
|
565
|
+
filterText={(c) => `${c.label} ${c.command}`}
|
|
566
|
+
multiSelect
|
|
567
|
+
immediateCancel
|
|
568
|
+
initialMarked={guessDefaultIds(draft.commands)}
|
|
569
|
+
onSubmit={(defs) => {
|
|
570
|
+
const commands = draft.commands.map((c) => ({
|
|
571
|
+
...c,
|
|
572
|
+
isDefault: defs.some((d) => d.id === c.id),
|
|
573
|
+
}));
|
|
574
|
+
addProject({ id: newId(), name: draft.name, commands });
|
|
575
|
+
rescan();
|
|
576
|
+
setStatus(`Added ${draft.name}.`);
|
|
577
|
+
go("list");
|
|
578
|
+
}}
|
|
579
|
+
onCancel={() => go("auto-name")}
|
|
580
|
+
renderRow={(c, { selected }) => <CommandRow c={c} selected={selected} />}
|
|
581
|
+
/>
|
|
582
|
+
</Frame>
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ---- manual flow ----
|
|
587
|
+
if (mode === "manual-name") {
|
|
588
|
+
return (
|
|
589
|
+
<Frame title="add a project manually">
|
|
590
|
+
<TextPrompt
|
|
591
|
+
key={promptKey}
|
|
592
|
+
label="Project name"
|
|
593
|
+
initial={draft.name}
|
|
594
|
+
onSubmit={(name) => {
|
|
595
|
+
setDraft((d) => ({ ...d, name: name.trim() || "project" }));
|
|
596
|
+
go("manual-label");
|
|
597
|
+
}}
|
|
598
|
+
onCancel={() => go("add")}
|
|
599
|
+
/>
|
|
600
|
+
</Frame>
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (mode === "manual-label") {
|
|
605
|
+
return (
|
|
606
|
+
<Frame title={`${draft.name} - add a command`}>
|
|
607
|
+
<TextPrompt
|
|
608
|
+
key={promptKey}
|
|
609
|
+
label="Command label (e.g. dev, server)"
|
|
610
|
+
onSubmit={(label) => {
|
|
611
|
+
setPendingCmd({ label: label.trim() || "run", command: "", cwd: "" });
|
|
612
|
+
go("manual-command");
|
|
613
|
+
}}
|
|
614
|
+
onCancel={() => go(draft.commands.length ? "manual-more" : "add")}
|
|
615
|
+
/>
|
|
616
|
+
</Frame>
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (mode === "manual-command") {
|
|
621
|
+
return (
|
|
622
|
+
<Frame title={`${draft.name} - add a command`}>
|
|
623
|
+
<TextPrompt
|
|
624
|
+
key={promptKey}
|
|
625
|
+
label="Shell command (e.g. bun run dev)"
|
|
626
|
+
onSubmit={(command) => {
|
|
627
|
+
setPendingCmd((c) => ({ ...c, command: command.trim() }));
|
|
628
|
+
go("manual-cwd");
|
|
629
|
+
}}
|
|
630
|
+
onCancel={() => go("manual-label")}
|
|
631
|
+
/>
|
|
632
|
+
</Frame>
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (mode === "manual-cwd") {
|
|
637
|
+
return (
|
|
638
|
+
<Frame title={`${draft.name} - add a command`}>
|
|
639
|
+
<TextPrompt
|
|
640
|
+
key={promptKey}
|
|
641
|
+
label="Working directory"
|
|
642
|
+
initial={process.cwd()}
|
|
643
|
+
onSubmit={(cwd) => {
|
|
644
|
+
const dir = cwd.trim() || process.cwd();
|
|
645
|
+
setDraft((d) => ({
|
|
646
|
+
...d,
|
|
647
|
+
commands: [...d.commands, newCommand(pendingCmd.label, pendingCmd.command, dir)],
|
|
648
|
+
}));
|
|
649
|
+
go("manual-more");
|
|
650
|
+
}}
|
|
651
|
+
onCancel={() => go("manual-command")}
|
|
652
|
+
/>
|
|
653
|
+
</Frame>
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (mode === "manual-more") {
|
|
658
|
+
return (
|
|
659
|
+
<Frame title={`${draft.name} - ${draft.commands.length} command(s)`}>
|
|
660
|
+
<Confirm
|
|
661
|
+
message="Add another command?"
|
|
662
|
+
onConfirm={() => go("manual-label")}
|
|
663
|
+
onCancel={() => go(draft.commands.length ? "manual-default" : "list")}
|
|
664
|
+
/>
|
|
665
|
+
</Frame>
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (mode === "manual-default") {
|
|
670
|
+
return (
|
|
671
|
+
<Frame title="default command(s) - what Enter starts" hint="space/tab toggle | enter save | q/esc back">
|
|
672
|
+
<ListSelect
|
|
673
|
+
items={draft.commands}
|
|
674
|
+
getKey={(c) => c.id}
|
|
675
|
+
filterText={(c) => `${c.label} ${c.command}`}
|
|
676
|
+
multiSelect
|
|
677
|
+
immediateCancel
|
|
678
|
+
initialMarked={guessDefaultIds(draft.commands)}
|
|
679
|
+
onSubmit={(defs) => {
|
|
680
|
+
const commands = draft.commands.map((c) => ({
|
|
681
|
+
...c,
|
|
682
|
+
isDefault: defs.some((d) => d.id === c.id),
|
|
683
|
+
}));
|
|
684
|
+
addProject({ id: newId(), name: draft.name, commands });
|
|
685
|
+
rescan();
|
|
686
|
+
setStatus(`Added ${draft.name}.`);
|
|
687
|
+
go("list");
|
|
688
|
+
}}
|
|
689
|
+
onCancel={() => go("manual-more")}
|
|
690
|
+
renderRow={(c, { selected }) => <CommandRow c={c} selected={selected} />}
|
|
691
|
+
/>
|
|
692
|
+
</Frame>
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// ---- edit flow ----
|
|
697
|
+
if (mode === "edit" && target) {
|
|
698
|
+
return (
|
|
699
|
+
<Frame title={`edit: ${target.name}`} hint="enter choose | q/esc back">
|
|
700
|
+
<ListSelect
|
|
701
|
+
items={EDIT_MENU}
|
|
702
|
+
getKey={(m) => m.id}
|
|
703
|
+
filterText={(m) => m.label}
|
|
704
|
+
immediateCancel
|
|
705
|
+
onSubmit={(items) => {
|
|
706
|
+
const choice = items[0]?.id;
|
|
707
|
+
if (choice === "rename") go("edit-rename");
|
|
708
|
+
else if (choice === "default") go("edit-default");
|
|
709
|
+
else if (choice === "remove") go("edit-remove");
|
|
710
|
+
else go("list");
|
|
711
|
+
}}
|
|
712
|
+
onCancel={() => go("list")}
|
|
713
|
+
renderRow={(m, { selected }) => <MenuRow m={m} selected={selected} />}
|
|
714
|
+
/>
|
|
715
|
+
</Frame>
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (mode === "edit-rename" && target) {
|
|
720
|
+
return (
|
|
721
|
+
<Frame title={`rename: ${target.name}`}>
|
|
722
|
+
<TextPrompt
|
|
723
|
+
key={promptKey}
|
|
724
|
+
label="New project name"
|
|
725
|
+
initial={target.name}
|
|
726
|
+
onSubmit={(name) => {
|
|
727
|
+
const updated = { ...target, name: name.trim() || target.name };
|
|
728
|
+
updateProject(updated);
|
|
729
|
+
setTarget(updated);
|
|
730
|
+
rescan();
|
|
731
|
+
go("edit");
|
|
732
|
+
}}
|
|
733
|
+
onCancel={() => go("edit")}
|
|
734
|
+
/>
|
|
735
|
+
</Frame>
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
if (mode === "edit-default" && target) {
|
|
740
|
+
return (
|
|
741
|
+
<Frame title={`${target.name} - default command(s)`} hint="space/tab toggle | enter save | q/esc back">
|
|
742
|
+
<ListSelect
|
|
743
|
+
items={target.commands}
|
|
744
|
+
getKey={(c) => c.id}
|
|
745
|
+
filterText={(c) => `${c.label} ${c.command}`}
|
|
746
|
+
multiSelect
|
|
747
|
+
immediateCancel
|
|
748
|
+
initialMarked={target.commands.filter((c) => c.isDefault).map((c) => c.id)}
|
|
749
|
+
onSubmit={(defs) => {
|
|
750
|
+
const updated = {
|
|
751
|
+
...target,
|
|
752
|
+
commands: target.commands.map((c) => ({
|
|
753
|
+
...c,
|
|
754
|
+
isDefault: defs.some((d) => d.id === c.id),
|
|
755
|
+
})),
|
|
756
|
+
};
|
|
757
|
+
updateProject(updated);
|
|
758
|
+
setTarget(updated);
|
|
759
|
+
rescan();
|
|
760
|
+
go("edit");
|
|
761
|
+
}}
|
|
762
|
+
onCancel={() => go("edit")}
|
|
763
|
+
renderRow={(c, { selected }) => <CommandRow c={c} selected={selected} />}
|
|
764
|
+
/>
|
|
765
|
+
</Frame>
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (mode === "edit-remove" && target) {
|
|
770
|
+
return (
|
|
771
|
+
<Frame title={`${target.name} - remove command(s)`} hint="space/tab mark | enter remove | q/esc back">
|
|
772
|
+
<ListSelect
|
|
773
|
+
items={target.commands}
|
|
774
|
+
getKey={(c) => c.id}
|
|
775
|
+
filterText={(c) => `${c.label} ${c.command}`}
|
|
776
|
+
multiSelect
|
|
777
|
+
immediateCancel
|
|
778
|
+
emptyText="No commands to remove."
|
|
779
|
+
onSubmit={(toRemove) => {
|
|
780
|
+
const updated = {
|
|
781
|
+
...target,
|
|
782
|
+
commands: target.commands.filter((c) => !toRemove.some((r) => r.id === c.id)),
|
|
783
|
+
};
|
|
784
|
+
updateProject(updated);
|
|
785
|
+
setTarget(updated);
|
|
786
|
+
rescan();
|
|
787
|
+
go("edit");
|
|
788
|
+
}}
|
|
789
|
+
onCancel={() => go("edit")}
|
|
790
|
+
renderRow={(c, { selected }) => <CommandRow c={c} selected={selected} />}
|
|
791
|
+
/>
|
|
792
|
+
</Frame>
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// ---- scan-root management ----
|
|
797
|
+
if (mode === "scan") {
|
|
798
|
+
const projectCount = (r: ScanRoot) => raw.filter((p) => p.source === r.path).length;
|
|
799
|
+
return (
|
|
800
|
+
<Frame
|
|
801
|
+
title="scan folders"
|
|
802
|
+
hint="enter manage hidden | n add | d remove root | h help | esc back"
|
|
803
|
+
>
|
|
804
|
+
{status ? <text fg={theme.green}>{status}</text> : null}
|
|
805
|
+
<ListSelect
|
|
806
|
+
items={cfg.scanRoots}
|
|
807
|
+
getKey={(r) => r.path}
|
|
808
|
+
immediateCancel
|
|
809
|
+
active={!showHelp}
|
|
810
|
+
filterText={(r) => r.path}
|
|
811
|
+
emptyText="No scan folders yet - press n to add one."
|
|
812
|
+
onSubmit={(items) => {
|
|
813
|
+
const r = items[0];
|
|
814
|
+
if (r) {
|
|
815
|
+
setScanTarget(r);
|
|
816
|
+
go("scan-excludes");
|
|
817
|
+
}
|
|
818
|
+
}}
|
|
819
|
+
onCancel={() => go("list")}
|
|
820
|
+
onExtraKey={(name, current) => {
|
|
821
|
+
if (name === "h") setShowHelp(true);
|
|
822
|
+
else if (name === "n") go("scan-add");
|
|
823
|
+
else if (name === "d" && current) {
|
|
824
|
+
structural(() => removeScanRoot(current.path));
|
|
825
|
+
setStatus(`Removed scan folder ${current.path}.`);
|
|
826
|
+
}
|
|
827
|
+
}}
|
|
828
|
+
renderRow={(r: ScanRoot, { selected }) => {
|
|
829
|
+
const hidden = r.exclude?.length ?? 0;
|
|
830
|
+
return (
|
|
831
|
+
<text>
|
|
832
|
+
<span fg={selected ? theme.selFg : theme.fg}>{r.path}</span>
|
|
833
|
+
<span fg={theme.dim}>
|
|
834
|
+
{` ${projectCount(r)} projects${hidden ? ` | ${hidden} hidden` : ""}`}
|
|
835
|
+
</span>
|
|
836
|
+
</text>
|
|
837
|
+
);
|
|
838
|
+
}}
|
|
839
|
+
/>
|
|
840
|
+
</Frame>
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// Un-hide projects you previously hid (revert a hide / an adopt's exclude).
|
|
845
|
+
if (mode === "scan-excludes" && scanTarget) {
|
|
846
|
+
const root = cfg.scanRoots.find((r) => r.path === scanTarget.path) ?? scanTarget;
|
|
847
|
+
const hidden = root.exclude ?? [];
|
|
848
|
+
return (
|
|
849
|
+
<Frame
|
|
850
|
+
title={`hidden in ${basename(root.path) || root.path}`}
|
|
851
|
+
hint="enter / d un-hide (show again) | esc back"
|
|
852
|
+
>
|
|
853
|
+
<ListSelect
|
|
854
|
+
items={hidden}
|
|
855
|
+
getKey={(f) => f}
|
|
856
|
+
immediateCancel
|
|
857
|
+
filterText={(f) => f}
|
|
858
|
+
emptyText="Nothing hidden here. (Hiding a project, or editing a scanned one, adds it here.)"
|
|
859
|
+
onSubmit={(items) => {
|
|
860
|
+
const f = items[0];
|
|
861
|
+
if (f) {
|
|
862
|
+
structural(() => includeInScan(root.path, f));
|
|
863
|
+
setStatus(`Un-hid ${f} - it'll show again if it has a runnable manifest.`);
|
|
864
|
+
}
|
|
865
|
+
}}
|
|
866
|
+
onCancel={() => go("scan")}
|
|
867
|
+
onExtraKey={(name, current) => {
|
|
868
|
+
if (name === "d" && current) {
|
|
869
|
+
structural(() => includeInScan(root.path, current));
|
|
870
|
+
setStatus(`Un-hid ${current}.`);
|
|
871
|
+
}
|
|
872
|
+
}}
|
|
873
|
+
renderRow={(f: string, { selected }) => (
|
|
874
|
+
<text fg={selected ? theme.selFg : theme.fg}>{f}</text>
|
|
875
|
+
)}
|
|
876
|
+
/>
|
|
877
|
+
</Frame>
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (mode === "scan-add") {
|
|
882
|
+
return (
|
|
883
|
+
<Frame title="add a scan folder">
|
|
884
|
+
<TextPrompt
|
|
885
|
+
key={promptKey}
|
|
886
|
+
label="Folder to auto-scan for projects"
|
|
887
|
+
placeholder="C:\\path\\to\\projects"
|
|
888
|
+
onSubmit={(path) => {
|
|
889
|
+
const dir = path.trim();
|
|
890
|
+
if (dir) {
|
|
891
|
+
structural(() => addScanRoot({ path: dir }));
|
|
892
|
+
setStatus(`Added scan folder ${dir}.`);
|
|
893
|
+
}
|
|
894
|
+
go("scan");
|
|
895
|
+
}}
|
|
896
|
+
onCancel={() => go("scan")}
|
|
897
|
+
/>
|
|
898
|
+
</Frame>
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// Fallback (e.g. a mode that needs a target but lost it): back to the list.
|
|
903
|
+
return (
|
|
904
|
+
<Frame title="launch">
|
|
905
|
+
<text fg={theme.dim}>Returning...</text>
|
|
906
|
+
</Frame>
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Small consistent chrome for the sub-screens.
|
|
911
|
+
function Frame({
|
|
912
|
+
title,
|
|
913
|
+
hint,
|
|
914
|
+
children,
|
|
915
|
+
}: {
|
|
916
|
+
title: string;
|
|
917
|
+
hint?: string;
|
|
918
|
+
children: ReactNode;
|
|
919
|
+
}) {
|
|
920
|
+
return (
|
|
921
|
+
<box style={{ padding: 1, flexDirection: "column" }}>
|
|
922
|
+
<Header title="launch" subtitle={title} hint={hint} />
|
|
923
|
+
{children}
|
|
924
|
+
</box>
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// ---------- CLI entry ----------
|
|
929
|
+
|
|
930
|
+
export async function runLaunch() {
|
|
931
|
+
const argv = process.argv.slice(2);
|
|
932
|
+
if (argv.includes("-h") || argv.includes("--help")) {
|
|
933
|
+
console.log(
|
|
934
|
+
[
|
|
935
|
+
"Usage: launch [name]",
|
|
936
|
+
" launch interactive picker",
|
|
937
|
+
" launch <name> start that project's default command(s)",
|
|
938
|
+
].join("\n"),
|
|
939
|
+
);
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const nameArg = argv.find((a) => !a.startsWith("-"));
|
|
944
|
+
if (nameArg) {
|
|
945
|
+
const p = findProject(nameArg);
|
|
946
|
+
if (!p) {
|
|
947
|
+
console.log(`No project named "${nameArg}". Known projects:`);
|
|
948
|
+
for (const x of allProjects()) console.log(` ${x.name}`);
|
|
949
|
+
process.exit(1);
|
|
950
|
+
}
|
|
951
|
+
const cmds = defaultCommands(p);
|
|
952
|
+
recordLastRun(p.id, cmds.map((c) => c.label));
|
|
953
|
+
console.log(`\nStarting ${p.name} ...\n`);
|
|
954
|
+
for (const c of cmds) console.log(`> ${c.label}: ${c.command} (${c.cwd})`);
|
|
955
|
+
startCommands(cmds);
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
const chosen = await mountScreen<Command[] | null>((done) => <LaunchScreen onChoose={done} />);
|
|
960
|
+
if (!chosen || !chosen.length) {
|
|
961
|
+
process.exit(0);
|
|
962
|
+
}
|
|
963
|
+
// Renderer is torn down by now; hand the terminal to the dev servers.
|
|
964
|
+
console.log(`\nStarting ...\n`);
|
|
965
|
+
for (const c of chosen) console.log(`> ${c.label}: ${c.command} (${c.cwd})`);
|
|
966
|
+
startCommands(chosen);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
if (import.meta.main) {
|
|
970
|
+
await runLaunch();
|
|
971
|
+
}
|