@fresh-editor/fresh-editor 0.3.8 → 0.3.10
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 +77 -0
- package/README.md +0 -1
- package/package.json +1 -1
- package/plugins/audit_mode.ts +35 -1
- package/plugins/config-schema.json +9 -3
- package/plugins/dashboard.ts +3 -3
- package/plugins/diagnostics_panel.ts +10 -0
- package/plugins/env-manager.i18n.json +338 -0
- package/plugins/env-manager.ts +23 -33
- package/plugins/examples/bookmarks.ts +3 -2
- package/plugins/lib/finder.ts +101 -17
- package/plugins/lib/fresh.d.ts +100 -42
- package/plugins/lib/widgets.ts +8 -0
- package/plugins/live_diff.ts +12 -1
- package/plugins/live_grep.i18n.json +349 -27
- package/plugins/live_grep.ts +660 -141
- package/plugins/markdown_compose.ts +3 -1
- package/plugins/orchestrator.ts +1493 -504
- package/plugins/schemas/theme.schema.json +15 -2
- package/plugins/search_replace.ts +70 -28
- package/plugins/theme_editor.i18n.json +28 -0
- package/plugins/vi_mode.ts +3 -3
package/plugins/orchestrator.ts
CHANGED
|
@@ -76,6 +76,18 @@ interface AgentSession {
|
|
|
76
76
|
state: AgentState;
|
|
77
77
|
// Wall-clock ms when orchestrator.new fired createWindow.
|
|
78
78
|
createdAt: number;
|
|
79
|
+
// `true` when this row is a worktree discovered on disk (via
|
|
80
|
+
// `git worktree list`) that has no live editor window yet.
|
|
81
|
+
// Discovered rows carry a synthetic negative `id`, no
|
|
82
|
+
// `terminalId`, and dive by *attaching* a new session to
|
|
83
|
+
// `root` rather than switching to an existing window. They are
|
|
84
|
+
// dropped from `orchestratorSessions` the moment a real window
|
|
85
|
+
// is opened at the same `root`.
|
|
86
|
+
discovered?: boolean;
|
|
87
|
+
// Branch checked out in this worktree (best-effort, for
|
|
88
|
+
// display). Set for discovered rows; left undefined for live
|
|
89
|
+
// sessions where the tab/label already carries the identity.
|
|
90
|
+
branch?: string;
|
|
79
91
|
}
|
|
80
92
|
|
|
81
93
|
// =============================================================================
|
|
@@ -84,6 +96,25 @@ interface AgentSession {
|
|
|
84
96
|
|
|
85
97
|
const orchestratorSessions = new Map<number, AgentSession>();
|
|
86
98
|
|
|
99
|
+
// Stable synthetic ids for discovered (on-disk, not-yet-opened)
|
|
100
|
+
// worktrees, keyed by canonical path. Live windows own the
|
|
101
|
+
// positive id space (editor `WindowId`s); discovered rows take
|
|
102
|
+
// negative ids so the two never collide and the existing
|
|
103
|
+
// `orchestratorSessions.get(id)` call sites keep working. Ids
|
|
104
|
+
// stay stable across rescans so the dialog selection doesn't
|
|
105
|
+
// jump when the worktree set is refreshed. `-1` is reserved as a
|
|
106
|
+
// "no selection" sentinel elsewhere, so allocation starts at `-2`.
|
|
107
|
+
const discoveredIdByPath = new Map<string, number>();
|
|
108
|
+
let nextDiscoveredId = -2;
|
|
109
|
+
function discoveredIdFor(path: string): number {
|
|
110
|
+
let id = discoveredIdByPath.get(path);
|
|
111
|
+
if (id === undefined) {
|
|
112
|
+
id = nextDiscoveredId--;
|
|
113
|
+
discoveredIdByPath.set(path, id);
|
|
114
|
+
}
|
|
115
|
+
return id;
|
|
116
|
+
}
|
|
117
|
+
|
|
87
118
|
// New-session form state. `null` ⇒ the floating form isn't
|
|
88
119
|
// open. Each field's `value` + `cursor` mirrors what the host
|
|
89
120
|
// renders inside the panel's TextInput widgets; the `submitting`
|
|
@@ -118,6 +149,14 @@ interface NewSessionForm {
|
|
|
118
149
|
// (checkbox disabled, branch field inert). `null`: probe
|
|
119
150
|
// in flight (keep checkbox in its last-known state).
|
|
120
151
|
projectPathIsGit: boolean | null;
|
|
152
|
+
// `true` when the resolved Project Path is itself an existing
|
|
153
|
+
// *linked* worktree (created by `git worktree add`). In that
|
|
154
|
+
// case leaving "Create a new git worktree" unchecked attaches
|
|
155
|
+
// the session to it as a managed worktree rather than treating
|
|
156
|
+
// it as a shared root. The probe defaults the checkbox to
|
|
157
|
+
// unchecked when it first detects this, and `buildFormSpec`
|
|
158
|
+
// surfaces an explanatory hint. `null` while the probe runs.
|
|
159
|
+
projectPathIsLinkedWorktree: boolean | null;
|
|
121
160
|
// Concrete session name the auto-generator would produce
|
|
122
161
|
// for the current Project Path (e.g. "session-3"). Surfaced
|
|
123
162
|
// as the Session Name placeholder so the user sees the
|
|
@@ -208,10 +247,42 @@ interface OpenDialogState {
|
|
|
208
247
|
// anchor it needs.
|
|
209
248
|
originalActiveSession: number;
|
|
210
249
|
// When non-null, the preview pane swaps to a confirmation
|
|
211
|
-
// panel for the named action against the
|
|
212
|
-
//
|
|
250
|
+
// panel for the named action against the listed session ids.
|
|
251
|
+
// A single-element `ids` is the per-row Stop/Archive/Delete
|
|
252
|
+
// path; a multi-element `ids` is a bulk action over the
|
|
253
|
+
// checkbox selection. Cleared on Cancel or after the action
|
|
254
|
+
// completes.
|
|
213
255
|
pendingConfirm:
|
|
214
|
-
| { action: "stop" | "archive" | "delete";
|
|
256
|
+
| { action: "stop" | "archive" | "delete"; ids: number[] }
|
|
257
|
+
| null;
|
|
258
|
+
// Rows the user has checkbox-selected (Space, or click) for a
|
|
259
|
+
// bulk Stop/Archive/Delete. Holds session ids — positive for
|
|
260
|
+
// live windows, negative for discovered on-disk worktrees
|
|
261
|
+
// (which bulk-delete via `git worktree remove`). Survives filter
|
|
262
|
+
// and scope changes; pruned against the live set on every
|
|
263
|
+
// refresh. Bulk mode (the dedicated selection bar) engages once
|
|
264
|
+
// two or more rows are checked.
|
|
265
|
+
selectedIds: Set<number>;
|
|
266
|
+
// `true` shows the discovered on-disk worktree rows in the list.
|
|
267
|
+
// The "Show all worktrees" checkbox below the scope control toggles
|
|
268
|
+
// it (Alt+T / `orchestrator_toggle_worktrees`). Defaults to false
|
|
269
|
+
// (worktrees hidden) — discovery is opt-in. Remembered across opens
|
|
270
|
+
// via `lastShowWorktrees`.
|
|
271
|
+
showWorktrees: boolean;
|
|
272
|
+
// `true` hides "trivial" sessions — those with no terminal and at
|
|
273
|
+
// most one open file/buffer (empty-unnamed-buffer and single-file
|
|
274
|
+
// shells left behind by one-off editor launches). The "Show
|
|
275
|
+
// empty/1-file sessions" checkbox (Alt+I / `orchestrator_toggle_trivial`)
|
|
276
|
+
// flips it. Defaults to true; remembered across opens via
|
|
277
|
+
// `lastHideTrivial`. The active session and discovered worktree rows
|
|
278
|
+
// are never hidden by this filter regardless of the flag.
|
|
279
|
+
hideTrivial: boolean;
|
|
280
|
+
// Progress marker for an in-flight *bulk* action. While set, the
|
|
281
|
+
// selection bar shows "Archiving 2/3…" and its buttons are
|
|
282
|
+
// hidden so a second Enter can't re-fire mid-batch. Cleared when
|
|
283
|
+
// the batch finishes.
|
|
284
|
+
bulkInFlight:
|
|
285
|
+
| { action: "stop" | "archive" | "delete"; total: number; done: number }
|
|
215
286
|
| null;
|
|
216
287
|
// Rows the embed reserves and rows the sessions list shows.
|
|
217
288
|
// Captured once at dialog-open from the editor's viewport so
|
|
@@ -265,6 +336,92 @@ let openPanel: FloatingWidgetPanel | null = null;
|
|
|
265
336
|
// showing every session; flipping it with the Project control / Alt+P
|
|
266
337
|
// updates this and the next open honours it.
|
|
267
338
|
let lastOpenScope: "current" | "all" = "all";
|
|
339
|
+
// Remembered across opens, like `lastOpenScope`: whether the
|
|
340
|
+
// discovered on-disk worktree rows are shown. Defaults to false
|
|
341
|
+
// (worktrees hidden) — surfacing them is opt-in via "Show all
|
|
342
|
+
// worktrees" (Alt+T).
|
|
343
|
+
let lastShowWorktrees = false;
|
|
344
|
+
// Remembered across opens: whether "trivial" sessions are hidden.
|
|
345
|
+
// Defaults to true — every editor launch on a throwaway directory or a
|
|
346
|
+
// single file leaves a workspace file behind, which restores as a shell
|
|
347
|
+
// window and clutters the list. Hiding them by default keeps the picker
|
|
348
|
+
// focused on real sessions; the "Show empty/1-file sessions" checkbox
|
|
349
|
+
// (Alt+I) reveals them.
|
|
350
|
+
let lastHideTrivial = true;
|
|
351
|
+
|
|
352
|
+
// Per-session content summary keyed by canonical session root, built
|
|
353
|
+
// from the on-disk workspace files. The restored shell windows don't
|
|
354
|
+
// carry their open-tab layout (it's lazily re-warmed on first dive), so
|
|
355
|
+
// the workspace file is the only place to learn how much a session
|
|
356
|
+
// holds. Rebuilt each time the picker opens. A session is "trivial"
|
|
357
|
+
// when it has no terminal and at most one real file/unnamed buffer —
|
|
358
|
+
// the empty-unnamed-buffer and single-file cases the filter targets.
|
|
359
|
+
interface SessionContent {
|
|
360
|
+
files: number;
|
|
361
|
+
hasTerminal: boolean;
|
|
362
|
+
trivial: boolean;
|
|
363
|
+
}
|
|
364
|
+
const sessionContentByRoot = new Map<string, SessionContent>();
|
|
365
|
+
|
|
366
|
+
// Roots from the editor (`WindowInfo.root`) and from workspace files
|
|
367
|
+
// (`working_dir`) are both canonical absolute paths, but normalise a
|
|
368
|
+
// trailing slash so the two always key the same map entry.
|
|
369
|
+
function normRoot(p: string): string {
|
|
370
|
+
return p.length > 1 && p.endsWith("/") ? p.slice(0, -1) : p;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Scan `<dataDir>/workspaces/*.json` and summarise each session's open
|
|
374
|
+
// content. Mirrors the host's own `discover_sessions` (which keys on the
|
|
375
|
+
// file's `working_dir`), so a root matches regardless of how the
|
|
376
|
+
// filename was percent-encoded. Best-effort: unreadable / unparseable
|
|
377
|
+
// files are skipped, and a missing summary is treated as "not trivial"
|
|
378
|
+
// (shown) by the filter, so we never hide a session we couldn't classify.
|
|
379
|
+
function scanSessionContent(): void {
|
|
380
|
+
sessionContentByRoot.clear();
|
|
381
|
+
const dir = editor.pathJoin(editor.getDataDir(), "workspaces");
|
|
382
|
+
let entries: DirEntry[];
|
|
383
|
+
try {
|
|
384
|
+
entries = editor.readDir(dir);
|
|
385
|
+
} catch {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (!entries) return;
|
|
389
|
+
for (const e of entries) {
|
|
390
|
+
if (!e.is_file || !e.name.endsWith(".json")) continue;
|
|
391
|
+
const raw = editor.readFile(editor.pathJoin(dir, e.name));
|
|
392
|
+
if (!raw) continue;
|
|
393
|
+
let ws: Record<string, unknown>;
|
|
394
|
+
try {
|
|
395
|
+
ws = JSON.parse(raw);
|
|
396
|
+
} catch {
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
const wd = ws["working_dir"];
|
|
400
|
+
if (typeof wd !== "string") continue;
|
|
401
|
+
let files = 0;
|
|
402
|
+
let hasTerminal = Array.isArray(ws["terminals"]) &&
|
|
403
|
+
(ws["terminals"] as unknown[]).length > 0;
|
|
404
|
+
const splits = ws["split_states"];
|
|
405
|
+
if (splits && typeof splits === "object") {
|
|
406
|
+
for (const sv of Object.values(splits as Record<string, unknown>)) {
|
|
407
|
+
const tabs = (sv as Record<string, unknown> | null)?.["open_tabs"];
|
|
408
|
+
if (!Array.isArray(tabs)) continue;
|
|
409
|
+
for (const t of tabs) {
|
|
410
|
+
if (t && typeof t === "object") {
|
|
411
|
+
if ("File" in t || "Unnamed" in t) files++;
|
|
412
|
+
else if ("Terminal" in t) hasTerminal = true;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
sessionContentByRoot.set(normRoot(wd), {
|
|
418
|
+
files,
|
|
419
|
+
hasTerminal,
|
|
420
|
+
trivial: !hasTerminal && files <= 1,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
268
425
|
const OPEN_MODE = "orchestrator-open";
|
|
269
426
|
|
|
270
427
|
// =============================================================================
|
|
@@ -298,11 +455,122 @@ function reconcileSessions(): void {
|
|
|
298
455
|
if (s.shared_worktree != null) existing.sharedWorktree = s.shared_worktree;
|
|
299
456
|
}
|
|
300
457
|
}
|
|
458
|
+
// Live windows live in the positive id space; their absence from
|
|
459
|
+
// `listWindows()` means they were closed, so drop them. Discovered
|
|
460
|
+
// worktrees (negative ids) are NOT backed by a window and must
|
|
461
|
+
// survive this sweep — they're pruned separately, against the
|
|
462
|
+
// on-disk worktree set, by `refreshDiscoveredWorktrees`.
|
|
301
463
|
for (const id of orchestratorSessions.keys()) {
|
|
302
|
-
if (!seen.has(id)) orchestratorSessions.delete(id);
|
|
464
|
+
if (id > 0 && !seen.has(id)) orchestratorSessions.delete(id);
|
|
465
|
+
}
|
|
466
|
+
// A worktree that's now open as a live window must not also linger
|
|
467
|
+
// as a discovered row. Drop any discovered entry whose root a live
|
|
468
|
+
// session already occupies.
|
|
469
|
+
const liveRoots = new Set<string>();
|
|
470
|
+
for (const s of orchestratorSessions.values()) {
|
|
471
|
+
if (!s.discovered) liveRoots.add(s.root);
|
|
472
|
+
}
|
|
473
|
+
for (const [id, s] of orchestratorSessions) {
|
|
474
|
+
if (s.discovered && liveRoots.has(s.root)) orchestratorSessions.delete(id);
|
|
303
475
|
}
|
|
304
476
|
}
|
|
305
477
|
|
|
478
|
+
// =============================================================================
|
|
479
|
+
// Discovered-worktree scan
|
|
480
|
+
//
|
|
481
|
+
// Surfaces worktrees that exist on disk but have no live editor
|
|
482
|
+
// window, so the user doesn't have to add them by hand. Because
|
|
483
|
+
// open sessions can span several repos, `git worktree list` must
|
|
484
|
+
// run once *per project*: the scan set is the distinct canonical
|
|
485
|
+
// repo roots of every live session, plus the editor's cwd repo.
|
|
486
|
+
// Each linked worktree not already open (and not an
|
|
487
|
+
// orchestrator-internal tree) becomes a discovered row that dives
|
|
488
|
+
// by attaching a fresh session to it.
|
|
489
|
+
// =============================================================================
|
|
490
|
+
|
|
491
|
+
let discoveryInFlight = false;
|
|
492
|
+
|
|
493
|
+
function isInternalWorktreePath(path: string): boolean {
|
|
494
|
+
// The sync-workspace and the `.archived/` graveyard are
|
|
495
|
+
// orchestrator bookkeeping, not user sessions.
|
|
496
|
+
return path.includes(".sync-workspace") || path.includes("/.archived/");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function refreshDiscoveredWorktrees(): Promise<void> {
|
|
500
|
+
if (discoveryInFlight) return;
|
|
501
|
+
discoveryInFlight = true;
|
|
502
|
+
try {
|
|
503
|
+
reconcileSessions();
|
|
504
|
+
|
|
505
|
+
// (1) Candidate dirs: every live session's root + the editor
|
|
506
|
+
// cwd. Resolve each to its canonical main repo root and
|
|
507
|
+
// dedupe so a repo with N open worktrees is scanned once.
|
|
508
|
+
const candidates = new Set<string>([editor.getCwd()]);
|
|
509
|
+
for (const s of orchestratorSessions.values()) {
|
|
510
|
+
if (!s.discovered) candidates.add(s.root);
|
|
511
|
+
}
|
|
512
|
+
const mainRoots = new Set<string>();
|
|
513
|
+
for (const dir of candidates) {
|
|
514
|
+
const canonical = await resolveCanonicalRepoRoot(dir);
|
|
515
|
+
if (canonical) mainRoots.add(canonical);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// (2) Roots already occupied by a live session — discovered rows
|
|
519
|
+
// for these would be duplicates.
|
|
520
|
+
const liveRoots = new Set<string>();
|
|
521
|
+
for (const s of orchestratorSessions.values()) {
|
|
522
|
+
if (!s.discovered) liveRoots.add(s.root);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// (3) Scan each repo and collect the linked worktrees worth
|
|
526
|
+
// surfacing.
|
|
527
|
+
const foundPaths = new Set<string>();
|
|
528
|
+
for (const repoRoot of mainRoots) {
|
|
529
|
+
const listed = await listLinkedWorktrees(repoRoot);
|
|
530
|
+
if (!listed) continue;
|
|
531
|
+
for (const wt of listed.worktrees) {
|
|
532
|
+
if (liveRoots.has(wt.path)) continue;
|
|
533
|
+
if (isInternalWorktreePath(wt.path)) continue;
|
|
534
|
+
foundPaths.add(wt.path);
|
|
535
|
+
const id = discoveredIdFor(wt.path);
|
|
536
|
+
const label = wt.branch || editor.pathBasename(wt.path);
|
|
537
|
+
const existing = orchestratorSessions.get(id);
|
|
538
|
+
if (existing) {
|
|
539
|
+
existing.label = label;
|
|
540
|
+
existing.root = wt.path;
|
|
541
|
+
existing.projectPath = listed.mainRoot;
|
|
542
|
+
existing.branch = wt.branch;
|
|
543
|
+
} else {
|
|
544
|
+
orchestratorSessions.set(id, {
|
|
545
|
+
id,
|
|
546
|
+
label,
|
|
547
|
+
root: wt.path,
|
|
548
|
+
projectPath: listed.mainRoot,
|
|
549
|
+
sharedWorktree: false,
|
|
550
|
+
terminalId: null,
|
|
551
|
+
state: "ready",
|
|
552
|
+
createdAt: Date.now(),
|
|
553
|
+
discovered: true,
|
|
554
|
+
branch: wt.branch,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// (4) Prune discovered rows that vanished from disk (or got
|
|
561
|
+
// opened, picked up by the liveRoots check above).
|
|
562
|
+
for (const [id, s] of orchestratorSessions) {
|
|
563
|
+
if (s.discovered && !foundPaths.has(s.root)) {
|
|
564
|
+
orchestratorSessions.delete(id);
|
|
565
|
+
discoveredIdByPath.delete(s.root);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
} finally {
|
|
569
|
+
discoveryInFlight = false;
|
|
570
|
+
}
|
|
571
|
+
if (openPanel) refreshOpenDialog();
|
|
572
|
+
}
|
|
573
|
+
|
|
306
574
|
// =============================================================================
|
|
307
575
|
// Session display helpers
|
|
308
576
|
// =============================================================================
|
|
@@ -381,12 +649,38 @@ function projectLabel(key: string): string {
|
|
|
381
649
|
function filterSessions(needle: string): number[] {
|
|
382
650
|
reconcileSessions();
|
|
383
651
|
const scope = openDialog?.scope ?? "current";
|
|
652
|
+
const showWorktrees = openDialog?.showWorktrees ?? false;
|
|
653
|
+
const hideTrivial = openDialog?.hideTrivial ?? false;
|
|
384
654
|
const cur = currentProjectKey();
|
|
385
|
-
|
|
655
|
+
let allIds = Array.from(orchestratorSessions.keys());
|
|
656
|
+
// "Show all worktrees" is opt-in: by default the discovered on-disk
|
|
657
|
+
// worktree rows are filtered out.
|
|
658
|
+
if (!showWorktrees) {
|
|
659
|
+
allIds = allIds.filter((id) => !orchestratorSessions.get(id)!.discovered);
|
|
660
|
+
}
|
|
661
|
+
// "Hide empty/1-file sessions": drop the restored shells that hold no
|
|
662
|
+
// real work. The active session is always kept (you must be able to
|
|
663
|
+
// see where you are), and discovered worktree rows are governed by
|
|
664
|
+
// their own toggle, not this one. A session with no summary (e.g. a
|
|
665
|
+
// freshly created agent session not yet written to disk) is kept too.
|
|
666
|
+
if (hideTrivial) {
|
|
667
|
+
const activeId = editor.activeWindow();
|
|
668
|
+
allIds = allIds.filter((id) => {
|
|
669
|
+
const s = orchestratorSessions.get(id)!;
|
|
670
|
+
if (s.discovered || id === activeId) return true;
|
|
671
|
+
const c = sessionContentByRoot.get(normRoot(s.root));
|
|
672
|
+
return !c || !c.trivial;
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const isDisc = (id: number): number =>
|
|
677
|
+
orchestratorSessions.get(id)!.discovered ? 1 : 0;
|
|
386
678
|
|
|
387
|
-
// Sort by (current-project-first,
|
|
388
|
-
// groups the current project's sessions
|
|
389
|
-
// projects'
|
|
679
|
+
// Sort by (current-project-first, project, live-before-discovered,
|
|
680
|
+
// then id) so an "all" view groups the current project's sessions
|
|
681
|
+
// at the top and other projects' below, and within each project the
|
|
682
|
+
// pre-existing live sessions come first with the discovered on-disk
|
|
683
|
+
// worktrees listed after them.
|
|
390
684
|
const byProjectThenId = (a: number, b: number): number => {
|
|
391
685
|
const sa = orchestratorSessions.get(a)!;
|
|
392
686
|
const sb = orchestratorSessions.get(b)!;
|
|
@@ -396,6 +690,9 @@ function filterSessions(needle: string): number[] {
|
|
|
396
690
|
const ka = projectKeyOf(sa);
|
|
397
691
|
const kb = projectKeyOf(sb);
|
|
398
692
|
if (ka !== kb) return ka < kb ? -1 : 1;
|
|
693
|
+
const da = isDisc(a);
|
|
694
|
+
const db = isDisc(b);
|
|
695
|
+
if (da !== db) return da - db;
|
|
399
696
|
return a - b;
|
|
400
697
|
};
|
|
401
698
|
|
|
@@ -422,25 +719,33 @@ function filterSessions(needle: string): number[] {
|
|
|
422
719
|
matches.push({ id, score: 2, len: label.length });
|
|
423
720
|
}
|
|
424
721
|
}
|
|
425
|
-
|
|
722
|
+
// Live sessions before discovered worktrees at equal relevance, so
|
|
723
|
+
// the on-disk rows still trail the real sessions in search results.
|
|
724
|
+
matches.sort(
|
|
725
|
+
(a, b) =>
|
|
726
|
+
a.score - b.score || isDisc(a.id) - isDisc(b.id) || a.len - b.len ||
|
|
727
|
+
a.id - b.id,
|
|
728
|
+
);
|
|
426
729
|
return matches.map((m) => m.id);
|
|
427
730
|
}
|
|
428
731
|
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
|
|
434
|
-
|
|
732
|
+
// Width of the NAME column before the trailing PROJECT column kicks
|
|
733
|
+
// in (filled only for cross-project rows). Kept in sync with
|
|
734
|
+
// `sessionsColumnHeader`. There is no id column — the numeric window
|
|
735
|
+
// id is an internal handle the user never needs in the list; rows are
|
|
736
|
+
// identified by name, the active one rendered bold and on-disk
|
|
737
|
+
// worktrees flagged with a `· on-disk` tag.
|
|
738
|
+
const LIST_NAME_W = 24;
|
|
435
739
|
|
|
436
|
-
// Header row above the session list: `
|
|
740
|
+
// Header row above the session list: `NAME … PROJECT`.
|
|
437
741
|
function sessionsColumnHeader(): WidgetSpec {
|
|
438
742
|
return {
|
|
439
743
|
kind: "raw",
|
|
440
744
|
entries: [
|
|
441
745
|
styledRow([
|
|
442
746
|
{
|
|
443
|
-
|
|
747
|
+
// 4-space lead aligns under the per-row `[ ] ` checkbox.
|
|
748
|
+
text: " " + "NAME".padEnd(LIST_NAME_W) + "PROJECT",
|
|
444
749
|
style: { fg: "ui.menu_disabled_fg" },
|
|
445
750
|
},
|
|
446
751
|
]),
|
|
@@ -448,39 +753,51 @@ function sessionsColumnHeader(): WidgetSpec {
|
|
|
448
753
|
};
|
|
449
754
|
}
|
|
450
755
|
|
|
451
|
-
// Build one rendered list-item row for `id
|
|
452
|
-
// `[
|
|
453
|
-
// The active session's
|
|
454
|
-
//
|
|
455
|
-
// only for sessions that don't
|
|
756
|
+
// Build one rendered list-item row for `id`:
|
|
757
|
+
// `[ ] ` <name + on-disk tag> <project basename>
|
|
758
|
+
// The active session's name renders bold; discovered (on-disk,
|
|
759
|
+
// unopened) worktrees render dim with a `· on-disk` tag instead of a
|
|
760
|
+
// glyph. The project column is filled only for sessions that don't
|
|
761
|
+
// belong to the current project.
|
|
456
762
|
function renderListItem(id: number, activeId: number): TextPropertyEntry {
|
|
457
763
|
const s = orchestratorSessions.get(id);
|
|
458
764
|
if (!s) {
|
|
459
|
-
return styledRow([{ text:
|
|
765
|
+
return styledRow([{ text: "(unknown)" }]);
|
|
460
766
|
}
|
|
461
767
|
const isActive = id === activeId;
|
|
462
|
-
const
|
|
768
|
+
const isDiscovered = !!s.discovered;
|
|
769
|
+
const isChecked = openDialog?.selectedIds.has(id) ?? false;
|
|
770
|
+
|
|
771
|
+
// Leading multi-select checkbox. `[x]` when this row is in the
|
|
772
|
+
// bulk selection, `[ ]` otherwise — toggled with Space (the
|
|
773
|
+
// rebindable `orchestrator_toggle_select`) or a click.
|
|
774
|
+
const checkbox = {
|
|
775
|
+
text: isChecked ? "[x] " : "[ ] ",
|
|
776
|
+
style: isChecked
|
|
777
|
+
? { fg: "ui.help_key_fg", bold: true }
|
|
778
|
+
: { fg: "ui.menu_disabled_fg" },
|
|
779
|
+
};
|
|
463
780
|
|
|
464
|
-
const idText = `[${id}]`.padEnd(LIST_ID_W);
|
|
465
781
|
const entries: { text: string; style?: Record<string, unknown> }[] = [
|
|
782
|
+
checkbox,
|
|
466
783
|
{
|
|
467
|
-
text:
|
|
784
|
+
text: s.label,
|
|
468
785
|
style: isActive
|
|
469
|
-
? { fg: "ui.
|
|
470
|
-
:
|
|
786
|
+
? { fg: "ui.help_key_fg", bold: true }
|
|
787
|
+
: isDiscovered
|
|
788
|
+
? { fg: "ui.menu_disabled_fg" }
|
|
789
|
+
: undefined,
|
|
471
790
|
},
|
|
472
|
-
{ text: s.label, style: isActive ? { bold: true } : undefined },
|
|
473
791
|
];
|
|
474
|
-
// Visible width of the NAME column so far (label +
|
|
792
|
+
// Visible width of the NAME column so far (label + tags), used
|
|
475
793
|
// to pad out to LIST_NAME_W before the PROJECT column.
|
|
476
794
|
let nameWidth = s.label.length;
|
|
477
|
-
if (
|
|
478
|
-
entries.push({
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
nameWidth += 2;
|
|
795
|
+
if (isDiscovered) {
|
|
796
|
+
entries.push({
|
|
797
|
+
text: " · on-disk",
|
|
798
|
+
style: { fg: "ui.menu_disabled_fg", italic: true },
|
|
799
|
+
});
|
|
800
|
+
nameWidth += 10;
|
|
484
801
|
}
|
|
485
802
|
// PROJECT column: basename for cross-project rows only; current-
|
|
486
803
|
// project rows leave it blank (the whole list is one project when
|
|
@@ -515,57 +832,24 @@ function buildPreviewEntries(
|
|
|
515
832
|
}
|
|
516
833
|
const activeId = editor.activeWindow();
|
|
517
834
|
const isActive = s.id === activeId;
|
|
518
|
-
const isBase = s.id === 1;
|
|
519
835
|
const stateText = isActive ? "ACT" : STATE_GLYPH[s.state].trim();
|
|
520
|
-
// Count siblings sharing the same `root`. The set includes
|
|
521
|
-
// `s` itself; `> 1` means at least one other session lives at
|
|
522
|
-
// the same path (shared-worktree mode, or two sessions
|
|
523
|
-
// explicitly aimed at the same directory).
|
|
524
|
-
const sharedCount = countSiblingsAtRoot(s.root);
|
|
525
836
|
const headerEntries: { text: string; style?: Record<string, unknown> }[] = [
|
|
526
837
|
{
|
|
527
838
|
text: stateText,
|
|
528
839
|
style: isActive
|
|
529
|
-
? { fg: "ui.
|
|
840
|
+
? { fg: "ui.help_key_fg", bold: true }
|
|
530
841
|
: { fg: "ui.menu_disabled_fg" },
|
|
531
842
|
},
|
|
532
843
|
{ text: " " },
|
|
533
844
|
{ text: ageString(s.createdAt), style: { fg: "ui.menu_disabled_fg" } },
|
|
534
845
|
];
|
|
535
|
-
if (
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
// greyed out.
|
|
540
|
-
headerEntries.push(
|
|
541
|
-
{ text: " " },
|
|
542
|
-
{
|
|
543
|
-
text: "BASE",
|
|
544
|
-
style: { fg: "ui.help_key_fg", bold: true },
|
|
545
|
-
},
|
|
546
|
-
{ text: " — editor session", style: { fg: "ui.menu_disabled_fg", italic: true } },
|
|
547
|
-
);
|
|
548
|
-
}
|
|
549
|
-
if (sharedCount > 1) {
|
|
846
|
+
if (!s.discovered && !ownsWorktree(s)) {
|
|
847
|
+
// In-place / launch session: runs inside a real checkout, owns no
|
|
848
|
+
// dedicated worktree. Surfaced so the user knows Archive doesn't
|
|
849
|
+
// apply (Delete just forgets it, leaving the directory untouched).
|
|
550
850
|
headerEntries.push(
|
|
551
851
|
{ text: " " },
|
|
552
|
-
{
|
|
553
|
-
text: `SHARED ×${sharedCount}`,
|
|
554
|
-
style: { fg: "ui.status_error_indicator_fg", bold: true },
|
|
555
|
-
},
|
|
556
|
-
);
|
|
557
|
-
} else if (s.sharedWorktree) {
|
|
558
|
-
// Single-session shared-worktree mode (the user opted out of
|
|
559
|
-
// a dedicated worktree even though no second session is on
|
|
560
|
-
// this root yet). Still worth surfacing so the user knows
|
|
561
|
-
// why Archive / Delete refuse to run a `git worktree
|
|
562
|
-
// remove` here.
|
|
563
|
-
headerEntries.push(
|
|
564
|
-
{ text: " " },
|
|
565
|
-
{
|
|
566
|
-
text: "SHARED",
|
|
567
|
-
style: { fg: "ui.menu_disabled_fg", italic: true },
|
|
568
|
-
},
|
|
852
|
+
{ text: "in-place", style: { fg: "ui.menu_disabled_fg", italic: true } },
|
|
569
853
|
);
|
|
570
854
|
}
|
|
571
855
|
return [
|
|
@@ -576,16 +860,85 @@ function buildPreviewEntries(
|
|
|
576
860
|
];
|
|
577
861
|
}
|
|
578
862
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
863
|
+
// A session "owns" a removable git worktree when it was created as a
|
|
864
|
+
// dedicated `git worktree add` (project path set, not a shared/in-place
|
|
865
|
+
// root) or was discovered on disk via `git worktree list`. Only these
|
|
866
|
+
// have a worktree to `git worktree remove`/`move`. The launch session
|
|
867
|
+
// (the dir the editor was started in) and in-place sessions run inside
|
|
868
|
+
// a real checkout, so Archive (which moves the worktree) doesn't apply
|
|
869
|
+
// and Delete simply forgets the session without touching the directory.
|
|
870
|
+
function ownsWorktree(s: AgentSession): boolean {
|
|
871
|
+
return !!s.discovered || (!!s.projectPath && !s.sharedWorktree);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// =============================================================================
|
|
875
|
+
// Multi-select / bulk actions
|
|
876
|
+
//
|
|
877
|
+
// The user checkbox-selects rows (Space — the rebindable
|
|
878
|
+
// `orchestrator_toggle_select` — or a click). Once two or more rows
|
|
879
|
+
// are checked the preview pane swaps to the bulk selection bar
|
|
880
|
+
// (`buildBulkPane`) offering Stop / Archive / Delete over the whole
|
|
881
|
+
// set, with a single confirmation for the batch. Rows ineligible for
|
|
882
|
+
// a given action (the base session; live sessions sharing a worktree)
|
|
883
|
+
// are skipped, and each button's count reflects only the eligible
|
|
884
|
+
// members.
|
|
885
|
+
// =============================================================================
|
|
886
|
+
|
|
887
|
+
type BulkAction = "stop" | "archive" | "delete";
|
|
888
|
+
|
|
889
|
+
// Checked ids that still resolve to a known session, in the dialog's
|
|
890
|
+
// current display order (so the bulk bar lists them the way the list
|
|
891
|
+
// shows them). Selection persists across filter/scope changes, so an
|
|
892
|
+
// id can be checked while filtered out of view — those still count.
|
|
893
|
+
function selectedSessions(): number[] {
|
|
894
|
+
if (!openDialog) return [];
|
|
895
|
+
const order = openDialog.filteredIds;
|
|
896
|
+
const seen = new Set<number>();
|
|
897
|
+
const out: number[] = [];
|
|
898
|
+
for (const id of order) {
|
|
899
|
+
if (openDialog.selectedIds.has(id) && orchestratorSessions.has(id)) {
|
|
900
|
+
out.push(id);
|
|
901
|
+
seen.add(id);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
// Checked-but-filtered-out rows, appended in id order so the count
|
|
905
|
+
// stays honest even when a search hides part of the selection.
|
|
906
|
+
for (const id of openDialog.selectedIds) {
|
|
907
|
+
if (!seen.has(id) && orchestratorSessions.has(id)) out.push(id);
|
|
908
|
+
}
|
|
909
|
+
return out;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Is `id` a legal target for `action`? Base session is never
|
|
913
|
+
// touched. Stop only applies to live windows. Archive/Delete apply
|
|
914
|
+
// to discovered worktrees (removable on disk) and to live sessions
|
|
915
|
+
// that own their worktree outright (not shared with siblings or the
|
|
916
|
+
// project root).
|
|
917
|
+
function bulkEligible(action: BulkAction, id: number): boolean {
|
|
918
|
+
const s = orchestratorSessions.get(id);
|
|
919
|
+
if (!s) return false;
|
|
920
|
+
// Stop kills the agent process group — only meaningful for a live
|
|
921
|
+
// session that actually spawned one (never the launch session, which
|
|
922
|
+
// has no agent terminal, so signalling it can't touch the editor).
|
|
923
|
+
if (action === "stop") return !s.discovered && id > 0 && !!s.terminalId;
|
|
924
|
+
// Delete forgets any session. When it owns a worktree the worktree is
|
|
925
|
+
// removed too; otherwise (launch/in-place) it's just dropped.
|
|
926
|
+
if (action === "delete") return id > 0 || !!s.discovered;
|
|
927
|
+
// Archive moves the worktree to the graveyard, so it needs one.
|
|
928
|
+
return ownsWorktree(s);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function eligibleSelected(action: BulkAction): number[] {
|
|
932
|
+
return selectedSessions().filter((id) => bulkEligible(action, id));
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Drop checked ids whose session has vanished (closed window,
|
|
936
|
+
// pruned worktree) so the selection can't grow stale references.
|
|
937
|
+
function pruneSelection(): void {
|
|
938
|
+
if (!openDialog) return;
|
|
939
|
+
for (const id of [...openDialog.selectedIds]) {
|
|
940
|
+
if (!orchestratorSessions.has(id)) openDialog.selectedIds.delete(id);
|
|
587
941
|
}
|
|
588
|
-
return n;
|
|
589
942
|
}
|
|
590
943
|
|
|
591
944
|
// Blank-row separator used inside the Sessions column between
|
|
@@ -610,10 +963,10 @@ function maxListRowsForScreen(): number {
|
|
|
610
963
|
const panelH = Math.floor(h * 0.9);
|
|
611
964
|
// Chrome that isn't list rows: panel borders (2) + title (1) +
|
|
612
965
|
// spacer (1) + footer (1) + sessions-section borders (2) +
|
|
613
|
-
// column chrome above the list (New + Project +
|
|
614
|
-
// separator + header =
|
|
615
|
-
// terminal still shows something.
|
|
616
|
-
return Math.max(MIN_LIST_ROWS, panelH -
|
|
966
|
+
// column chrome above the list (New + Project + Worktree-filter +
|
|
967
|
+
// Trivial-filter + Filter + separator + header = 7) = 14. Floor at
|
|
968
|
+
// MIN_LIST_ROWS so a tiny terminal still shows something.
|
|
969
|
+
return Math.max(MIN_LIST_ROWS, panelH - 14);
|
|
617
970
|
}
|
|
618
971
|
|
|
619
972
|
// Compose the right-hand preview pane. Normally it shows info
|
|
@@ -658,130 +1011,28 @@ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
|
|
|
658
1011
|
),
|
|
659
1012
|
});
|
|
660
1013
|
}
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
text: `Stop session [${s.id}] ${s.label}?`,
|
|
673
|
-
style: { bold: true },
|
|
674
|
-
},
|
|
675
|
-
]),
|
|
676
|
-
styledRow([{ text: "" }]),
|
|
677
|
-
styledRow([{ text: "This will:" }]),
|
|
678
|
-
styledRow([{ text: " • send SIGTERM to all session processes" }]),
|
|
679
|
-
styledRow([{ text: " • SIGKILL after a short grace period" }]),
|
|
680
|
-
styledRow([{ text: "" }]),
|
|
681
|
-
styledRow([{ text: "The worktree and session record remain." }]),
|
|
682
|
-
],
|
|
683
|
-
},
|
|
684
|
-
spacer(0),
|
|
685
|
-
row(
|
|
686
|
-
flexSpacer(),
|
|
687
|
-
button("Cancel", { key: "confirm-cancel" }),
|
|
688
|
-
spacer(2),
|
|
689
|
-
button("Confirm Stop", {
|
|
690
|
-
intent: "danger",
|
|
691
|
-
key: "confirm-stop",
|
|
692
|
-
}),
|
|
693
|
-
),
|
|
694
|
-
),
|
|
695
|
-
});
|
|
696
|
-
}
|
|
697
|
-
if (action === "archive") {
|
|
698
|
-
return labeledSection({
|
|
699
|
-
label: "Confirm Archive",
|
|
700
|
-
child: col(
|
|
701
|
-
{
|
|
702
|
-
kind: "raw",
|
|
703
|
-
entries: [
|
|
704
|
-
styledRow([
|
|
705
|
-
{
|
|
706
|
-
text: `Archive session [${s.id}] ${s.label}?`,
|
|
707
|
-
style: { bold: true },
|
|
708
|
-
},
|
|
709
|
-
]),
|
|
710
|
-
styledRow([{ text: "" }]),
|
|
711
|
-
styledRow([{ text: "This will:" }]),
|
|
712
|
-
styledRow([{ text: " • SIGKILL all session processes" }]),
|
|
713
|
-
styledRow([{ text: " • close the editor session" }]),
|
|
714
|
-
styledRow([{ text: " • move the worktree to .archived/" }]),
|
|
715
|
-
styledRow([{ text: "" }]),
|
|
716
|
-
styledRow([{ text: "Reversible via Unarchive." }]),
|
|
717
|
-
],
|
|
718
|
-
},
|
|
719
|
-
spacer(0),
|
|
720
|
-
row(
|
|
721
|
-
flexSpacer(),
|
|
722
|
-
button("Cancel", { key: "confirm-cancel" }),
|
|
723
|
-
spacer(2),
|
|
724
|
-
button("Confirm Archive", {
|
|
725
|
-
intent: "danger",
|
|
726
|
-
key: "confirm-archive",
|
|
727
|
-
}),
|
|
728
|
-
),
|
|
729
|
-
),
|
|
730
|
-
});
|
|
731
|
-
}
|
|
732
|
-
if (action === "delete") {
|
|
733
|
-
return labeledSection({
|
|
734
|
-
label: "Confirm Delete",
|
|
735
|
-
child: col(
|
|
736
|
-
{
|
|
737
|
-
kind: "raw",
|
|
738
|
-
entries: [
|
|
739
|
-
styledRow([
|
|
740
|
-
{
|
|
741
|
-
text: `Delete session [${s.id}] ${s.label}?`,
|
|
742
|
-
style: { bold: true },
|
|
743
|
-
},
|
|
744
|
-
]),
|
|
745
|
-
styledRow([{ text: "" }]),
|
|
746
|
-
styledRow([{ text: "This will:" }]),
|
|
747
|
-
styledRow([{ text: " • stop all session processes" }]),
|
|
748
|
-
styledRow([{ text: " • run `git worktree remove`" }]),
|
|
749
|
-
styledRow([{ text: " • drop the session record" }]),
|
|
750
|
-
styledRow([{ text: "" }]),
|
|
751
|
-
styledRow([
|
|
752
|
-
{
|
|
753
|
-
text: "Uncommitted changes will be lost.",
|
|
754
|
-
style: {
|
|
755
|
-
fg: "ui.status_error_indicator_fg",
|
|
756
|
-
bold: true,
|
|
757
|
-
},
|
|
758
|
-
},
|
|
759
|
-
]),
|
|
760
|
-
],
|
|
761
|
-
},
|
|
762
|
-
spacer(0),
|
|
763
|
-
row(
|
|
764
|
-
flexSpacer(),
|
|
765
|
-
button("Cancel", { key: "confirm-cancel" }),
|
|
766
|
-
spacer(2),
|
|
767
|
-
button("Confirm Delete", {
|
|
768
|
-
intent: "danger",
|
|
769
|
-
key: "confirm-delete",
|
|
770
|
-
}),
|
|
771
|
-
),
|
|
772
|
-
),
|
|
773
|
-
});
|
|
774
|
-
}
|
|
1014
|
+
// Confirmation panel — single-row Stop/Archive/Delete or a bulk
|
|
1015
|
+
// batch. Independent of the cursor row: the confirmed ids live in
|
|
1016
|
+
// `pendingConfirm`, so it renders whenever a confirm is pending.
|
|
1017
|
+
if (openDialog?.pendingConfirm) {
|
|
1018
|
+
return buildConfirmPane(openDialog.pendingConfirm);
|
|
1019
|
+
}
|
|
1020
|
+
// Bulk selection bar: two or more rows checked (or a bulk action
|
|
1021
|
+
// in flight) → operate on the whole batch rather than the cursor
|
|
1022
|
+
// row.
|
|
1023
|
+
if (selectedSessions().length >= 2 || openDialog?.bulkInFlight) {
|
|
1024
|
+
return buildBulkPane();
|
|
775
1025
|
}
|
|
776
1026
|
// Match the sessions column's content height so the two panes'
|
|
777
1027
|
// bottom borders land on the same row. Sessions column inside its
|
|
778
|
-
// borders = New (1) + Project (1) +
|
|
779
|
-
//
|
|
780
|
-
//
|
|
781
|
-
//
|
|
782
|
-
// details ARE shown, two info
|
|
783
|
-
// lines — `_DETAILS_CHROME_ROWS`
|
|
784
|
-
|
|
1028
|
+
// borders = New (1) + Project (1) + Worktree-filter (1) +
|
|
1029
|
+
// Trivial-filter (1) + Filter (1) + separator (1) + header (1) +
|
|
1030
|
+
// list (listVisibleRows) = listVisibleRows + 7. Preview inside its
|
|
1031
|
+
// borders = button row (1) + spacer (1) + embedRows, so embedRows
|
|
1032
|
+
// must equal listVisibleRows + 5. When details ARE shown, two info
|
|
1033
|
+
// rows + a spacer eat three more lines — `_DETAILS_CHROME_ROWS`
|
|
1034
|
+
// accounts for that.
|
|
1035
|
+
const totalEmbedBase = (openDialog?.listVisibleRows ?? MIN_LIST_ROWS) + 5;
|
|
785
1036
|
const detailsOn = openDialog?.showDetails ?? false;
|
|
786
1037
|
const _DETAILS_CHROME_ROWS = 3; // 2 info rows + 1 spacer
|
|
787
1038
|
const embedRows = Math.max(
|
|
@@ -814,6 +1065,48 @@ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
|
|
|
814
1065
|
// turns details on, pressing `[ Preview ]` turns them off
|
|
815
1066
|
// (back to compact).
|
|
816
1067
|
const detailsToggleLabel = detailsOn ? "Preview" : "Details";
|
|
1068
|
+
// Discovered worktree: no live window to embed, so there's
|
|
1069
|
+
// nothing to Stop / Archive / Delete yet. Offer only "Open"
|
|
1070
|
+
// (Visit attaches a fresh session to the worktree) and describe
|
|
1071
|
+
// what diving will do. The empty `windowId: 0` embed keeps the
|
|
1072
|
+
// pane the same height as live-session previews so the dialog
|
|
1073
|
+
// doesn't jump when the selection moves between row kinds.
|
|
1074
|
+
if (s.discovered) {
|
|
1075
|
+
const openButtonRow = row(
|
|
1076
|
+
button("Open", { intent: "primary", key: "visit" }),
|
|
1077
|
+
flexSpacer(),
|
|
1078
|
+
button("Stop", { key: "stop", disabled: true }),
|
|
1079
|
+
spacer(2),
|
|
1080
|
+
button("Archive", { key: "archive", disabled: true }),
|
|
1081
|
+
spacer(2),
|
|
1082
|
+
button("Delete", { intent: "danger", key: "delete", disabled: true }),
|
|
1083
|
+
);
|
|
1084
|
+
const info: TextPropertyEntry[] = [
|
|
1085
|
+
styledRow([
|
|
1086
|
+
{ text: "On-disk worktree (not open)", style: { fg: "ui.menu_disabled_fg", bold: true } },
|
|
1087
|
+
]),
|
|
1088
|
+
styledRow([{ text: "" }]),
|
|
1089
|
+
styledRow([{ text: "branch ", style: { fg: "ui.menu_disabled_fg" } }, { text: s.branch || "(detached)" }]),
|
|
1090
|
+
styledRow([{ text: "path ", style: { fg: "ui.menu_disabled_fg" } }, { text: s.root }]),
|
|
1091
|
+
styledRow([{ text: "" }]),
|
|
1092
|
+
styledRow([
|
|
1093
|
+
{
|
|
1094
|
+
text: "Press Enter to open this worktree as a session.",
|
|
1095
|
+
style: { fg: "ui.help_key_fg", italic: true },
|
|
1096
|
+
},
|
|
1097
|
+
]),
|
|
1098
|
+
];
|
|
1099
|
+
return labeledSection({
|
|
1100
|
+
label: `${s.label} — on-disk worktree`,
|
|
1101
|
+
child: col(
|
|
1102
|
+
openButtonRow,
|
|
1103
|
+
spacer(0),
|
|
1104
|
+
{ kind: "raw", entries: info },
|
|
1105
|
+
spacer(0),
|
|
1106
|
+
windowEmbed({ windowId: 0, rows: Math.max(3, embedRows - 6), key: "live-preview" }),
|
|
1107
|
+
),
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
817
1110
|
// Per-action availability. The row always renders all four
|
|
818
1111
|
// buttons (no layout shift between selections), but each is
|
|
819
1112
|
// marked disabled when its action would be refused against the
|
|
@@ -822,17 +1115,18 @@ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
|
|
|
822
1115
|
// same conditions that `stopSelectedSession`, `enterConfirm`,
|
|
823
1116
|
// and the lifecycle handlers already check internally.
|
|
824
1117
|
//
|
|
825
|
-
// * Stop:
|
|
826
|
-
//
|
|
827
|
-
//
|
|
828
|
-
//
|
|
829
|
-
//
|
|
830
|
-
//
|
|
831
|
-
|
|
832
|
-
const
|
|
833
|
-
const
|
|
834
|
-
const stopDisabled =
|
|
835
|
-
const
|
|
1118
|
+
// * Stop: only a live session with an agent terminal can be
|
|
1119
|
+
// stopped (the launch session has none).
|
|
1120
|
+
// * Archive: needs an owned worktree to move to the graveyard.
|
|
1121
|
+
// * Delete: forgets the session, removing the worktree only when one
|
|
1122
|
+
// is owned (otherwise the directory is left untouched).
|
|
1123
|
+
// * Archive/Delete are both refused on the last live window — the
|
|
1124
|
+
// editor must always host at least one.
|
|
1125
|
+
const hasWorktree = ownsWorktree(s);
|
|
1126
|
+
const isLastWindow = s.id > 0 && liveWindowCount() <= 1;
|
|
1127
|
+
const stopDisabled = s.discovered || !s.terminalId;
|
|
1128
|
+
const archiveDisabled = !hasWorktree || isLastWindow;
|
|
1129
|
+
const deleteDisabled = isLastWindow;
|
|
836
1130
|
const buttonRow = row(
|
|
837
1131
|
button("Visit", { intent: "primary", key: "visit" }),
|
|
838
1132
|
spacer(2),
|
|
@@ -841,12 +1135,12 @@ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
|
|
|
841
1135
|
spacer(2),
|
|
842
1136
|
button("Stop", { key: "stop", disabled: stopDisabled }),
|
|
843
1137
|
spacer(2),
|
|
844
|
-
button("Archive", { key: "archive", disabled:
|
|
1138
|
+
button("Archive", { key: "archive", disabled: archiveDisabled }),
|
|
845
1139
|
spacer(2),
|
|
846
1140
|
button("Delete", {
|
|
847
1141
|
intent: "danger",
|
|
848
1142
|
key: "delete",
|
|
849
|
-
disabled:
|
|
1143
|
+
disabled: deleteDisabled,
|
|
850
1144
|
}),
|
|
851
1145
|
);
|
|
852
1146
|
const embedWidget = windowEmbed({
|
|
@@ -863,20 +1157,225 @@ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
|
|
|
863
1157
|
embedWidget,
|
|
864
1158
|
)
|
|
865
1159
|
: col(buttonRow, spacer(0), embedWidget);
|
|
866
|
-
// Surface
|
|
867
|
-
// (the list-row badge gets truncated at 25% column width).
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
: `[${s.id}] ${s.label}`;
|
|
1160
|
+
// Surface the launch session in the preview label so it's always
|
|
1161
|
+
// visible (the list-row badge gets truncated at 25% column width).
|
|
1162
|
+
// It's the dir the editor was started in — informational only; it's
|
|
1163
|
+
// deletable like any other session once another window exists.
|
|
1164
|
+
const sectionLabel = s.id === 1
|
|
1165
|
+
? `${s.label} — launch session`
|
|
1166
|
+
: s.label;
|
|
874
1167
|
return labeledSection({
|
|
875
1168
|
label: sectionLabel,
|
|
876
1169
|
child: body,
|
|
877
1170
|
});
|
|
878
1171
|
}
|
|
879
1172
|
|
|
1173
|
+
// The per-action bullet lines shown in the confirmation panel.
|
|
1174
|
+
// `delete` adds a separate red "uncommitted changes" line in the
|
|
1175
|
+
// caller because it needs distinct styling.
|
|
1176
|
+
function confirmActionLines(action: BulkAction): string[] {
|
|
1177
|
+
switch (action) {
|
|
1178
|
+
case "stop":
|
|
1179
|
+
return [
|
|
1180
|
+
" • send SIGTERM to all session processes",
|
|
1181
|
+
" • SIGKILL after a short grace period",
|
|
1182
|
+
"",
|
|
1183
|
+
"The worktree and session record remain.",
|
|
1184
|
+
];
|
|
1185
|
+
case "archive":
|
|
1186
|
+
return [
|
|
1187
|
+
" • SIGKILL all session processes",
|
|
1188
|
+
" • close the editor session",
|
|
1189
|
+
" • move the worktree to .archived/",
|
|
1190
|
+
"",
|
|
1191
|
+
"Reversible via Unarchive.",
|
|
1192
|
+
];
|
|
1193
|
+
case "delete":
|
|
1194
|
+
return [
|
|
1195
|
+
" • stop all session processes",
|
|
1196
|
+
" • run `git worktree remove`",
|
|
1197
|
+
" • drop the session record",
|
|
1198
|
+
];
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// Confirmation panel for a Stop/Archive/Delete over one or many
|
|
1203
|
+
// sessions. A single id renders the familiar per-session prompt; two
|
|
1204
|
+
// or more render a batch prompt that lists the targets. The Confirm
|
|
1205
|
+
// button reuses the same `confirm-<action>` key the single path
|
|
1206
|
+
// always used, so the existing widget_event handlers fire for both —
|
|
1207
|
+
// they read `pendingConfirm.ids`.
|
|
1208
|
+
function buildConfirmPane(
|
|
1209
|
+
confirm: { action: BulkAction; ids: number[] },
|
|
1210
|
+
): WidgetSpec {
|
|
1211
|
+
const { action, ids } = confirm;
|
|
1212
|
+
const cap = action[0].toUpperCase() + action.slice(1);
|
|
1213
|
+
const existing = ids.filter((id) => orchestratorSessions.has(id));
|
|
1214
|
+
const bulk = existing.length > 1;
|
|
1215
|
+
const diskNote = (id: number): string =>
|
|
1216
|
+
orchestratorSessions.get(id)?.discovered ? " · on-disk" : "";
|
|
1217
|
+
const entries: TextPropertyEntry[] = [];
|
|
1218
|
+
if (bulk) {
|
|
1219
|
+
entries.push(
|
|
1220
|
+
styledRow([
|
|
1221
|
+
{ text: `${cap} these ${existing.length} sessions?`, style: { bold: true } },
|
|
1222
|
+
]),
|
|
1223
|
+
styledRow([{ text: "" }]),
|
|
1224
|
+
);
|
|
1225
|
+
for (const id of existing.slice(0, 8)) {
|
|
1226
|
+
const ss = orchestratorSessions.get(id)!;
|
|
1227
|
+
entries.push(
|
|
1228
|
+
styledRow([
|
|
1229
|
+
{ text: ` ${ss.label}` },
|
|
1230
|
+
{ text: diskNote(id), style: { fg: "ui.menu_disabled_fg", italic: true } },
|
|
1231
|
+
]),
|
|
1232
|
+
);
|
|
1233
|
+
}
|
|
1234
|
+
if (existing.length > 8) {
|
|
1235
|
+
entries.push(
|
|
1236
|
+
styledRow([
|
|
1237
|
+
{
|
|
1238
|
+
text: ` … and ${existing.length - 8} more`,
|
|
1239
|
+
style: { fg: "ui.menu_disabled_fg", italic: true },
|
|
1240
|
+
},
|
|
1241
|
+
]),
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
} else {
|
|
1245
|
+
const id = existing[0];
|
|
1246
|
+
const ss = id !== undefined ? orchestratorSessions.get(id) : undefined;
|
|
1247
|
+
entries.push(
|
|
1248
|
+
styledRow([
|
|
1249
|
+
{ text: `${cap} session ${ss?.label ?? ""}?`, style: { bold: true } },
|
|
1250
|
+
]),
|
|
1251
|
+
);
|
|
1252
|
+
}
|
|
1253
|
+
entries.push(
|
|
1254
|
+
styledRow([{ text: "" }]),
|
|
1255
|
+
styledRow([{ text: bulk ? "For each session this will:" : "This will:" }]),
|
|
1256
|
+
);
|
|
1257
|
+
for (const line of confirmActionLines(action)) {
|
|
1258
|
+
entries.push(styledRow([{ text: line }]));
|
|
1259
|
+
}
|
|
1260
|
+
if (action === "delete") {
|
|
1261
|
+
entries.push(
|
|
1262
|
+
styledRow([{ text: "" }]),
|
|
1263
|
+
styledRow([
|
|
1264
|
+
{
|
|
1265
|
+
text: "Uncommitted changes will be lost.",
|
|
1266
|
+
style: { fg: "ui.status_error_indicator_fg", bold: true },
|
|
1267
|
+
},
|
|
1268
|
+
]),
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
return labeledSection({
|
|
1272
|
+
label: bulk ? `Confirm ${cap} — ${existing.length} sessions` : `Confirm ${cap}`,
|
|
1273
|
+
child: col(
|
|
1274
|
+
{ kind: "raw", entries },
|
|
1275
|
+
spacer(0),
|
|
1276
|
+
row(
|
|
1277
|
+
flexSpacer(),
|
|
1278
|
+
button("Cancel", { key: "confirm-cancel" }),
|
|
1279
|
+
spacer(2),
|
|
1280
|
+
button(`Confirm ${cap}`, { intent: "danger", key: `confirm-${action}` }),
|
|
1281
|
+
),
|
|
1282
|
+
),
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// The dedicated bulk selection bar (Layout B). Shown in place of the
|
|
1287
|
+
// per-session preview when two or more rows are checked. The bulk
|
|
1288
|
+
// action buttons sit at the *top* of the pane; the list of affected
|
|
1289
|
+
// sessions renders below as a scrollable `list` widget (so a long
|
|
1290
|
+
// selection scrolls — keyboard, wheel, and the draggable scrollbar —
|
|
1291
|
+
// rather than overflowing the pane). Each action's count is the
|
|
1292
|
+
// number of *eligible* members; an action with no eligible members is
|
|
1293
|
+
// disabled.
|
|
1294
|
+
function buildBulkPane(): WidgetSpec {
|
|
1295
|
+
const sel = selectedSessions();
|
|
1296
|
+
const stopN = eligibleSelected("stop").length;
|
|
1297
|
+
const archiveN = eligibleSelected("archive").length;
|
|
1298
|
+
const deleteN = eligibleSelected("delete").length;
|
|
1299
|
+
|
|
1300
|
+
const inflight = openDialog?.bulkInFlight ?? null;
|
|
1301
|
+
const actionRow = inflight
|
|
1302
|
+
? row(
|
|
1303
|
+
{
|
|
1304
|
+
kind: "raw",
|
|
1305
|
+
entries: [
|
|
1306
|
+
styledRow([
|
|
1307
|
+
{
|
|
1308
|
+
text: `${inflight.action[0].toUpperCase()}${inflight.action.slice(1)}ing ${inflight.done}/${inflight.total}…`,
|
|
1309
|
+
style: { fg: "ui.menu_disabled_fg", italic: true },
|
|
1310
|
+
},
|
|
1311
|
+
]),
|
|
1312
|
+
],
|
|
1313
|
+
},
|
|
1314
|
+
flexSpacer(),
|
|
1315
|
+
)
|
|
1316
|
+
: row(
|
|
1317
|
+
button(`Stop (${stopN})`, { key: "bulk-stop", disabled: stopN === 0 }),
|
|
1318
|
+
spacer(2),
|
|
1319
|
+
button(`Archive (${archiveN})`, {
|
|
1320
|
+
key: "bulk-archive",
|
|
1321
|
+
disabled: archiveN === 0,
|
|
1322
|
+
}),
|
|
1323
|
+
spacer(2),
|
|
1324
|
+
button(`Delete (${deleteN})`, {
|
|
1325
|
+
intent: "danger",
|
|
1326
|
+
key: "bulk-delete",
|
|
1327
|
+
disabled: deleteN === 0,
|
|
1328
|
+
}),
|
|
1329
|
+
flexSpacer(),
|
|
1330
|
+
button("Clear", { key: "bulk-clear" }),
|
|
1331
|
+
);
|
|
1332
|
+
|
|
1333
|
+
// Affected-sessions list. Flag the rows a destructive action will
|
|
1334
|
+
// skip so the count discrepancy explains itself.
|
|
1335
|
+
const items: TextPropertyEntry[] = sel.map((id) => {
|
|
1336
|
+
const ss = orchestratorSessions.get(id)!;
|
|
1337
|
+
const rowParts: StyledSegment[] = [{ text: ` ${ss.label}` }];
|
|
1338
|
+
if (!ss.discovered && !ownsWorktree(ss)) {
|
|
1339
|
+
rowParts.push({
|
|
1340
|
+
text: " · in-place (forgotten, not removed)",
|
|
1341
|
+
style: { fg: "ui.menu_disabled_fg", italic: true },
|
|
1342
|
+
});
|
|
1343
|
+
} else if (ss.discovered) {
|
|
1344
|
+
rowParts.push({
|
|
1345
|
+
text: " · on-disk worktree",
|
|
1346
|
+
style: { fg: "ui.menu_disabled_fg", italic: true },
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
return styledRow(rowParts);
|
|
1350
|
+
});
|
|
1351
|
+
const itemKeys = sel.map((id) => `bulksel-${id}`);
|
|
1352
|
+
// Match the preview pane's height: content = action row (1) +
|
|
1353
|
+
// spacer (1) + list, and the embed pane reserves `listVisibleRows
|
|
1354
|
+
// + 4` for its body — so the list takes that height and the two
|
|
1355
|
+
// panes' bottom borders line up.
|
|
1356
|
+
const listRows = Math.max(3, (openDialog?.listVisibleRows ?? MIN_LIST_ROWS) + 4);
|
|
1357
|
+
|
|
1358
|
+
return labeledSection({
|
|
1359
|
+
label: `Bulk actions — ${sel.length} selected`,
|
|
1360
|
+
child: col(
|
|
1361
|
+
actionRow,
|
|
1362
|
+
spacer(0),
|
|
1363
|
+
list({
|
|
1364
|
+
items,
|
|
1365
|
+
itemKeys,
|
|
1366
|
+
// Display-only: no highlighted row, and out of the Tab cycle
|
|
1367
|
+
// (focus belongs on the action buttons). Up/Down still scroll
|
|
1368
|
+
// it via the host's smart-key forwarding, and the scrollbar
|
|
1369
|
+
// drags it.
|
|
1370
|
+
selectedIndex: -1,
|
|
1371
|
+
visibleRows: listRows,
|
|
1372
|
+
focusable: false,
|
|
1373
|
+
key: "bulk-list",
|
|
1374
|
+
}),
|
|
1375
|
+
),
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
|
|
880
1379
|
function buildOpenSpec(): WidgetSpec {
|
|
881
1380
|
if (!openDialog) return col();
|
|
882
1381
|
const filtered = openDialog.filteredIds;
|
|
@@ -890,9 +1389,11 @@ function buildOpenSpec(): WidgetSpec {
|
|
|
890
1389
|
const selIdx = filtered.length === 0
|
|
891
1390
|
? -1
|
|
892
1391
|
: Math.max(0, Math.min(openDialog.selectedIndex, filtered.length - 1));
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
1392
|
+
// Gate on the *index* (selIdx < 0 means "filter matched nothing"),
|
|
1393
|
+
// not the sign of the id: discovered worktrees carry negative ids
|
|
1394
|
+
// and must still resolve to their row here.
|
|
1395
|
+
const selectedSession = selIdx >= 0
|
|
1396
|
+
? orchestratorSessions.get(filtered[selIdx])
|
|
896
1397
|
: undefined;
|
|
897
1398
|
|
|
898
1399
|
// The "New Session" button advertises Alt+N (or whatever the
|
|
@@ -974,6 +1475,43 @@ function buildOpenSpec(): WidgetSpec {
|
|
|
974
1475
|
scopeButton,
|
|
975
1476
|
flexSpacer(),
|
|
976
1477
|
);
|
|
1478
|
+
// Per-project filter checkbox, on its own row under the Project
|
|
1479
|
+
// control: opt-in toggle that surfaces the discovered on-disk
|
|
1480
|
+
// worktree rows. A `toggle` (single `[ ]`/`[v]` — no double
|
|
1481
|
+
// bracket) that's clickable and bound to Alt+T
|
|
1482
|
+
// (`orchestrator_toggle_worktrees`, rebindable). The label carries
|
|
1483
|
+
// the live keybinding hint, mirroring the Project control's
|
|
1484
|
+
// "(Alt+P)". Inert while a confirm prompt is up.
|
|
1485
|
+
const worktreeKey = editor.getKeybindingLabel(
|
|
1486
|
+
"orchestrator_toggle_worktrees",
|
|
1487
|
+
OPEN_MODE,
|
|
1488
|
+
);
|
|
1489
|
+
const worktreeLabel = worktreeKey
|
|
1490
|
+
? `Show all worktrees (${worktreeKey})`
|
|
1491
|
+
: "Show all worktrees";
|
|
1492
|
+
const worktreeFilterRow = row(
|
|
1493
|
+
toggle(openDialog.showWorktrees, worktreeLabel, {
|
|
1494
|
+
key: openDialog.pendingConfirm !== null ? undefined : "worktree-show",
|
|
1495
|
+
}),
|
|
1496
|
+
flexSpacer(),
|
|
1497
|
+
);
|
|
1498
|
+
// Content filter checkbox, beneath the worktree one. The flag is
|
|
1499
|
+
// `hideTrivial`, but the checkbox reads as an opt-in "show" toggle to
|
|
1500
|
+
// match the worktree row: unchecked (default) hides the empty /
|
|
1501
|
+
// single-file shells, checking it reveals them. Inert during confirm.
|
|
1502
|
+
const trivialKey = editor.getKeybindingLabel(
|
|
1503
|
+
"orchestrator_toggle_trivial",
|
|
1504
|
+
OPEN_MODE,
|
|
1505
|
+
);
|
|
1506
|
+
const trivialLabel = trivialKey
|
|
1507
|
+
? `Show empty/1-file sessions (${trivialKey})`
|
|
1508
|
+
: "Show empty/1-file sessions";
|
|
1509
|
+
const trivialFilterRow = row(
|
|
1510
|
+
toggle(!openDialog.hideTrivial, trivialLabel, {
|
|
1511
|
+
key: openDialog.pendingConfirm !== null ? undefined : "hide-trivial",
|
|
1512
|
+
}),
|
|
1513
|
+
flexSpacer(),
|
|
1514
|
+
);
|
|
977
1515
|
|
|
978
1516
|
return col(
|
|
979
1517
|
{
|
|
@@ -1028,6 +1566,8 @@ function buildOpenSpec(): WidgetSpec {
|
|
|
1028
1566
|
flexSpacer(),
|
|
1029
1567
|
),
|
|
1030
1568
|
projectControlRow,
|
|
1569
|
+
worktreeFilterRow,
|
|
1570
|
+
trivialFilterRow,
|
|
1031
1571
|
filterInput,
|
|
1032
1572
|
sessionsSeparator(),
|
|
1033
1573
|
sessionsColumnHeader(),
|
|
@@ -1067,6 +1607,11 @@ function buildOpenSpec(): WidgetSpec {
|
|
|
1067
1607
|
hintBar([
|
|
1068
1608
|
{ keys: "↑↓", label: "nav" },
|
|
1069
1609
|
{ keys: "Enter", label: "dive" },
|
|
1610
|
+
{
|
|
1611
|
+
keys: editor.getKeybindingLabel("orchestrator_toggle_select", OPEN_MODE) ||
|
|
1612
|
+
"Space",
|
|
1613
|
+
label: "select",
|
|
1614
|
+
},
|
|
1070
1615
|
{
|
|
1071
1616
|
keys: scopeKey || "⌥P",
|
|
1072
1617
|
label: scope === "current" ? "all projects" : "current only",
|
|
@@ -1127,6 +1672,7 @@ function clearDialogError(): void {
|
|
|
1127
1672
|
|
|
1128
1673
|
function refreshOpenDialog(): void {
|
|
1129
1674
|
if (!openPanel || !openDialog) return;
|
|
1675
|
+
pruneSelection();
|
|
1130
1676
|
openDialog.filteredIds = filterSessions(openDialog.filter.value);
|
|
1131
1677
|
// Clamp the selection into range so a fresh filter or a
|
|
1132
1678
|
// session vanishing under us doesn't leave us pointing past
|
|
@@ -1149,6 +1695,9 @@ function refreshOpenDialog(): void {
|
|
|
1149
1695
|
function openControlRoom(): void {
|
|
1150
1696
|
if (openPanel) return;
|
|
1151
1697
|
reconcileSessions();
|
|
1698
|
+
// Summarise on-disk session content up front so the trivial filter
|
|
1699
|
+
// has data on the first render.
|
|
1700
|
+
scanSessionContent();
|
|
1152
1701
|
const activeId = editor.activeWindow();
|
|
1153
1702
|
// Seed with the screen-max; buildOpenSpec refits to the session
|
|
1154
1703
|
// count on the first render (and every render after).
|
|
@@ -1167,6 +1716,10 @@ function openControlRoom(): void {
|
|
|
1167
1716
|
// Restore the last-used scope (defaults to "all"); the Project
|
|
1168
1717
|
// control / Alt+P updates it for next time.
|
|
1169
1718
|
scope: lastOpenScope,
|
|
1719
|
+
selectedIds: new Set<number>(),
|
|
1720
|
+
showWorktrees: lastShowWorktrees,
|
|
1721
|
+
hideTrivial: lastHideTrivial,
|
|
1722
|
+
bulkInFlight: null,
|
|
1170
1723
|
};
|
|
1171
1724
|
openDialog.filteredIds = filterSessions("");
|
|
1172
1725
|
const activeIdx = openDialog.filteredIds.indexOf(activeId);
|
|
@@ -1188,6 +1741,12 @@ function openControlRoom(): void {
|
|
|
1188
1741
|
// safe — there's nothing to act on then anyway.
|
|
1189
1742
|
openPanel.setFocusKey("visit");
|
|
1190
1743
|
editor.setEditorMode(OPEN_MODE);
|
|
1744
|
+
|
|
1745
|
+
// Discover worktrees that exist on disk but aren't open yet and
|
|
1746
|
+
// fold them into the list. Async (it shells out to git per
|
|
1747
|
+
// project); the dialog renders immediately with live sessions and
|
|
1748
|
+
// gains the discovered rows when the scan lands.
|
|
1749
|
+
void refreshDiscoveredWorktrees();
|
|
1191
1750
|
}
|
|
1192
1751
|
|
|
1193
1752
|
function closeOpenDialog(): void {
|
|
@@ -1199,33 +1758,27 @@ function closeOpenDialog(): void {
|
|
|
1199
1758
|
editor.setEditorMode(null);
|
|
1200
1759
|
}
|
|
1201
1760
|
|
|
1202
|
-
// Stop every process
|
|
1203
|
-
//
|
|
1204
|
-
//
|
|
1205
|
-
//
|
|
1206
|
-
//
|
|
1207
|
-
//
|
|
1208
|
-
// it
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
const
|
|
1212
|
-
if (
|
|
1213
|
-
if (id === 1) {
|
|
1214
|
-
setDialogError("cannot stop the base session");
|
|
1215
|
-
refreshOpenDialog();
|
|
1216
|
-
return;
|
|
1217
|
-
}
|
|
1761
|
+
// Stop every process one session owns. Sends SIGTERM first via the
|
|
1762
|
+
// host's `signalWindow` (which fans out through the window's
|
|
1763
|
+
// process-group tracker), then follows up with SIGKILL after a short
|
|
1764
|
+
// grace period so ill-behaved agents that ignore SIGTERM still get
|
|
1765
|
+
// reaped. The session record stays put — Stop only kills processes,
|
|
1766
|
+
// it doesn't touch the worktree or the editor session. Returns false
|
|
1767
|
+
// for ids it can't stop (base session, discovered worktrees with no
|
|
1768
|
+
// live window).
|
|
1769
|
+
function stopOne(id: number): boolean {
|
|
1770
|
+
const s = orchestratorSessions.get(id);
|
|
1771
|
+
if (!s || id <= 0 || s.discovered || !s.terminalId) return false;
|
|
1218
1772
|
editor.signalWindow(id, "SIGTERM");
|
|
1219
|
-
// SIGKILL fallback for agents that ignore SIGTERM. The
|
|
1220
|
-
//
|
|
1221
|
-
//
|
|
1222
|
-
//
|
|
1223
|
-
// the host exposes `editor.delay(ms)` as the asynchronous
|
|
1773
|
+
// SIGKILL fallback for agents that ignore SIGTERM. The host's
|
|
1774
|
+
// signalWindow is idempotent on already-exited process groups, so
|
|
1775
|
+
// the second call is safe whether or not the first one took.
|
|
1776
|
+
// QuickJS has no `setTimeout`; `editor.delay(ms)` is the async
|
|
1224
1777
|
// sleep primitive, which we kick off but don't await.
|
|
1225
1778
|
void editor.delay(2000).then(() => {
|
|
1226
1779
|
editor.signalWindow(id, "SIGKILL");
|
|
1227
1780
|
});
|
|
1228
|
-
|
|
1781
|
+
return true;
|
|
1229
1782
|
}
|
|
1230
1783
|
|
|
1231
1784
|
// ---------------------------------------------------------------------
|
|
@@ -1305,139 +1858,124 @@ function pickNextActiveSession(excludeId: number): number {
|
|
|
1305
1858
|
for (const sid of orchestratorSessions.keys()) {
|
|
1306
1859
|
if (sid !== excludeId && sid > 0) return sid;
|
|
1307
1860
|
}
|
|
1308
|
-
|
|
1861
|
+
// No other live window. Callers guard against closing the last
|
|
1862
|
+
// window before reaching here, so this is a safe no-op swap (id 1
|
|
1863
|
+
// is no longer guaranteed to exist — it's deletable like any other).
|
|
1864
|
+
return excludeId;
|
|
1309
1865
|
}
|
|
1310
1866
|
|
|
1311
|
-
//
|
|
1312
|
-
//
|
|
1313
|
-
//
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
if (!openDialog) return;
|
|
1319
|
-
// Prefer the explicit id from the confirm path. Otherwise read
|
|
1320
|
-
// the currently selected row — used by the legacy direct-call
|
|
1321
|
-
// entry points. Once the row is hidden synchronously after
|
|
1322
|
-
// confirm, `filteredIds[selectedIndex]` no longer points at the
|
|
1323
|
-
// session being archived (it shifts to whatever is now under
|
|
1324
|
-
// the cursor).
|
|
1325
|
-
const id = typeof explicitId === "number"
|
|
1326
|
-
? explicitId
|
|
1327
|
-
: openDialog.filteredIds[openDialog.selectedIndex];
|
|
1328
|
-
// Clear the in-flight marker so the preview pane stops showing
|
|
1329
|
-
// "Archiving…" if the operation refuses or fails. After
|
|
1330
|
-
// `closeWindow` succeeds the row is gone from `listWindows()`
|
|
1331
|
-
// anyway, so clearing then is harmless.
|
|
1332
|
-
const clearInFlight = () => {
|
|
1333
|
-
if (
|
|
1334
|
-
openDialog?.inFlight && typeof id === "number" &&
|
|
1335
|
-
openDialog.inFlight.sessionId === id
|
|
1336
|
-
) {
|
|
1337
|
-
openDialog.inFlight = null;
|
|
1338
|
-
refreshOpenDialog();
|
|
1339
|
-
}
|
|
1340
|
-
};
|
|
1341
|
-
if (typeof id !== "number" || id <= 0) return;
|
|
1342
|
-
if (id === 1) {
|
|
1343
|
-
setDialogError("cannot archive the base session");
|
|
1344
|
-
clearInFlight();
|
|
1345
|
-
return;
|
|
1346
|
-
}
|
|
1347
|
-
// close_window refuses to close the active window; swap to a
|
|
1348
|
-
// different session first. The pick prefers something already
|
|
1349
|
-
// in the dialog's current filter, falls back to the base
|
|
1350
|
-
// session — both always exist (base is undeletable, and we'd
|
|
1351
|
-
// have nothing to archive without at least one session).
|
|
1352
|
-
if (id === editor.activeWindow()) {
|
|
1353
|
-
editor.setActiveWindow(pickNextActiveSession(id));
|
|
1354
|
-
}
|
|
1355
|
-
const session = orchestratorSessions.get(id);
|
|
1356
|
-
if (!session) {
|
|
1357
|
-
clearInFlight();
|
|
1358
|
-
return;
|
|
1867
|
+
// Number of real editor windows. Discovered on-disk rows have negative
|
|
1868
|
+
// ids and are not windows. The editor must always host at least one
|
|
1869
|
+
// window, so deleting/archiving the last live window is refused.
|
|
1870
|
+
function liveWindowCount(): number {
|
|
1871
|
+
let n = 0;
|
|
1872
|
+
for (const s of orchestratorSessions.values()) {
|
|
1873
|
+
if (s.id > 0) n += 1;
|
|
1359
1874
|
}
|
|
1875
|
+
return n;
|
|
1876
|
+
}
|
|
1360
1877
|
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
editor.setStatus("Orchestrator: archive failed — not a git repository");
|
|
1371
|
-
clearInFlight();
|
|
1372
|
-
return;
|
|
1878
|
+
// Resolve the *main* repo root a session's worktree belongs to, so
|
|
1879
|
+
// `git worktree move/remove` runs from a stable directory (never from
|
|
1880
|
+
// inside the tree being moved/removed). Prefers the canonical
|
|
1881
|
+
// `projectPath` recorded at create/discovery time, falling back to
|
|
1882
|
+
// resolving from the worktree itself.
|
|
1883
|
+
async function worktreeRepoRoot(s: AgentSession): Promise<string | null> {
|
|
1884
|
+
if (s.projectPath) {
|
|
1885
|
+
const r = await resolveCanonicalRepoRoot(s.projectPath);
|
|
1886
|
+
if (r) return r;
|
|
1373
1887
|
}
|
|
1374
|
-
|
|
1888
|
+
return await resolveCanonicalRepoRoot(s.root);
|
|
1889
|
+
}
|
|
1375
1890
|
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
editor.signalWindow(id, "SIGKILL");
|
|
1382
|
-
editor.closeWindow(id);
|
|
1891
|
+
interface LifecycleResult {
|
|
1892
|
+
ok: boolean;
|
|
1893
|
+
err?: string;
|
|
1894
|
+
repoRoot?: string;
|
|
1895
|
+
}
|
|
1383
1896
|
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1897
|
+
// Archive a single session: SIGKILL its processes (archive is a
|
|
1898
|
+
// "done with this for now" action — no graceful teardown needed since
|
|
1899
|
+
// the worktree stays on disk), close the editor session, move the
|
|
1900
|
+
// worktree to the `.archived/` graveyard, and append a manifest
|
|
1901
|
+
// entry so Unarchive can reverse it. Handles both live sessions and
|
|
1902
|
+
// discovered on-disk worktrees (the latter have no window to close).
|
|
1903
|
+
// Does NOT trigger sync — the caller batches one sync per repo after
|
|
1904
|
+
// the whole run.
|
|
1905
|
+
async function archiveOne(id: number): Promise<LifecycleResult> {
|
|
1906
|
+
const s = orchestratorSessions.get(id);
|
|
1907
|
+
if (!s) return { ok: false, err: "session gone" };
|
|
1908
|
+
// Archive moves the worktree to the graveyard — only sessions that
|
|
1909
|
+
// own one can be archived. A launch/in-place session has no separate
|
|
1910
|
+
// worktree, so there's nothing to move; use Delete to forget it.
|
|
1911
|
+
if (!ownsWorktree(s)) {
|
|
1912
|
+
return { ok: false, err: "no worktree to archive — use Delete to forget this session" };
|
|
1913
|
+
}
|
|
1914
|
+
// The editor must keep at least one window; closing the last one
|
|
1915
|
+
// would orphan the move. Refuse before touching the worktree.
|
|
1916
|
+
if (s.id > 0 && liveWindowCount() <= 1) {
|
|
1917
|
+
return { ok: false, err: "cannot archive the last window — open another session first" };
|
|
1918
|
+
}
|
|
1919
|
+
const repoRoot = await worktreeRepoRoot(s);
|
|
1920
|
+
if (!repoRoot) return { ok: false, err: "not a git repository" };
|
|
1921
|
+
|
|
1922
|
+
// Live session: close_window refuses to close the active window, so
|
|
1923
|
+
// switch away first, then SIGKILL the process group (so pty
|
|
1924
|
+
// children release worktree locks) and close the editor session.
|
|
1925
|
+
if (!s.discovered && id > 0) {
|
|
1926
|
+
if (id === editor.activeWindow()) {
|
|
1927
|
+
editor.setActiveWindow(pickNextActiveSession(id));
|
|
1928
|
+
}
|
|
1929
|
+
if (s.terminalId) editor.signalWindow(id, "SIGKILL");
|
|
1930
|
+
editor.closeWindow(id);
|
|
1931
|
+
// Brief settle so the filesystem reflects the pty's exit before
|
|
1932
|
+
// we move the worktree out from under it.
|
|
1933
|
+
await editor.delay(250);
|
|
1934
|
+
}
|
|
1387
1935
|
|
|
1388
|
-
// git worktree move keeps git's internal bookkeeping
|
|
1389
|
-
// consistent (the new path stays registered as a worktree).
|
|
1390
1936
|
const archivedRoot = editor.pathJoin(
|
|
1391
1937
|
editor.getDataDir(),
|
|
1392
1938
|
"orchestrator",
|
|
1393
1939
|
slugify(repoRoot),
|
|
1394
1940
|
".archived",
|
|
1395
|
-
|
|
1941
|
+
s.label,
|
|
1396
1942
|
);
|
|
1397
1943
|
const parent = editor.pathDirname(archivedRoot);
|
|
1398
1944
|
if (!editor.createDir(parent)) {
|
|
1399
|
-
|
|
1400
|
-
`Orchestrator: archive failed — could not create ${parent}`,
|
|
1401
|
-
);
|
|
1402
|
-
clearInFlight();
|
|
1403
|
-
return;
|
|
1945
|
+
return { ok: false, err: `could not create ${parent}`, repoRoot };
|
|
1404
1946
|
}
|
|
1947
|
+
// git worktree move keeps git's internal bookkeeping consistent
|
|
1948
|
+
// (the new path stays registered as a worktree).
|
|
1405
1949
|
const moveRes = await spawnCollect(
|
|
1406
1950
|
"git",
|
|
1407
|
-
["-C", repoRoot, "worktree", "move",
|
|
1951
|
+
["-C", repoRoot, "worktree", "move", s.root, archivedRoot],
|
|
1408
1952
|
repoRoot,
|
|
1409
1953
|
);
|
|
1410
1954
|
if (moveRes.exit_code !== 0) {
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
clearInFlight();
|
|
1417
|
-
return;
|
|
1955
|
+
return {
|
|
1956
|
+
ok: false,
|
|
1957
|
+
err: lastNonEmptyLine(moveRes.stderr) || "worktree move failed",
|
|
1958
|
+
repoRoot,
|
|
1959
|
+
};
|
|
1418
1960
|
}
|
|
1419
1961
|
|
|
1420
|
-
// Append manifest entry. The branch info is best-effort:
|
|
1421
|
-
// we assume Orchestrator's convention of branch==label (set in
|
|
1422
|
-
// the new-session form) until a session knows its branch
|
|
1423
|
-
// separately.
|
|
1424
1962
|
const manifest = loadArchiveManifest(repoRoot);
|
|
1425
1963
|
manifest.sessions.push({
|
|
1426
|
-
label:
|
|
1964
|
+
label: s.label,
|
|
1427
1965
|
root: archivedRoot,
|
|
1428
|
-
original_root:
|
|
1429
|
-
branch:
|
|
1966
|
+
original_root: s.root,
|
|
1967
|
+
branch: s.branch || s.label,
|
|
1430
1968
|
archived_at: new Date().toISOString(),
|
|
1431
1969
|
});
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1970
|
+
saveArchiveManifest(repoRoot, manifest);
|
|
1971
|
+
|
|
1972
|
+
// A discovered row has no window_closed hook to drop it — remove it
|
|
1973
|
+
// from the model directly.
|
|
1974
|
+
if (s.discovered) {
|
|
1975
|
+
orchestratorSessions.delete(id);
|
|
1976
|
+
discoveredIdByPath.delete(s.root);
|
|
1438
1977
|
}
|
|
1439
|
-
|
|
1440
|
-
triggerSyncAsync(repoRoot);
|
|
1978
|
+
return { ok: true, repoRoot };
|
|
1441
1979
|
}
|
|
1442
1980
|
|
|
1443
1981
|
// ---------------------------------------------------------------------
|
|
@@ -1640,86 +2178,149 @@ async function buildSyncSnapshot(repoRoot: string): Promise<unknown> {
|
|
|
1640
2178
|
};
|
|
1641
2179
|
}
|
|
1642
2180
|
|
|
1643
|
-
// Delete
|
|
1644
|
-
// session
|
|
1645
|
-
//
|
|
1646
|
-
//
|
|
1647
|
-
//
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
//
|
|
1655
|
-
//
|
|
1656
|
-
//
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
2181
|
+
// Delete a single session: close the editor session, then — only when
|
|
2182
|
+
// the session owns a worktree — `git worktree remove --force` to drop
|
|
2183
|
+
// it from disk (and prune any archive-manifest entry). A launch or
|
|
2184
|
+
// in-place session owns no worktree, so Delete just forgets it: the
|
|
2185
|
+
// window closes and the directory is left untouched (a fresh session
|
|
2186
|
+
// can always be opened there again). Handles discovered on-disk
|
|
2187
|
+
// worktrees (no window to close). Does NOT trigger sync — the caller
|
|
2188
|
+
// batches it.
|
|
2189
|
+
async function deleteOne(id: number): Promise<LifecycleResult> {
|
|
2190
|
+
const s = orchestratorSessions.get(id);
|
|
2191
|
+
if (!s) return { ok: false, err: "session gone" };
|
|
2192
|
+
// The editor must keep at least one window. Refuse before any
|
|
2193
|
+
// close/worktree-removal so a removable session can't `git worktree
|
|
2194
|
+
// remove` the tree the live editor is still sitting in.
|
|
2195
|
+
if (s.id > 0 && liveWindowCount() <= 1) {
|
|
2196
|
+
return { ok: false, err: "cannot delete the last window — open another session first" };
|
|
2197
|
+
}
|
|
2198
|
+
const removable = ownsWorktree(s);
|
|
2199
|
+
|
|
2200
|
+
if (!s.discovered && id > 0) {
|
|
2201
|
+
// close_window refuses to close the active (and the last) window,
|
|
2202
|
+
// so swap away first. SIGKILL only when there's an agent terminal —
|
|
2203
|
+
// a launch/in-place session has none, and signalling it must never
|
|
2204
|
+
// touch the editor itself.
|
|
2205
|
+
if (id === editor.activeWindow()) {
|
|
2206
|
+
editor.setActiveWindow(pickNextActiveSession(id));
|
|
1661
2207
|
}
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
clearInFlight();
|
|
1666
|
-
return;
|
|
2208
|
+
if (s.terminalId) editor.signalWindow(id, "SIGKILL");
|
|
2209
|
+
editor.closeWindow(id);
|
|
2210
|
+
if (removable) await editor.delay(250);
|
|
1667
2211
|
}
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
if (
|
|
1671
|
-
|
|
2212
|
+
|
|
2213
|
+
let repoRoot: string | undefined;
|
|
2214
|
+
if (removable) {
|
|
2215
|
+
const rr = await worktreeRepoRoot(s);
|
|
2216
|
+
if (!rr) return { ok: false, err: "not a git repository" };
|
|
2217
|
+
repoRoot = rr;
|
|
2218
|
+
// `--force` because the worktree may have unstaged changes the user
|
|
2219
|
+
// explicitly chose to discard via the confirm step.
|
|
2220
|
+
const removeRes = await spawnCollect(
|
|
2221
|
+
"git",
|
|
2222
|
+
["-C", rr, "worktree", "remove", "--force", s.root],
|
|
2223
|
+
rr,
|
|
2224
|
+
);
|
|
2225
|
+
if (removeRes.exit_code !== 0) {
|
|
2226
|
+
return {
|
|
2227
|
+
ok: false,
|
|
2228
|
+
err: lastNonEmptyLine(removeRes.stderr) || "worktree remove failed",
|
|
2229
|
+
repoRoot,
|
|
2230
|
+
};
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
// Drop the matching manifest entry too, in case the session was
|
|
2234
|
+
// already archived (delete-from-archived drops dormant sessions).
|
|
2235
|
+
const manifest = loadArchiveManifest(rr);
|
|
2236
|
+
const before = manifest.sessions.length;
|
|
2237
|
+
manifest.sessions = manifest.sessions.filter((e) => e.label !== s.label);
|
|
2238
|
+
if (manifest.sessions.length !== before) {
|
|
2239
|
+
saveArchiveManifest(rr, manifest);
|
|
2240
|
+
}
|
|
1672
2241
|
}
|
|
1673
2242
|
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
["rev-parse", "--show-toplevel"],
|
|
1678
|
-
cwd,
|
|
1679
|
-
);
|
|
1680
|
-
if (top.exit_code !== 0) {
|
|
1681
|
-
editor.setStatus("Orchestrator: delete failed — not a git repository");
|
|
1682
|
-
clearInFlight();
|
|
1683
|
-
return;
|
|
2243
|
+
if (s.discovered) {
|
|
2244
|
+
orchestratorSessions.delete(id);
|
|
2245
|
+
discoveredIdByPath.delete(s.root);
|
|
1684
2246
|
}
|
|
1685
|
-
|
|
2247
|
+
return { ok: true, repoRoot };
|
|
2248
|
+
}
|
|
1686
2249
|
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
2250
|
+
// Unified runner for a confirmed Stop / Archive / Delete over one or
|
|
2251
|
+
// many ids. Re-filters to eligible targets at execution time (the
|
|
2252
|
+
// selection or single row may have gone stale between confirm and
|
|
2253
|
+
// run), drives the in-flight progress markers, runs the per-id cores
|
|
2254
|
+
// sequentially, prunes acted-on ids from the selection, and triggers
|
|
2255
|
+
// one sync per touched repo at the end.
|
|
2256
|
+
async function runConfirmedAction(
|
|
2257
|
+
action: BulkAction,
|
|
2258
|
+
ids: number[],
|
|
2259
|
+
): Promise<void> {
|
|
2260
|
+
if (!openDialog) return;
|
|
2261
|
+
const targets = ids.filter((id) => bulkEligible(action, id));
|
|
2262
|
+
if (targets.length === 0) {
|
|
2263
|
+
setDialogError(`nothing eligible to ${action} in the selection`);
|
|
2264
|
+
refreshOpenDialog();
|
|
2265
|
+
return;
|
|
2266
|
+
}
|
|
1690
2267
|
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
editor.setStatus(
|
|
1700
|
-
`Orchestrator: worktree remove failed: ${
|
|
1701
|
-
lastNonEmptyLine(removeRes.stderr) || "unknown error"
|
|
1702
|
-
}`,
|
|
1703
|
-
);
|
|
1704
|
-
clearInFlight();
|
|
2268
|
+
if (action === "stop") {
|
|
2269
|
+
let n = 0;
|
|
2270
|
+
for (const id of targets) if (stopOne(id)) n += 1;
|
|
2271
|
+
editor.setStatus(`Orchestrator: stop signal sent to ${n} session(s)`);
|
|
2272
|
+
// Stop leaves sessions in place; drop them from the selection so
|
|
2273
|
+
// the bulk bar reflects that the action ran.
|
|
2274
|
+
for (const id of targets) openDialog.selectedIds.delete(id);
|
|
2275
|
+
refreshOpenDialog();
|
|
1705
2276
|
return;
|
|
1706
2277
|
}
|
|
1707
2278
|
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
2279
|
+
const single = targets.length === 1;
|
|
2280
|
+
if (single) {
|
|
2281
|
+
openDialog.inFlight = { action, sessionId: targets[0] };
|
|
2282
|
+
} else {
|
|
2283
|
+
openDialog.bulkInFlight = { action, total: targets.length, done: 0 };
|
|
2284
|
+
}
|
|
2285
|
+
refreshOpenDialog();
|
|
2286
|
+
|
|
2287
|
+
const touchedRepos = new Set<string>();
|
|
2288
|
+
let okCount = 0;
|
|
2289
|
+
let lastErr = "";
|
|
2290
|
+
for (let i = 0; i < targets.length; i++) {
|
|
2291
|
+
const id = targets[i];
|
|
2292
|
+
const res = action === "archive" ? await archiveOne(id) : await deleteOne(id);
|
|
2293
|
+
if (res.ok) {
|
|
2294
|
+
okCount += 1;
|
|
2295
|
+
if (res.repoRoot) touchedRepos.add(res.repoRoot);
|
|
2296
|
+
} else {
|
|
2297
|
+
lastErr = res.err ?? "failed";
|
|
2298
|
+
}
|
|
2299
|
+
openDialog?.selectedIds.delete(id);
|
|
2300
|
+
if (openDialog?.bulkInFlight) openDialog.bulkInFlight.done = i + 1;
|
|
2301
|
+
refreshOpenDialog();
|
|
2302
|
+
}
|
|
2303
|
+
if (openDialog) {
|
|
2304
|
+
openDialog.inFlight = null;
|
|
2305
|
+
openDialog.bulkInFlight = null;
|
|
1718
2306
|
}
|
|
1719
2307
|
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
2308
|
+
const verb = action === "archive" ? "archived" : "deleted";
|
|
2309
|
+
if (okCount === 0) {
|
|
2310
|
+
setDialogError(`${action} failed: ${lastErr || "unknown error"}`);
|
|
2311
|
+
} else if (lastErr) {
|
|
2312
|
+
setDialogError(`${verb} ${okCount}/${targets.length}; last error: ${lastErr}`);
|
|
2313
|
+
} else {
|
|
2314
|
+
editor.setStatus(`Orchestrator: ${verb} ${okCount} session(s)`);
|
|
2315
|
+
}
|
|
2316
|
+
for (const repo of touchedRepos) triggerSyncAsync(repo);
|
|
2317
|
+
refreshOpenDialog();
|
|
2318
|
+
// The batch emptied the selection, so the pane is back in
|
|
2319
|
+
// single-preview mode — restore focus to Visit (the bulk buttons
|
|
2320
|
+
// it may have been on are gone).
|
|
2321
|
+
if (openPanel && selectedSessions().length < 2 && !openDialog.pendingConfirm) {
|
|
2322
|
+
openPanel.setFocusKey("visit");
|
|
2323
|
+
}
|
|
1723
2324
|
}
|
|
1724
2325
|
|
|
1725
2326
|
// `Alt+N` from inside the picker opens the new-session form — saves
|
|
@@ -1742,6 +2343,25 @@ editor.defineMode(
|
|
|
1742
2343
|
// text; session names don't contain `/`, so that's an
|
|
1743
2344
|
// acceptable trade for the quick-focus.)
|
|
1744
2345
|
["/", "orchestrator_focus_filter"],
|
|
2346
|
+
// Space toggles the highlighted row's membership in the bulk
|
|
2347
|
+
// selection. Bound as a mode chord (not a widget smart-key) so
|
|
2348
|
+
// it's user-rebindable in the keybinding editor and fires
|
|
2349
|
+
// regardless of which control holds focus — the host's
|
|
2350
|
+
// `dispatch_floating_widget_key` defers any explicitly-bound
|
|
2351
|
+
// mode key, including bare chars, before the text-input path.
|
|
2352
|
+
// The trade (same as `/`) is that Space can't be typed into the
|
|
2353
|
+
// filter while the picker is open; session names don't contain
|
|
2354
|
+
// spaces, so that's acceptable.
|
|
2355
|
+
["Space", "orchestrator_toggle_select"],
|
|
2356
|
+
// Alt+T toggles "Show all worktrees" — the opt-in filter that
|
|
2357
|
+
// surfaces discovered on-disk worktree rows. Rebindable, same as
|
|
2358
|
+
// the scope toggle.
|
|
2359
|
+
["M-t", "orchestrator_toggle_worktrees"],
|
|
2360
|
+
// Alt+I toggles "Show empty/1-file sessions" — reveals the trivial
|
|
2361
|
+
// restored shells hidden by default. Rebindable, same as the others.
|
|
2362
|
+
// (Alt+E is unavailable: it's the Edit menu's mnemonic, which the
|
|
2363
|
+
// menu bar claims before the picker's mode keymap sees it.)
|
|
2364
|
+
["M-i", "orchestrator_toggle_trivial"],
|
|
1745
2365
|
],
|
|
1746
2366
|
true,
|
|
1747
2367
|
true,
|
|
@@ -1758,6 +2378,38 @@ registerHandler("orchestrator_focus_filter", () => {
|
|
|
1758
2378
|
openPanel.setFocusKey("filter");
|
|
1759
2379
|
});
|
|
1760
2380
|
|
|
2381
|
+
// Space (rebindable): toggle the highlighted row in/out of the bulk
|
|
2382
|
+
// selection. Manages focus across the single↔bulk transition: when
|
|
2383
|
+
// the second row is checked the preview pane swaps to the bulk bar
|
|
2384
|
+
// (so the now-absent "visit" focus would otherwise be clamped to a
|
|
2385
|
+
// random tabbable), and when the selection drops back below two the
|
|
2386
|
+
// per-session preview — with its "visit" button — returns.
|
|
2387
|
+
registerHandler("orchestrator_toggle_select", () => {
|
|
2388
|
+
if (!openDialog || !openPanel) return;
|
|
2389
|
+
// Inert while a confirm prompt is up — the selection is frozen
|
|
2390
|
+
// behind the confirmation panel.
|
|
2391
|
+
if (openDialog.pendingConfirm) return;
|
|
2392
|
+
const id = openDialog.filteredIds[openDialog.selectedIndex];
|
|
2393
|
+
if (typeof id !== "number") return;
|
|
2394
|
+
const wasBulk = selectedSessions().length >= 2;
|
|
2395
|
+
if (openDialog.selectedIds.has(id)) {
|
|
2396
|
+
openDialog.selectedIds.delete(id);
|
|
2397
|
+
} else {
|
|
2398
|
+
openDialog.selectedIds.add(id);
|
|
2399
|
+
}
|
|
2400
|
+
clearDialogError();
|
|
2401
|
+
refreshOpenDialog();
|
|
2402
|
+
const isBulk = selectedSessions().length >= 2;
|
|
2403
|
+
if (!wasBulk && isBulk) {
|
|
2404
|
+
// Entering bulk mode — land focus on a bulk button (Up/Down from
|
|
2405
|
+
// a button still drives the list, so navigation keeps working).
|
|
2406
|
+
openPanel.setFocusKey("bulk-archive");
|
|
2407
|
+
} else if (wasBulk && !isBulk) {
|
|
2408
|
+
// Back to single preview — restore focus to Visit.
|
|
2409
|
+
openPanel.setFocusKey("visit");
|
|
2410
|
+
}
|
|
2411
|
+
});
|
|
2412
|
+
|
|
1761
2413
|
function toggleScope(): void {
|
|
1762
2414
|
if (!openDialog) return;
|
|
1763
2415
|
openDialog.scope = openDialog.scope === "current" ? "all" : "current";
|
|
@@ -1776,6 +2428,55 @@ function toggleScope(): void {
|
|
|
1776
2428
|
|
|
1777
2429
|
registerHandler("orchestrator_toggle_scope", toggleScope);
|
|
1778
2430
|
|
|
2431
|
+
// Flip "Show all worktrees" — reveal/hide the discovered on-disk
|
|
2432
|
+
// worktree rows. Preserves the highlighted row across the re-filter
|
|
2433
|
+
// where possible; drops now-hidden discovered rows from the bulk
|
|
2434
|
+
// selection. Shared by the Alt+T chord and the checkbox click.
|
|
2435
|
+
function toggleShowWorktrees(): void {
|
|
2436
|
+
if (!openDialog) return;
|
|
2437
|
+
openDialog.showWorktrees = !openDialog.showWorktrees;
|
|
2438
|
+
lastShowWorktrees = openDialog.showWorktrees;
|
|
2439
|
+
// Hiding worktrees shouldn't leave them lingering in the selection.
|
|
2440
|
+
if (!openDialog.showWorktrees) {
|
|
2441
|
+
for (const id of [...openDialog.selectedIds]) {
|
|
2442
|
+
if (orchestratorSessions.get(id)?.discovered) {
|
|
2443
|
+
openDialog.selectedIds.delete(id);
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
const prevId = openDialog.filteredIds[openDialog.selectedIndex];
|
|
2448
|
+
openDialog.filteredIds = filterSessions(openDialog.filter.value);
|
|
2449
|
+
const nextIdx = prevId !== undefined ? openDialog.filteredIds.indexOf(prevId) : -1;
|
|
2450
|
+
openDialog.selectedIndex = nextIdx >= 0 ? nextIdx : 0;
|
|
2451
|
+
refreshOpenDialog();
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
registerHandler("orchestrator_toggle_worktrees", toggleShowWorktrees);
|
|
2455
|
+
|
|
2456
|
+
// Flip "Show empty/1-file sessions" — reveal/hide the trivial restored
|
|
2457
|
+
// shells. Preserves the highlighted row across the re-filter where
|
|
2458
|
+
// possible; drops now-hidden rows from the bulk selection. Shared by the
|
|
2459
|
+
// Alt+I chord and the checkbox click.
|
|
2460
|
+
function toggleHideTrivial(): void {
|
|
2461
|
+
if (!openDialog) return;
|
|
2462
|
+
openDialog.hideTrivial = !openDialog.hideTrivial;
|
|
2463
|
+
lastHideTrivial = openDialog.hideTrivial;
|
|
2464
|
+
const prevId = openDialog.filteredIds[openDialog.selectedIndex];
|
|
2465
|
+
openDialog.filteredIds = filterSessions(openDialog.filter.value);
|
|
2466
|
+
// Hiding trivial rows shouldn't leave them lingering in the selection.
|
|
2467
|
+
if (openDialog.hideTrivial) {
|
|
2468
|
+
const visible = new Set(openDialog.filteredIds);
|
|
2469
|
+
for (const id of [...openDialog.selectedIds]) {
|
|
2470
|
+
if (!visible.has(id)) openDialog.selectedIds.delete(id);
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
const nextIdx = prevId !== undefined ? openDialog.filteredIds.indexOf(prevId) : -1;
|
|
2474
|
+
openDialog.selectedIndex = nextIdx >= 0 ? nextIdx : 0;
|
|
2475
|
+
refreshOpenDialog();
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
registerHandler("orchestrator_toggle_trivial", toggleHideTrivial);
|
|
2479
|
+
|
|
1779
2480
|
// =============================================================================
|
|
1780
2481
|
// New-session floating form
|
|
1781
2482
|
// =============================================================================
|
|
@@ -2078,6 +2779,130 @@ async function pathIsInsideGitWorkTree(
|
|
|
2078
2779
|
return (res.stdout || "").trim() === "true";
|
|
2079
2780
|
}
|
|
2080
2781
|
|
|
2782
|
+
// =============================================================================
|
|
2783
|
+
// Worktree classification & discovery
|
|
2784
|
+
//
|
|
2785
|
+
// Two distinct git facts drive the "attach to an existing worktree"
|
|
2786
|
+
// flows:
|
|
2787
|
+
//
|
|
2788
|
+
// * `classifyWorktree(path)` answers "is this path a *linked*
|
|
2789
|
+
// worktree, and if so what repo does it belong to?" — used by
|
|
2790
|
+
// the new-session form to attach (rather than fork) when the
|
|
2791
|
+
// user points Project Path at an existing worktree.
|
|
2792
|
+
// * `listLinkedWorktrees(repoRoot)` enumerates every linked
|
|
2793
|
+
// worktree of a repo (via `git worktree list --porcelain`) —
|
|
2794
|
+
// used to surface on-disk worktrees in the Open dialog without
|
|
2795
|
+
// the user adding them by hand.
|
|
2796
|
+
// =============================================================================
|
|
2797
|
+
|
|
2798
|
+
interface WorktreeInfo {
|
|
2799
|
+
// `git rev-parse --show-toplevel` for the path.
|
|
2800
|
+
toplevel: string;
|
|
2801
|
+
// Canonical main-worktree root (dirname of `--git-common-dir`).
|
|
2802
|
+
// This is the repo the worktree belongs to, used as the
|
|
2803
|
+
// session's `projectPath` so attached worktrees group under
|
|
2804
|
+
// their repo in the picker.
|
|
2805
|
+
mainRoot: string;
|
|
2806
|
+
// `true` when the path is a *linked* worktree (its per-worktree
|
|
2807
|
+
// git dir differs from the shared common dir), i.e. a tree
|
|
2808
|
+
// created by `git worktree add` rather than the main checkout.
|
|
2809
|
+
isLinked: boolean;
|
|
2810
|
+
// Branch checked out there (`refs/heads/<name>` short form), or
|
|
2811
|
+
// empty when detached.
|
|
2812
|
+
branch: string;
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
/// Classify `path` as a git worktree. Returns `null` when `path`
|
|
2816
|
+
/// is not inside any git work tree (the caller then treats it as a
|
|
2817
|
+
/// plain directory / shared root).
|
|
2818
|
+
async function classifyWorktree(path: string): Promise<WorktreeInfo | null> {
|
|
2819
|
+
if (!path) return null;
|
|
2820
|
+
const top = await spawnCollect("git", ["-C", path, "rev-parse", "--show-toplevel"], path);
|
|
2821
|
+
if (top.exit_code !== 0) return null;
|
|
2822
|
+
const toplevel = (top.stdout || "").trim();
|
|
2823
|
+
if (!toplevel) return null;
|
|
2824
|
+
|
|
2825
|
+
// The per-worktree git dir vs. the shared common dir: they are
|
|
2826
|
+
// equal for the main worktree and differ for every linked
|
|
2827
|
+
// worktree (`<common>/worktrees/<id>`). That difference is the
|
|
2828
|
+
// canonical "is this a linked worktree?" test.
|
|
2829
|
+
const [gitDir, commonDir] = await Promise.all([
|
|
2830
|
+
spawnCollect("git", ["-C", toplevel, "rev-parse", "--path-format=absolute", "--git-dir"], toplevel),
|
|
2831
|
+
spawnCollect(
|
|
2832
|
+
"git",
|
|
2833
|
+
["-C", toplevel, "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
2834
|
+
toplevel,
|
|
2835
|
+
),
|
|
2836
|
+
]);
|
|
2837
|
+
const gd = gitDir.exit_code === 0 ? (gitDir.stdout || "").trim() : "";
|
|
2838
|
+
const cd = commonDir.exit_code === 0 ? (commonDir.stdout || "").trim() : "";
|
|
2839
|
+
const isLinked = gd !== "" && cd !== "" && gd !== cd;
|
|
2840
|
+
const mainRoot = cd ? editor.pathDirname(cd) : toplevel;
|
|
2841
|
+
|
|
2842
|
+
const head = await spawnCollect(
|
|
2843
|
+
"git",
|
|
2844
|
+
["-C", toplevel, "rev-parse", "--abbrev-ref", "HEAD"],
|
|
2845
|
+
toplevel,
|
|
2846
|
+
);
|
|
2847
|
+
let branch = head.exit_code === 0 ? (head.stdout || "").trim() : "";
|
|
2848
|
+
if (branch === "HEAD") branch = ""; // detached
|
|
2849
|
+
|
|
2850
|
+
return { toplevel, mainRoot, isLinked, branch };
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
interface ParsedWorktree {
|
|
2854
|
+
path: string;
|
|
2855
|
+
branch: string;
|
|
2856
|
+
detached: boolean;
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2859
|
+
/// Parse `git worktree list --porcelain` output. Blocks are
|
|
2860
|
+
/// separated by blank lines; the first block is the main worktree,
|
|
2861
|
+
/// the rest are linked. Each block has a `worktree <path>` line
|
|
2862
|
+
/// plus `branch refs/heads/<name>` or `detached`.
|
|
2863
|
+
function parseWorktreePorcelain(stdout: string): ParsedWorktree[] {
|
|
2864
|
+
const out: ParsedWorktree[] = [];
|
|
2865
|
+
let cur: ParsedWorktree | null = null;
|
|
2866
|
+
for (const raw of (stdout || "").split(/\r?\n/)) {
|
|
2867
|
+
const line = raw.trimEnd();
|
|
2868
|
+
if (line.startsWith("worktree ")) {
|
|
2869
|
+
if (cur) out.push(cur);
|
|
2870
|
+
cur = { path: line.slice("worktree ".length), branch: "", detached: false };
|
|
2871
|
+
} else if (cur && line.startsWith("branch ")) {
|
|
2872
|
+
const ref = line.slice("branch ".length);
|
|
2873
|
+
cur.branch = ref.replace(/^refs\/heads\//, "");
|
|
2874
|
+
} else if (cur && line === "detached") {
|
|
2875
|
+
cur.detached = true;
|
|
2876
|
+
} else if (line === "" && cur) {
|
|
2877
|
+
out.push(cur);
|
|
2878
|
+
cur = null;
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
if (cur) out.push(cur);
|
|
2882
|
+
return out;
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
/// Enumerate the *linked* worktrees of `repoRoot` (excludes the
|
|
2886
|
+
/// main worktree, which is the repo's own checkout). Returns the
|
|
2887
|
+
/// parsed entries with the main-repo root resolved so callers can
|
|
2888
|
+
/// tag discovered sessions with the right `projectPath`.
|
|
2889
|
+
async function listLinkedWorktrees(
|
|
2890
|
+
repoRoot: string,
|
|
2891
|
+
): Promise<{ mainRoot: string; worktrees: ParsedWorktree[] } | null> {
|
|
2892
|
+
const res = await spawnCollect(
|
|
2893
|
+
"git",
|
|
2894
|
+
["-C", repoRoot, "worktree", "list", "--porcelain"],
|
|
2895
|
+
repoRoot,
|
|
2896
|
+
);
|
|
2897
|
+
if (res.exit_code !== 0) return null;
|
|
2898
|
+
const all = parseWorktreePorcelain(res.stdout || "");
|
|
2899
|
+
if (all.length === 0) return null;
|
|
2900
|
+
// The first entry is always the main worktree.
|
|
2901
|
+
const mainRoot = all[0].path;
|
|
2902
|
+
const worktrees = all.slice(1);
|
|
2903
|
+
return { mainRoot, worktrees };
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2081
2906
|
async function nextAutoSessionName(
|
|
2082
2907
|
repoRoot: string,
|
|
2083
2908
|
options?: { persist?: boolean },
|
|
@@ -2164,9 +2989,11 @@ function buildFormSpec(): WidgetSpec {
|
|
|
2164
2989
|
// inert when worktree creation is off.
|
|
2165
2990
|
let branchPlaceholder: string;
|
|
2166
2991
|
if (branchInert) {
|
|
2167
|
-
branchPlaceholder = worktreeEnabled
|
|
2168
|
-
? "
|
|
2169
|
-
:
|
|
2992
|
+
branchPlaceholder = !worktreeEnabled
|
|
2993
|
+
? "no git — N/A"
|
|
2994
|
+
: form.projectPathIsLinkedWorktree === true
|
|
2995
|
+
? "existing worktree — N/A"
|
|
2996
|
+
: "shared worktree — N/A";
|
|
2170
2997
|
} else if (!form.defaultBranch) {
|
|
2171
2998
|
branchPlaceholder = "detecting default branch…";
|
|
2172
2999
|
} else if (form.defaultBranchIsHeadFallback) {
|
|
@@ -2241,6 +3068,24 @@ function buildFormSpec(): WidgetSpec {
|
|
|
2241
3068
|
]),
|
|
2242
3069
|
],
|
|
2243
3070
|
},
|
|
3071
|
+
// Existing-worktree hint: when Project Path points at a linked
|
|
3072
|
+
// worktree, explain what the (un)checked box now means so the
|
|
3073
|
+
// attach behaviour isn't a silent surprise.
|
|
3074
|
+
...(form.projectPathIsLinkedWorktree === true
|
|
3075
|
+
? [{
|
|
3076
|
+
kind: "raw" as const,
|
|
3077
|
+
entries: [
|
|
3078
|
+
styledRow([
|
|
3079
|
+
{
|
|
3080
|
+
text: form.createWorktree
|
|
3081
|
+
? " ↳ existing worktree here — uncheck to attach instead of forking a new one"
|
|
3082
|
+
: " ↳ existing worktree — this session will attach to it",
|
|
3083
|
+
style: { fg: "ui.help_key_fg", italic: true },
|
|
3084
|
+
},
|
|
3085
|
+
]),
|
|
3086
|
+
],
|
|
3087
|
+
}]
|
|
3088
|
+
: []),
|
|
2244
3089
|
// === Form body: labeled, full-width inputs. ==================
|
|
2245
3090
|
// Labels are plain — the `▸` glyph used to be baked into all
|
|
2246
3091
|
// three strings and stayed put regardless of focus, which was
|
|
@@ -2380,6 +3225,7 @@ function openForm(options?: { fromPicker?: boolean }): void {
|
|
|
2380
3225
|
lastError: null,
|
|
2381
3226
|
defaultProjectPath: "",
|
|
2382
3227
|
projectPathIsGit: null,
|
|
3228
|
+
projectPathIsLinkedWorktree: null,
|
|
2383
3229
|
defaultSessionName: "",
|
|
2384
3230
|
defaultBranch: "",
|
|
2385
3231
|
defaultBranchIsHeadFallback: false,
|
|
@@ -2442,6 +3288,24 @@ async function probeProjectPathDefaults(): Promise<void> {
|
|
|
2442
3288
|
if (!form || form.probeToken !== token) return;
|
|
2443
3289
|
form.projectPathIsGit = isGit;
|
|
2444
3290
|
|
|
3291
|
+
// (2b) Existing-linked-worktree detection. When the path is a
|
|
3292
|
+
// worktree created by `git worktree add` (not the repo's main
|
|
3293
|
+
// checkout), default the checkbox to *unchecked* so the
|
|
3294
|
+
// natural action is to attach to it. Only flip on the
|
|
3295
|
+
// detection transition so we don't fight a user who
|
|
3296
|
+
// deliberately re-checks "create a new worktree".
|
|
3297
|
+
const wasLinked = form.projectPathIsLinkedWorktree;
|
|
3298
|
+
if (isGit) {
|
|
3299
|
+
const info = await classifyWorktree(effectivePath);
|
|
3300
|
+
if (!form || form.probeToken !== token) return;
|
|
3301
|
+
form.projectPathIsLinkedWorktree = info?.isLinked === true;
|
|
3302
|
+
} else {
|
|
3303
|
+
form.projectPathIsLinkedWorktree = false;
|
|
3304
|
+
}
|
|
3305
|
+
if (form.projectPathIsLinkedWorktree && wasLinked !== true) {
|
|
3306
|
+
form.createWorktree = false;
|
|
3307
|
+
}
|
|
3308
|
+
|
|
2445
3309
|
// (3) Default branch + session name probes only make sense on
|
|
2446
3310
|
// a git path. On non-git, leave both empty (the renderer
|
|
2447
3311
|
// surfaces a "no git — N/A" branch placeholder, and the
|
|
@@ -2861,14 +3725,28 @@ async function submitForm(): Promise<void> {
|
|
|
2861
3725
|
editor.setGlobalState("orchestrator.last_cmd", cmd);
|
|
2862
3726
|
}
|
|
2863
3727
|
|
|
3728
|
+
// Attach-to-existing-worktree: when the user opted out of
|
|
3729
|
+
// creating a worktree but pointed Project Path at an *existing
|
|
3730
|
+
// linked worktree* (one created by `git worktree add`, possibly
|
|
3731
|
+
// for a repo Fresh has never opened before), treat it as the
|
|
3732
|
+
// dedicated worktree it is rather than a shared root. That means
|
|
3733
|
+
// `shared_worktree = false` (so Archive / Delete can
|
|
3734
|
+
// `git worktree move` / `remove` it) and a `project_path` of the
|
|
3735
|
+
// owning repo so the session groups with its siblings. A path
|
|
3736
|
+
// that's the repo's *main* worktree, or a non-git directory, stays
|
|
3737
|
+
// shared — you can't `git worktree remove` either of those.
|
|
3738
|
+
const attachInfo = !createWorktree ? await classifyWorktree(root) : null;
|
|
3739
|
+
if (!form) return;
|
|
3740
|
+
const isLinkedAttach = attachInfo?.isLinked === true;
|
|
3741
|
+
const effectiveProjectPath = isLinkedAttach ? attachInfo!.mainRoot : projectPath;
|
|
3742
|
+
|
|
2864
3743
|
// Branch / cmd values used for the per-window state record —
|
|
2865
|
-
// `branchName` only exists in the worktree-create flow above;
|
|
2866
|
-
//
|
|
2867
|
-
//
|
|
2868
|
-
// matches the situation on disk.
|
|
3744
|
+
// `branchName` only exists in the worktree-create flow above; for
|
|
3745
|
+
// an attached linked worktree we report its checked-out branch;
|
|
3746
|
+
// for the shared-worktree / non-git case we leave it blank.
|
|
2869
3747
|
const reportedBranch = createWorktree
|
|
2870
3748
|
? (branchInput || sessionName)
|
|
2871
|
-
: "";
|
|
3749
|
+
: (isLinkedAttach ? attachInfo!.branch : "");
|
|
2872
3750
|
|
|
2873
3751
|
// Append the user-effective values to per-field input
|
|
2874
3752
|
// history so ↑/↓ can recall them on the next form open.
|
|
@@ -2886,7 +3764,9 @@ async function submitForm(): Promise<void> {
|
|
|
2886
3764
|
// terminal IS the new window's seed buffer, so the window is
|
|
2887
3765
|
// born with a single tab.
|
|
2888
3766
|
const argv = splitAgentCmd(cmd);
|
|
2889
|
-
|
|
3767
|
+
// Shared only when we neither created a worktree nor attached to an
|
|
3768
|
+
// existing linked one (i.e. a non-git dir or the repo's main tree).
|
|
3769
|
+
const sharedWorktree = !createWorktree && !isLinkedAttach;
|
|
2890
3770
|
try {
|
|
2891
3771
|
const result = await editor.createWindowWithTerminal({
|
|
2892
3772
|
root,
|
|
@@ -2898,17 +3778,26 @@ async function submitForm(): Promise<void> {
|
|
|
2898
3778
|
const id = result.windowId;
|
|
2899
3779
|
// `createWindowWithTerminal` already dove into the new window,
|
|
2900
3780
|
// so `setWindowState` writes to it.
|
|
2901
|
-
editor.setWindowState("project_path",
|
|
3781
|
+
editor.setWindowState("project_path", effectiveProjectPath);
|
|
2902
3782
|
editor.setWindowState("shared_worktree", sharedWorktree);
|
|
3783
|
+
// If we attached to a worktree that was sitting in the picker as
|
|
3784
|
+
// a discovered row, drop that placeholder — this live window
|
|
3785
|
+
// supersedes it.
|
|
3786
|
+
const discId = discoveredIdByPath.get(root);
|
|
3787
|
+
if (discId !== undefined) {
|
|
3788
|
+
orchestratorSessions.delete(discId);
|
|
3789
|
+
discoveredIdByPath.delete(root);
|
|
3790
|
+
}
|
|
2903
3791
|
const tracked: AgentSession = {
|
|
2904
3792
|
id,
|
|
2905
3793
|
label: sessionName,
|
|
2906
3794
|
root,
|
|
2907
|
-
projectPath,
|
|
3795
|
+
projectPath: effectiveProjectPath,
|
|
2908
3796
|
sharedWorktree,
|
|
2909
3797
|
terminalId: result.terminalId,
|
|
2910
3798
|
state: "running",
|
|
2911
3799
|
createdAt: Date.now(),
|
|
3800
|
+
branch: reportedBranch || undefined,
|
|
2912
3801
|
};
|
|
2913
3802
|
orchestratorSessions.set(id, tracked);
|
|
2914
3803
|
} catch (e) {
|
|
@@ -2920,6 +3809,54 @@ async function submitForm(): Promise<void> {
|
|
|
2920
3809
|
}
|
|
2921
3810
|
}
|
|
2922
3811
|
|
|
3812
|
+
/// Open a session in an existing worktree without creating one —
|
|
3813
|
+
/// the dive action for a discovered row, and the building block the
|
|
3814
|
+
/// new-session form reuses when the user points Project Path at an
|
|
3815
|
+
/// existing linked worktree. Spawns a bare terminal (no agent
|
|
3816
|
+
/// command) rooted at the worktree, tags the window with its
|
|
3817
|
+
/// canonical project + `shared_worktree = false` so Archive / Delete
|
|
3818
|
+
/// manage it as the real worktree it is, then drops the discovered
|
|
3819
|
+
/// placeholder (the live window supersedes it).
|
|
3820
|
+
async function attachToWorktree(opts: {
|
|
3821
|
+
root: string;
|
|
3822
|
+
projectPath: string;
|
|
3823
|
+
label: string;
|
|
3824
|
+
branch?: string;
|
|
3825
|
+
discoveredId?: number;
|
|
3826
|
+
}): Promise<void> {
|
|
3827
|
+
try {
|
|
3828
|
+
const result = await editor.createWindowWithTerminal({
|
|
3829
|
+
root: opts.root,
|
|
3830
|
+
label: opts.label,
|
|
3831
|
+
cwd: opts.root,
|
|
3832
|
+
});
|
|
3833
|
+
const id = result.windowId;
|
|
3834
|
+
editor.setWindowState("project_path", opts.projectPath);
|
|
3835
|
+
editor.setWindowState("shared_worktree", false);
|
|
3836
|
+
if (opts.discoveredId !== undefined) {
|
|
3837
|
+
orchestratorSessions.delete(opts.discoveredId);
|
|
3838
|
+
discoveredIdByPath.delete(opts.root);
|
|
3839
|
+
}
|
|
3840
|
+
orchestratorSessions.set(id, {
|
|
3841
|
+
id,
|
|
3842
|
+
label: opts.label,
|
|
3843
|
+
root: opts.root,
|
|
3844
|
+
projectPath: opts.projectPath,
|
|
3845
|
+
sharedWorktree: false,
|
|
3846
|
+
terminalId: result.terminalId,
|
|
3847
|
+
state: "running",
|
|
3848
|
+
createdAt: Date.now(),
|
|
3849
|
+
branch: opts.branch,
|
|
3850
|
+
});
|
|
3851
|
+
} catch (e) {
|
|
3852
|
+
editor.setStatus(
|
|
3853
|
+
`Orchestrator: failed to attach session — ${
|
|
3854
|
+
e instanceof Error ? e.message : String(e)
|
|
3855
|
+
}`,
|
|
3856
|
+
);
|
|
3857
|
+
}
|
|
3858
|
+
}
|
|
3859
|
+
|
|
2923
3860
|
function startNewSession(): void {
|
|
2924
3861
|
if (form) return; // already open
|
|
2925
3862
|
openForm();
|
|
@@ -3116,39 +4053,54 @@ function enterConfirm(action: "stop" | "archive" | "delete"): void {
|
|
|
3116
4053
|
if (!openDialog || !openPanel) return;
|
|
3117
4054
|
const id = openDialog.filteredIds[openDialog.selectedIndex];
|
|
3118
4055
|
if (typeof id !== "number" || id <= 0) return;
|
|
3119
|
-
//
|
|
3120
|
-
//
|
|
3121
|
-
//
|
|
3122
|
-
//
|
|
3123
|
-
//
|
|
3124
|
-
//
|
|
3125
|
-
//
|
|
4056
|
+
// Archive moves the worktree to the graveyard, so it only applies to
|
|
4057
|
+
// a session that owns one. A launch/in-place session runs inside a
|
|
4058
|
+
// real checkout with no `git worktree` entry — Archive would have
|
|
4059
|
+
// nothing to move (and must never rm-rf the user's actual project).
|
|
4060
|
+
// Delete forgets the session and removes the worktree only when one
|
|
4061
|
+
// is owned. Both refuse the last live window (the editor must keep at
|
|
4062
|
+
// least one) — surface that before the confirm step.
|
|
3126
4063
|
if (action === "archive" || action === "delete") {
|
|
3127
4064
|
const session = orchestratorSessions.get(id);
|
|
3128
|
-
if (session) {
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
refreshOpenDialog();
|
|
3135
|
-
return;
|
|
3136
|
-
}
|
|
3137
|
-
if (session.sharedWorktree) {
|
|
3138
|
-
// Single-session shared-worktree mode: there's no
|
|
3139
|
-
// `git worktree` entry to remove for this session.
|
|
3140
|
-
// Block both lifecycle actions so we don't run
|
|
3141
|
-
// `git worktree remove` against a non-worktree path
|
|
3142
|
-
// and rm-rf the user's actual project directory.
|
|
3143
|
-
setDialogError(
|
|
3144
|
-
`cannot ${action} session [${id}] ${session.label} — session shares its working tree with the project root; close it via the editor instead`,
|
|
3145
|
-
);
|
|
3146
|
-
refreshOpenDialog();
|
|
3147
|
-
return;
|
|
3148
|
-
}
|
|
4065
|
+
if (session && session.id > 0 && liveWindowCount() <= 1) {
|
|
4066
|
+
setDialogError(
|
|
4067
|
+
`cannot ${action} session [${id}] ${session.label} — it's the last window; open another session first`,
|
|
4068
|
+
);
|
|
4069
|
+
refreshOpenDialog();
|
|
4070
|
+
return;
|
|
3149
4071
|
}
|
|
3150
4072
|
}
|
|
3151
|
-
|
|
4073
|
+
if (action === "archive") {
|
|
4074
|
+
const session = orchestratorSessions.get(id);
|
|
4075
|
+
if (session && !ownsWorktree(session)) {
|
|
4076
|
+
setDialogError(
|
|
4077
|
+
`cannot archive session [${id}] ${session.label} — it has no dedicated worktree; use Delete to forget it`,
|
|
4078
|
+
);
|
|
4079
|
+
refreshOpenDialog();
|
|
4080
|
+
return;
|
|
4081
|
+
}
|
|
4082
|
+
}
|
|
4083
|
+
openDialog.pendingConfirm = { action, ids: [id] };
|
|
4084
|
+
openPanel.update(buildOpenSpec());
|
|
4085
|
+
openPanel.setFocusKey("confirm-cancel");
|
|
4086
|
+
}
|
|
4087
|
+
|
|
4088
|
+
// Open the confirm panel for a *bulk* action over the current
|
|
4089
|
+
// checkbox selection. Filters to the eligible members up front (so
|
|
4090
|
+
// the confirm count matches what will actually run); refuses with a
|
|
4091
|
+
// banner when nothing is eligible.
|
|
4092
|
+
function enterBulkConfirm(action: BulkAction): void {
|
|
4093
|
+
if (!openDialog || !openPanel) return;
|
|
4094
|
+
const targets = eligibleSelected(action);
|
|
4095
|
+
if (targets.length === 0) {
|
|
4096
|
+
setDialogError(`no selected session can be ${action === "stop" ? "stopped" : action + "d"}`);
|
|
4097
|
+
refreshOpenDialog();
|
|
4098
|
+
return;
|
|
4099
|
+
}
|
|
4100
|
+
// All three actions confirm — even Stop, so a bulk Stop over a
|
|
4101
|
+
// large selection isn't a single mis-key away. The confirm panel
|
|
4102
|
+
// lists the targets and shows the eligible count.
|
|
4103
|
+
openDialog.pendingConfirm = { action, ids: targets };
|
|
3152
4104
|
openPanel.update(buildOpenSpec());
|
|
3153
4105
|
openPanel.setFocusKey("confirm-cancel");
|
|
3154
4106
|
}
|
|
@@ -3290,7 +4242,16 @@ editor.on("widget_event", (e) => {
|
|
|
3290
4242
|
refreshOpenDialog();
|
|
3291
4243
|
return;
|
|
3292
4244
|
}
|
|
3293
|
-
|
|
4245
|
+
// List selection. Keyboard nav fires this with `widget_key`
|
|
4246
|
+
// "sessions" (the list's own key); a mouse click on a row fires it
|
|
4247
|
+
// with `widget_key` set to the clicked item's key, carrying the
|
|
4248
|
+
// list key in `payload.list_key` instead — accept both so clicking a
|
|
4249
|
+
// row selects it (highlight + preview) just like arrowing to it.
|
|
4250
|
+
if (
|
|
4251
|
+
e.event_type === "select" &&
|
|
4252
|
+
(e.widget_key === "sessions" ||
|
|
4253
|
+
((e.payload ?? {}) as Record<string, unknown>).list_key === "sessions")
|
|
4254
|
+
) {
|
|
3294
4255
|
const payload = (e.payload ?? {}) as Record<string, unknown>;
|
|
3295
4256
|
const idx = payload.index;
|
|
3296
4257
|
if (typeof idx === "number") {
|
|
@@ -3307,8 +4268,11 @@ editor.on("widget_event", (e) => {
|
|
|
3307
4268
|
// on the button. Snap focus back to Visit so the user can
|
|
3308
4269
|
// press Enter to open the newly-highlighted session — the
|
|
3309
4270
|
// dialog's whole reason for being. Idempotent when focus
|
|
3310
|
-
// is already on Visit.
|
|
3311
|
-
|
|
4271
|
+
// is already on Visit. Skipped in bulk mode and during a
|
|
4272
|
+
// confirm, where "visit" isn't in the spec.
|
|
4273
|
+
if (selectedSessions().length < 2 && !openDialog.pendingConfirm) {
|
|
4274
|
+
openPanel.setFocusKey("visit");
|
|
4275
|
+
}
|
|
3312
4276
|
}
|
|
3313
4277
|
return;
|
|
3314
4278
|
}
|
|
@@ -3317,6 +4281,20 @@ editor.on("widget_event", (e) => {
|
|
|
3317
4281
|
(e.widget_key === "sessions" || e.widget_key === "visit")
|
|
3318
4282
|
) {
|
|
3319
4283
|
const id = openDialog.filteredIds[openDialog.selectedIndex];
|
|
4284
|
+
const sel = typeof id === "number" ? orchestratorSessions.get(id) : undefined;
|
|
4285
|
+
if (sel && sel.discovered) {
|
|
4286
|
+
// Discovered worktree: there's no window to switch to —
|
|
4287
|
+
// open one by attaching a fresh session to the worktree.
|
|
4288
|
+
closeOpenDialog();
|
|
4289
|
+
void attachToWorktree({
|
|
4290
|
+
root: sel.root,
|
|
4291
|
+
projectPath: sel.projectPath ?? sel.root,
|
|
4292
|
+
label: sel.label,
|
|
4293
|
+
branch: sel.branch,
|
|
4294
|
+
discoveredId: sel.id,
|
|
4295
|
+
});
|
|
4296
|
+
return;
|
|
4297
|
+
}
|
|
3320
4298
|
if (typeof id === "number" && id > 0 && id !== editor.activeWindow()) {
|
|
3321
4299
|
editor.setActiveWindow(id);
|
|
3322
4300
|
}
|
|
@@ -3337,6 +4315,18 @@ editor.on("widget_event", (e) => {
|
|
|
3337
4315
|
refreshOpenDialog();
|
|
3338
4316
|
return;
|
|
3339
4317
|
}
|
|
4318
|
+
if (e.event_type === "toggle" && e.widget_key === "worktree-show") {
|
|
4319
|
+
// The toggle widget reports the new checked state; route through
|
|
4320
|
+
// the shared flip so the Alt+T chord and the click stay in sync.
|
|
4321
|
+
toggleShowWorktrees();
|
|
4322
|
+
return;
|
|
4323
|
+
}
|
|
4324
|
+
if (e.event_type === "toggle" && e.widget_key === "hide-trivial") {
|
|
4325
|
+
// Same pattern as the worktree toggle: route the click through the
|
|
4326
|
+
// shared flip so the checkbox and the Alt+I chord stay in sync.
|
|
4327
|
+
toggleHideTrivial();
|
|
4328
|
+
return;
|
|
4329
|
+
}
|
|
3340
4330
|
if (e.event_type === "activate" && e.widget_key === "stop") {
|
|
3341
4331
|
enterConfirm("stop");
|
|
3342
4332
|
return;
|
|
@@ -3349,44 +4339,47 @@ editor.on("widget_event", (e) => {
|
|
|
3349
4339
|
enterConfirm("delete");
|
|
3350
4340
|
return;
|
|
3351
4341
|
}
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
4342
|
+
// Bulk action bar (Layout B) — Stop / Archive / Delete over the
|
|
4343
|
+
// checkbox selection, plus Clear.
|
|
4344
|
+
if (e.event_type === "activate" && e.widget_key === "bulk-stop") {
|
|
4345
|
+
enterBulkConfirm("stop");
|
|
3355
4346
|
return;
|
|
3356
4347
|
}
|
|
3357
|
-
if (e.event_type === "activate" && e.widget_key === "
|
|
3358
|
-
|
|
3359
|
-
stopSelectedSession();
|
|
3360
|
-
if (openPanel) openPanel.update(buildOpenSpec());
|
|
4348
|
+
if (e.event_type === "activate" && e.widget_key === "bulk-archive") {
|
|
4349
|
+
enterBulkConfirm("archive");
|
|
3361
4350
|
return;
|
|
3362
4351
|
}
|
|
3363
|
-
if (e.event_type === "activate" && e.widget_key === "
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
// is still the source of truth and will drop it on
|
|
3370
|
-
// `closeWindow`, which is intentional: a slightly-laggy real
|
|
3371
|
-
// state beats a synchronously faked one that can desync from
|
|
3372
|
-
// git reality (e.g. when `git worktree move` fails).
|
|
3373
|
-
if (typeof id === "number" && id > 0) {
|
|
3374
|
-
openDialog.inFlight = { action: "archive", sessionId: id };
|
|
3375
|
-
}
|
|
3376
|
-
void archiveSelectedSession(id);
|
|
4352
|
+
if (e.event_type === "activate" && e.widget_key === "bulk-delete") {
|
|
4353
|
+
enterBulkConfirm("delete");
|
|
4354
|
+
return;
|
|
4355
|
+
}
|
|
4356
|
+
if (e.event_type === "activate" && e.widget_key === "bulk-clear") {
|
|
4357
|
+
openDialog.selectedIds.clear();
|
|
3377
4358
|
refreshOpenDialog();
|
|
4359
|
+
openPanel.setFocusKey("visit");
|
|
3378
4360
|
return;
|
|
3379
4361
|
}
|
|
3380
|
-
if (e.event_type === "activate" && e.widget_key === "confirm-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
4362
|
+
if (e.event_type === "activate" && e.widget_key === "confirm-cancel") {
|
|
4363
|
+
openDialog.pendingConfirm = null;
|
|
4364
|
+
openPanel.update(buildOpenSpec());
|
|
4365
|
+
return;
|
|
4366
|
+
}
|
|
4367
|
+
// Confirmed Stop / Archive / Delete — single row or bulk batch.
|
|
4368
|
+
// The ids were captured into `pendingConfirm` by enterConfirm /
|
|
4369
|
+
// enterBulkConfirm; `runConfirmedAction` re-checks eligibility,
|
|
4370
|
+
// drives the in-flight markers, and triggers sync.
|
|
4371
|
+
if (
|
|
4372
|
+
e.event_type === "activate" &&
|
|
4373
|
+
(e.widget_key === "confirm-stop" ||
|
|
4374
|
+
e.widget_key === "confirm-archive" ||
|
|
4375
|
+
e.widget_key === "confirm-delete")
|
|
4376
|
+
) {
|
|
4377
|
+
const confirm = openDialog.pendingConfirm;
|
|
4378
|
+
openDialog.pendingConfirm = null;
|
|
4379
|
+
if (confirm) {
|
|
4380
|
+
void runConfirmedAction(confirm.action, confirm.ids);
|
|
3387
4381
|
}
|
|
3388
|
-
|
|
3389
|
-
refreshOpenDialog();
|
|
4382
|
+
if (openPanel) openPanel.update(buildOpenSpec());
|
|
3390
4383
|
return;
|
|
3391
4384
|
}
|
|
3392
4385
|
if (e.event_type === "cancel") {
|
|
@@ -3424,10 +4417,6 @@ function killSelected(): void {
|
|
|
3424
4417
|
editor.setStatus("Orchestrator: select a session row first");
|
|
3425
4418
|
return;
|
|
3426
4419
|
}
|
|
3427
|
-
if (id === 1) {
|
|
3428
|
-
editor.setStatus("Orchestrator: cannot kill the base session");
|
|
3429
|
-
return;
|
|
3430
|
-
}
|
|
3431
4420
|
if (id === editor.activeWindow()) {
|
|
3432
4421
|
editor.setStatus(
|
|
3433
4422
|
"Orchestrator: dive elsewhere first, then kill this session",
|