@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.
@@ -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
+ }