@fresh-editor/fresh-editor 0.3.8 → 0.3.9
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 +49 -0
- package/README.md +0 -1
- package/package.json +1 -1
- package/plugins/dashboard.ts +3 -3
- package/plugins/examples/bookmarks.ts +3 -2
- package/plugins/lib/finder.ts +57 -10
- package/plugins/lib/fresh.d.ts +71 -31
- package/plugins/lib/widgets.ts +8 -0
- package/plugins/live_grep.i18n.json +349 -27
- package/plugins/live_grep.ts +596 -141
- package/plugins/orchestrator.ts +1227 -393
- 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,34 @@ 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
|
+
// Progress marker for an in-flight *bulk* action. While set, the
|
|
273
|
+
// selection bar shows "Archiving 2/3…" and its buttons are
|
|
274
|
+
// hidden so a second Enter can't re-fire mid-batch. Cleared when
|
|
275
|
+
// the batch finishes.
|
|
276
|
+
bulkInFlight:
|
|
277
|
+
| { action: "stop" | "archive" | "delete"; total: number; done: number }
|
|
215
278
|
| null;
|
|
216
279
|
// Rows the embed reserves and rows the sessions list shows.
|
|
217
280
|
// Captured once at dialog-open from the editor's viewport so
|
|
@@ -265,6 +328,11 @@ let openPanel: FloatingWidgetPanel | null = null;
|
|
|
265
328
|
// showing every session; flipping it with the Project control / Alt+P
|
|
266
329
|
// updates this and the next open honours it.
|
|
267
330
|
let lastOpenScope: "current" | "all" = "all";
|
|
331
|
+
// Remembered across opens, like `lastOpenScope`: whether the
|
|
332
|
+
// discovered on-disk worktree rows are shown. Defaults to false
|
|
333
|
+
// (worktrees hidden) — surfacing them is opt-in via "Show all
|
|
334
|
+
// worktrees" (Alt+T).
|
|
335
|
+
let lastShowWorktrees = false;
|
|
268
336
|
const OPEN_MODE = "orchestrator-open";
|
|
269
337
|
|
|
270
338
|
// =============================================================================
|
|
@@ -298,11 +366,122 @@ function reconcileSessions(): void {
|
|
|
298
366
|
if (s.shared_worktree != null) existing.sharedWorktree = s.shared_worktree;
|
|
299
367
|
}
|
|
300
368
|
}
|
|
369
|
+
// Live windows live in the positive id space; their absence from
|
|
370
|
+
// `listWindows()` means they were closed, so drop them. Discovered
|
|
371
|
+
// worktrees (negative ids) are NOT backed by a window and must
|
|
372
|
+
// survive this sweep — they're pruned separately, against the
|
|
373
|
+
// on-disk worktree set, by `refreshDiscoveredWorktrees`.
|
|
301
374
|
for (const id of orchestratorSessions.keys()) {
|
|
302
|
-
if (!seen.has(id)) orchestratorSessions.delete(id);
|
|
375
|
+
if (id > 0 && !seen.has(id)) orchestratorSessions.delete(id);
|
|
376
|
+
}
|
|
377
|
+
// A worktree that's now open as a live window must not also linger
|
|
378
|
+
// as a discovered row. Drop any discovered entry whose root a live
|
|
379
|
+
// session already occupies.
|
|
380
|
+
const liveRoots = new Set<string>();
|
|
381
|
+
for (const s of orchestratorSessions.values()) {
|
|
382
|
+
if (!s.discovered) liveRoots.add(s.root);
|
|
383
|
+
}
|
|
384
|
+
for (const [id, s] of orchestratorSessions) {
|
|
385
|
+
if (s.discovered && liveRoots.has(s.root)) orchestratorSessions.delete(id);
|
|
303
386
|
}
|
|
304
387
|
}
|
|
305
388
|
|
|
389
|
+
// =============================================================================
|
|
390
|
+
// Discovered-worktree scan
|
|
391
|
+
//
|
|
392
|
+
// Surfaces worktrees that exist on disk but have no live editor
|
|
393
|
+
// window, so the user doesn't have to add them by hand. Because
|
|
394
|
+
// open sessions can span several repos, `git worktree list` must
|
|
395
|
+
// run once *per project*: the scan set is the distinct canonical
|
|
396
|
+
// repo roots of every live session, plus the editor's cwd repo.
|
|
397
|
+
// Each linked worktree not already open (and not an
|
|
398
|
+
// orchestrator-internal tree) becomes a discovered row that dives
|
|
399
|
+
// by attaching a fresh session to it.
|
|
400
|
+
// =============================================================================
|
|
401
|
+
|
|
402
|
+
let discoveryInFlight = false;
|
|
403
|
+
|
|
404
|
+
function isInternalWorktreePath(path: string): boolean {
|
|
405
|
+
// The sync-workspace and the `.archived/` graveyard are
|
|
406
|
+
// orchestrator bookkeeping, not user sessions.
|
|
407
|
+
return path.includes(".sync-workspace") || path.includes("/.archived/");
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function refreshDiscoveredWorktrees(): Promise<void> {
|
|
411
|
+
if (discoveryInFlight) return;
|
|
412
|
+
discoveryInFlight = true;
|
|
413
|
+
try {
|
|
414
|
+
reconcileSessions();
|
|
415
|
+
|
|
416
|
+
// (1) Candidate dirs: every live session's root + the editor
|
|
417
|
+
// cwd. Resolve each to its canonical main repo root and
|
|
418
|
+
// dedupe so a repo with N open worktrees is scanned once.
|
|
419
|
+
const candidates = new Set<string>([editor.getCwd()]);
|
|
420
|
+
for (const s of orchestratorSessions.values()) {
|
|
421
|
+
if (!s.discovered) candidates.add(s.root);
|
|
422
|
+
}
|
|
423
|
+
const mainRoots = new Set<string>();
|
|
424
|
+
for (const dir of candidates) {
|
|
425
|
+
const canonical = await resolveCanonicalRepoRoot(dir);
|
|
426
|
+
if (canonical) mainRoots.add(canonical);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// (2) Roots already occupied by a live session — discovered rows
|
|
430
|
+
// for these would be duplicates.
|
|
431
|
+
const liveRoots = new Set<string>();
|
|
432
|
+
for (const s of orchestratorSessions.values()) {
|
|
433
|
+
if (!s.discovered) liveRoots.add(s.root);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// (3) Scan each repo and collect the linked worktrees worth
|
|
437
|
+
// surfacing.
|
|
438
|
+
const foundPaths = new Set<string>();
|
|
439
|
+
for (const repoRoot of mainRoots) {
|
|
440
|
+
const listed = await listLinkedWorktrees(repoRoot);
|
|
441
|
+
if (!listed) continue;
|
|
442
|
+
for (const wt of listed.worktrees) {
|
|
443
|
+
if (liveRoots.has(wt.path)) continue;
|
|
444
|
+
if (isInternalWorktreePath(wt.path)) continue;
|
|
445
|
+
foundPaths.add(wt.path);
|
|
446
|
+
const id = discoveredIdFor(wt.path);
|
|
447
|
+
const label = wt.branch || editor.pathBasename(wt.path);
|
|
448
|
+
const existing = orchestratorSessions.get(id);
|
|
449
|
+
if (existing) {
|
|
450
|
+
existing.label = label;
|
|
451
|
+
existing.root = wt.path;
|
|
452
|
+
existing.projectPath = listed.mainRoot;
|
|
453
|
+
existing.branch = wt.branch;
|
|
454
|
+
} else {
|
|
455
|
+
orchestratorSessions.set(id, {
|
|
456
|
+
id,
|
|
457
|
+
label,
|
|
458
|
+
root: wt.path,
|
|
459
|
+
projectPath: listed.mainRoot,
|
|
460
|
+
sharedWorktree: false,
|
|
461
|
+
terminalId: null,
|
|
462
|
+
state: "ready",
|
|
463
|
+
createdAt: Date.now(),
|
|
464
|
+
discovered: true,
|
|
465
|
+
branch: wt.branch,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// (4) Prune discovered rows that vanished from disk (or got
|
|
472
|
+
// opened, picked up by the liveRoots check above).
|
|
473
|
+
for (const [id, s] of orchestratorSessions) {
|
|
474
|
+
if (s.discovered && !foundPaths.has(s.root)) {
|
|
475
|
+
orchestratorSessions.delete(id);
|
|
476
|
+
discoveredIdByPath.delete(s.root);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
} finally {
|
|
480
|
+
discoveryInFlight = false;
|
|
481
|
+
}
|
|
482
|
+
if (openPanel) refreshOpenDialog();
|
|
483
|
+
}
|
|
484
|
+
|
|
306
485
|
// =============================================================================
|
|
307
486
|
// Session display helpers
|
|
308
487
|
// =============================================================================
|
|
@@ -381,12 +560,23 @@ function projectLabel(key: string): string {
|
|
|
381
560
|
function filterSessions(needle: string): number[] {
|
|
382
561
|
reconcileSessions();
|
|
383
562
|
const scope = openDialog?.scope ?? "current";
|
|
563
|
+
const showWorktrees = openDialog?.showWorktrees ?? false;
|
|
384
564
|
const cur = currentProjectKey();
|
|
385
|
-
|
|
565
|
+
let allIds = Array.from(orchestratorSessions.keys());
|
|
566
|
+
// "Show all worktrees" is opt-in: by default the discovered on-disk
|
|
567
|
+
// worktree rows are filtered out.
|
|
568
|
+
if (!showWorktrees) {
|
|
569
|
+
allIds = allIds.filter((id) => !orchestratorSessions.get(id)!.discovered);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const isDisc = (id: number): number =>
|
|
573
|
+
orchestratorSessions.get(id)!.discovered ? 1 : 0;
|
|
386
574
|
|
|
387
|
-
// Sort by (current-project-first,
|
|
388
|
-
// groups the current project's sessions
|
|
389
|
-
// projects'
|
|
575
|
+
// Sort by (current-project-first, project, live-before-discovered,
|
|
576
|
+
// then id) so an "all" view groups the current project's sessions
|
|
577
|
+
// at the top and other projects' below, and within each project the
|
|
578
|
+
// pre-existing live sessions come first with the discovered on-disk
|
|
579
|
+
// worktrees listed after them.
|
|
390
580
|
const byProjectThenId = (a: number, b: number): number => {
|
|
391
581
|
const sa = orchestratorSessions.get(a)!;
|
|
392
582
|
const sb = orchestratorSessions.get(b)!;
|
|
@@ -396,6 +586,9 @@ function filterSessions(needle: string): number[] {
|
|
|
396
586
|
const ka = projectKeyOf(sa);
|
|
397
587
|
const kb = projectKeyOf(sb);
|
|
398
588
|
if (ka !== kb) return ka < kb ? -1 : 1;
|
|
589
|
+
const da = isDisc(a);
|
|
590
|
+
const db = isDisc(b);
|
|
591
|
+
if (da !== db) return da - db;
|
|
399
592
|
return a - b;
|
|
400
593
|
};
|
|
401
594
|
|
|
@@ -422,25 +615,33 @@ function filterSessions(needle: string): number[] {
|
|
|
422
615
|
matches.push({ id, score: 2, len: label.length });
|
|
423
616
|
}
|
|
424
617
|
}
|
|
425
|
-
|
|
618
|
+
// Live sessions before discovered worktrees at equal relevance, so
|
|
619
|
+
// the on-disk rows still trail the real sessions in search results.
|
|
620
|
+
matches.sort(
|
|
621
|
+
(a, b) =>
|
|
622
|
+
a.score - b.score || isDisc(a.id) - isDisc(b.id) || a.len - b.len ||
|
|
623
|
+
a.id - b.id,
|
|
624
|
+
);
|
|
426
625
|
return matches.map((m) => m.id);
|
|
427
626
|
}
|
|
428
627
|
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
|
|
434
|
-
|
|
628
|
+
// Width of the NAME column before the trailing PROJECT column kicks
|
|
629
|
+
// in (filled only for cross-project rows). Kept in sync with
|
|
630
|
+
// `sessionsColumnHeader`. There is no id column — the numeric window
|
|
631
|
+
// id is an internal handle the user never needs in the list; rows are
|
|
632
|
+
// identified by name, the active one rendered bold and on-disk
|
|
633
|
+
// worktrees flagged with a `· on-disk` tag.
|
|
634
|
+
const LIST_NAME_W = 24;
|
|
435
635
|
|
|
436
|
-
// Header row above the session list: `
|
|
636
|
+
// Header row above the session list: `NAME … PROJECT`.
|
|
437
637
|
function sessionsColumnHeader(): WidgetSpec {
|
|
438
638
|
return {
|
|
439
639
|
kind: "raw",
|
|
440
640
|
entries: [
|
|
441
641
|
styledRow([
|
|
442
642
|
{
|
|
443
|
-
|
|
643
|
+
// 4-space lead aligns under the per-row `[ ] ` checkbox.
|
|
644
|
+
text: " " + "NAME".padEnd(LIST_NAME_W) + "PROJECT",
|
|
444
645
|
style: { fg: "ui.menu_disabled_fg" },
|
|
445
646
|
},
|
|
446
647
|
]),
|
|
@@ -448,28 +649,42 @@ function sessionsColumnHeader(): WidgetSpec {
|
|
|
448
649
|
};
|
|
449
650
|
}
|
|
450
651
|
|
|
451
|
-
// Build one rendered list-item row for `id
|
|
452
|
-
// `[
|
|
453
|
-
// The active session's
|
|
454
|
-
//
|
|
455
|
-
// only for sessions that don't
|
|
652
|
+
// Build one rendered list-item row for `id`:
|
|
653
|
+
// `[ ] ` <name + BASE/⇄ badges + on-disk tag> <project basename>
|
|
654
|
+
// The active session's name renders bold; discovered (on-disk,
|
|
655
|
+
// unopened) worktrees render dim with a `· on-disk` tag instead of a
|
|
656
|
+
// glyph. The project column is filled only for sessions that don't
|
|
657
|
+
// belong to the current project.
|
|
456
658
|
function renderListItem(id: number, activeId: number): TextPropertyEntry {
|
|
457
659
|
const s = orchestratorSessions.get(id);
|
|
458
660
|
if (!s) {
|
|
459
|
-
return styledRow([{ text:
|
|
661
|
+
return styledRow([{ text: "(unknown)" }]);
|
|
460
662
|
}
|
|
461
663
|
const isActive = id === activeId;
|
|
462
664
|
const isBase = id === 1;
|
|
665
|
+
const isDiscovered = !!s.discovered;
|
|
666
|
+
const isChecked = openDialog?.selectedIds.has(id) ?? false;
|
|
667
|
+
|
|
668
|
+
// Leading multi-select checkbox. `[x]` when this row is in the
|
|
669
|
+
// bulk selection, `[ ]` otherwise — toggled with Space (the
|
|
670
|
+
// rebindable `orchestrator_toggle_select`) or a click.
|
|
671
|
+
const checkbox = {
|
|
672
|
+
text: isChecked ? "[x] " : "[ ] ",
|
|
673
|
+
style: isChecked
|
|
674
|
+
? { fg: "ui.help_key_fg", bold: true }
|
|
675
|
+
: { fg: "ui.menu_disabled_fg" },
|
|
676
|
+
};
|
|
463
677
|
|
|
464
|
-
const idText = `[${id}]`.padEnd(LIST_ID_W);
|
|
465
678
|
const entries: { text: string; style?: Record<string, unknown> }[] = [
|
|
679
|
+
checkbox,
|
|
466
680
|
{
|
|
467
|
-
text:
|
|
681
|
+
text: s.label,
|
|
468
682
|
style: isActive
|
|
469
|
-
? { fg: "ui.
|
|
470
|
-
:
|
|
683
|
+
? { fg: "ui.help_key_fg", bold: true }
|
|
684
|
+
: isDiscovered
|
|
685
|
+
? { fg: "ui.menu_disabled_fg" }
|
|
686
|
+
: undefined,
|
|
471
687
|
},
|
|
472
|
-
{ text: s.label, style: isActive ? { bold: true } : undefined },
|
|
473
688
|
];
|
|
474
689
|
// Visible width of the NAME column so far (label + badges), used
|
|
475
690
|
// to pad out to LIST_NAME_W before the PROJECT column.
|
|
@@ -482,6 +697,13 @@ function renderListItem(id: number, activeId: number): TextPropertyEntry {
|
|
|
482
697
|
entries.push({ text: " ⇄", style: { fg: "ui.menu_disabled_fg" } });
|
|
483
698
|
nameWidth += 2;
|
|
484
699
|
}
|
|
700
|
+
if (isDiscovered) {
|
|
701
|
+
entries.push({
|
|
702
|
+
text: " · on-disk",
|
|
703
|
+
style: { fg: "ui.menu_disabled_fg", italic: true },
|
|
704
|
+
});
|
|
705
|
+
nameWidth += 10;
|
|
706
|
+
}
|
|
485
707
|
// PROJECT column: basename for cross-project rows only; current-
|
|
486
708
|
// project rows leave it blank (the whole list is one project when
|
|
487
709
|
// scoped, so this column is empty then).
|
|
@@ -526,7 +748,7 @@ function buildPreviewEntries(
|
|
|
526
748
|
{
|
|
527
749
|
text: stateText,
|
|
528
750
|
style: isActive
|
|
529
|
-
? { fg: "ui.
|
|
751
|
+
? { fg: "ui.help_key_fg", bold: true }
|
|
530
752
|
: { fg: "ui.menu_disabled_fg" },
|
|
531
753
|
},
|
|
532
754
|
{ text: " " },
|
|
@@ -588,6 +810,72 @@ function countSiblingsAtRoot(root: string): number {
|
|
|
588
810
|
return n;
|
|
589
811
|
}
|
|
590
812
|
|
|
813
|
+
// =============================================================================
|
|
814
|
+
// Multi-select / bulk actions
|
|
815
|
+
//
|
|
816
|
+
// The user checkbox-selects rows (Space — the rebindable
|
|
817
|
+
// `orchestrator_toggle_select` — or a click). Once two or more rows
|
|
818
|
+
// are checked the preview pane swaps to the bulk selection bar
|
|
819
|
+
// (`buildBulkPane`) offering Stop / Archive / Delete over the whole
|
|
820
|
+
// set, with a single confirmation for the batch. Rows ineligible for
|
|
821
|
+
// a given action (the base session; live sessions sharing a worktree)
|
|
822
|
+
// are skipped, and each button's count reflects only the eligible
|
|
823
|
+
// members.
|
|
824
|
+
// =============================================================================
|
|
825
|
+
|
|
826
|
+
type BulkAction = "stop" | "archive" | "delete";
|
|
827
|
+
|
|
828
|
+
// Checked ids that still resolve to a known session, in the dialog's
|
|
829
|
+
// current display order (so the bulk bar lists them the way the list
|
|
830
|
+
// shows them). Selection persists across filter/scope changes, so an
|
|
831
|
+
// id can be checked while filtered out of view — those still count.
|
|
832
|
+
function selectedSessions(): number[] {
|
|
833
|
+
if (!openDialog) return [];
|
|
834
|
+
const order = openDialog.filteredIds;
|
|
835
|
+
const seen = new Set<number>();
|
|
836
|
+
const out: number[] = [];
|
|
837
|
+
for (const id of order) {
|
|
838
|
+
if (openDialog.selectedIds.has(id) && orchestratorSessions.has(id)) {
|
|
839
|
+
out.push(id);
|
|
840
|
+
seen.add(id);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
// Checked-but-filtered-out rows, appended in id order so the count
|
|
844
|
+
// stays honest even when a search hides part of the selection.
|
|
845
|
+
for (const id of openDialog.selectedIds) {
|
|
846
|
+
if (!seen.has(id) && orchestratorSessions.has(id)) out.push(id);
|
|
847
|
+
}
|
|
848
|
+
return out;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// Is `id` a legal target for `action`? Base session is never
|
|
852
|
+
// touched. Stop only applies to live windows. Archive/Delete apply
|
|
853
|
+
// to discovered worktrees (removable on disk) and to live sessions
|
|
854
|
+
// that own their worktree outright (not shared with siblings or the
|
|
855
|
+
// project root).
|
|
856
|
+
function bulkEligible(action: BulkAction, id: number): boolean {
|
|
857
|
+
const s = orchestratorSessions.get(id);
|
|
858
|
+
if (!s) return false;
|
|
859
|
+
if (id === 1) return false;
|
|
860
|
+
if (action === "stop") return !s.discovered && id > 0;
|
|
861
|
+
if (s.discovered) return true;
|
|
862
|
+
const sharesRoot = countSiblingsAtRoot(s.root) > 1 || s.sharedWorktree;
|
|
863
|
+
return !sharesRoot;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function eligibleSelected(action: BulkAction): number[] {
|
|
867
|
+
return selectedSessions().filter((id) => bulkEligible(action, id));
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Drop checked ids whose session has vanished (closed window,
|
|
871
|
+
// pruned worktree) so the selection can't grow stale references.
|
|
872
|
+
function pruneSelection(): void {
|
|
873
|
+
if (!openDialog) return;
|
|
874
|
+
for (const id of [...openDialog.selectedIds]) {
|
|
875
|
+
if (!orchestratorSessions.has(id)) openDialog.selectedIds.delete(id);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
591
879
|
// Blank-row separator used inside the Sessions column between
|
|
592
880
|
// the filter, the new-session button, and the list.
|
|
593
881
|
function sessionsSeparator(): WidgetSpec {
|
|
@@ -610,10 +898,10 @@ function maxListRowsForScreen(): number {
|
|
|
610
898
|
const panelH = Math.floor(h * 0.9);
|
|
611
899
|
// Chrome that isn't list rows: panel borders (2) + title (1) +
|
|
612
900
|
// 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 -
|
|
901
|
+
// column chrome above the list (New + Project + Worktree-filter +
|
|
902
|
+
// Filter + separator + header = 6) = 13. Floor at MIN_LIST_ROWS so
|
|
903
|
+
// a tiny terminal still shows something.
|
|
904
|
+
return Math.max(MIN_LIST_ROWS, panelH - 13);
|
|
617
905
|
}
|
|
618
906
|
|
|
619
907
|
// Compose the right-hand preview pane. Normally it shows info
|
|
@@ -658,130 +946,28 @@ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
|
|
|
658
946
|
),
|
|
659
947
|
});
|
|
660
948
|
}
|
|
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
|
-
}
|
|
949
|
+
// Confirmation panel — single-row Stop/Archive/Delete or a bulk
|
|
950
|
+
// batch. Independent of the cursor row: the confirmed ids live in
|
|
951
|
+
// `pendingConfirm`, so it renders whenever a confirm is pending.
|
|
952
|
+
if (openDialog?.pendingConfirm) {
|
|
953
|
+
return buildConfirmPane(openDialog.pendingConfirm);
|
|
954
|
+
}
|
|
955
|
+
// Bulk selection bar: two or more rows checked (or a bulk action
|
|
956
|
+
// in flight) → operate on the whole batch rather than the cursor
|
|
957
|
+
// row.
|
|
958
|
+
if (selectedSessions().length >= 2 || openDialog?.bulkInFlight) {
|
|
959
|
+
return buildBulkPane();
|
|
775
960
|
}
|
|
776
961
|
// Match the sessions column's content height so the two panes'
|
|
777
962
|
// bottom borders land on the same row. Sessions column inside its
|
|
778
|
-
// borders = New (1) + Project (1) +
|
|
779
|
-
//
|
|
780
|
-
// Preview inside its borders = button
|
|
781
|
-
// embedRows, so embedRows must equal
|
|
782
|
-
// details ARE shown, two info rows + a
|
|
783
|
-
// lines — `_DETAILS_CHROME_ROWS` accounts
|
|
784
|
-
|
|
963
|
+
// borders = New (1) + Project (1) + Worktree-filter (1) +
|
|
964
|
+
// Filter (1) + separator (1) + header (1) + list (listVisibleRows)
|
|
965
|
+
// = listVisibleRows + 6. Preview inside its borders = button
|
|
966
|
+
// row (1) + spacer (1) + embedRows, so embedRows must equal
|
|
967
|
+
// listVisibleRows + 4. When details ARE shown, two info rows + a
|
|
968
|
+
// spacer eat three more lines — `_DETAILS_CHROME_ROWS` accounts
|
|
969
|
+
// for that.
|
|
970
|
+
const totalEmbedBase = (openDialog?.listVisibleRows ?? MIN_LIST_ROWS) + 4;
|
|
785
971
|
const detailsOn = openDialog?.showDetails ?? false;
|
|
786
972
|
const _DETAILS_CHROME_ROWS = 3; // 2 info rows + 1 spacer
|
|
787
973
|
const embedRows = Math.max(
|
|
@@ -814,6 +1000,48 @@ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
|
|
|
814
1000
|
// turns details on, pressing `[ Preview ]` turns them off
|
|
815
1001
|
// (back to compact).
|
|
816
1002
|
const detailsToggleLabel = detailsOn ? "Preview" : "Details";
|
|
1003
|
+
// Discovered worktree: no live window to embed, so there's
|
|
1004
|
+
// nothing to Stop / Archive / Delete yet. Offer only "Open"
|
|
1005
|
+
// (Visit attaches a fresh session to the worktree) and describe
|
|
1006
|
+
// what diving will do. The empty `windowId: 0` embed keeps the
|
|
1007
|
+
// pane the same height as live-session previews so the dialog
|
|
1008
|
+
// doesn't jump when the selection moves between row kinds.
|
|
1009
|
+
if (s.discovered) {
|
|
1010
|
+
const openButtonRow = row(
|
|
1011
|
+
button("Open", { intent: "primary", key: "visit" }),
|
|
1012
|
+
flexSpacer(),
|
|
1013
|
+
button("Stop", { key: "stop", disabled: true }),
|
|
1014
|
+
spacer(2),
|
|
1015
|
+
button("Archive", { key: "archive", disabled: true }),
|
|
1016
|
+
spacer(2),
|
|
1017
|
+
button("Delete", { intent: "danger", key: "delete", disabled: true }),
|
|
1018
|
+
);
|
|
1019
|
+
const info: TextPropertyEntry[] = [
|
|
1020
|
+
styledRow([
|
|
1021
|
+
{ text: "On-disk worktree (not open)", style: { fg: "ui.menu_disabled_fg", bold: true } },
|
|
1022
|
+
]),
|
|
1023
|
+
styledRow([{ text: "" }]),
|
|
1024
|
+
styledRow([{ text: "branch ", style: { fg: "ui.menu_disabled_fg" } }, { text: s.branch || "(detached)" }]),
|
|
1025
|
+
styledRow([{ text: "path ", style: { fg: "ui.menu_disabled_fg" } }, { text: s.root }]),
|
|
1026
|
+
styledRow([{ text: "" }]),
|
|
1027
|
+
styledRow([
|
|
1028
|
+
{
|
|
1029
|
+
text: "Press Enter to open this worktree as a session.",
|
|
1030
|
+
style: { fg: "ui.help_key_fg", italic: true },
|
|
1031
|
+
},
|
|
1032
|
+
]),
|
|
1033
|
+
];
|
|
1034
|
+
return labeledSection({
|
|
1035
|
+
label: `${s.label} — on-disk worktree`,
|
|
1036
|
+
child: col(
|
|
1037
|
+
openButtonRow,
|
|
1038
|
+
spacer(0),
|
|
1039
|
+
{ kind: "raw", entries: info },
|
|
1040
|
+
spacer(0),
|
|
1041
|
+
windowEmbed({ windowId: 0, rows: Math.max(3, embedRows - 6), key: "live-preview" }),
|
|
1042
|
+
),
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
817
1045
|
// Per-action availability. The row always renders all four
|
|
818
1046
|
// buttons (no layout shift between selections), but each is
|
|
819
1047
|
// marked disabled when its action would be refused against the
|
|
@@ -869,14 +1097,225 @@ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
|
|
|
869
1097
|
// its worktree would close the editor / break the user's current
|
|
870
1098
|
// tree, so Stop / Archive / Delete refuse against it.
|
|
871
1099
|
const sectionLabel = isBase
|
|
872
|
-
?
|
|
873
|
-
:
|
|
1100
|
+
? `${s.label} — BASE (editor session)`
|
|
1101
|
+
: s.label;
|
|
874
1102
|
return labeledSection({
|
|
875
1103
|
label: sectionLabel,
|
|
876
1104
|
child: body,
|
|
877
1105
|
});
|
|
878
1106
|
}
|
|
879
1107
|
|
|
1108
|
+
// The per-action bullet lines shown in the confirmation panel.
|
|
1109
|
+
// `delete` adds a separate red "uncommitted changes" line in the
|
|
1110
|
+
// caller because it needs distinct styling.
|
|
1111
|
+
function confirmActionLines(action: BulkAction): string[] {
|
|
1112
|
+
switch (action) {
|
|
1113
|
+
case "stop":
|
|
1114
|
+
return [
|
|
1115
|
+
" • send SIGTERM to all session processes",
|
|
1116
|
+
" • SIGKILL after a short grace period",
|
|
1117
|
+
"",
|
|
1118
|
+
"The worktree and session record remain.",
|
|
1119
|
+
];
|
|
1120
|
+
case "archive":
|
|
1121
|
+
return [
|
|
1122
|
+
" • SIGKILL all session processes",
|
|
1123
|
+
" • close the editor session",
|
|
1124
|
+
" • move the worktree to .archived/",
|
|
1125
|
+
"",
|
|
1126
|
+
"Reversible via Unarchive.",
|
|
1127
|
+
];
|
|
1128
|
+
case "delete":
|
|
1129
|
+
return [
|
|
1130
|
+
" • stop all session processes",
|
|
1131
|
+
" • run `git worktree remove`",
|
|
1132
|
+
" • drop the session record",
|
|
1133
|
+
];
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// Confirmation panel for a Stop/Archive/Delete over one or many
|
|
1138
|
+
// sessions. A single id renders the familiar per-session prompt; two
|
|
1139
|
+
// or more render a batch prompt that lists the targets. The Confirm
|
|
1140
|
+
// button reuses the same `confirm-<action>` key the single path
|
|
1141
|
+
// always used, so the existing widget_event handlers fire for both —
|
|
1142
|
+
// they read `pendingConfirm.ids`.
|
|
1143
|
+
function buildConfirmPane(
|
|
1144
|
+
confirm: { action: BulkAction; ids: number[] },
|
|
1145
|
+
): WidgetSpec {
|
|
1146
|
+
const { action, ids } = confirm;
|
|
1147
|
+
const cap = action[0].toUpperCase() + action.slice(1);
|
|
1148
|
+
const existing = ids.filter((id) => orchestratorSessions.has(id));
|
|
1149
|
+
const bulk = existing.length > 1;
|
|
1150
|
+
const diskNote = (id: number): string =>
|
|
1151
|
+
orchestratorSessions.get(id)?.discovered ? " · on-disk" : "";
|
|
1152
|
+
const entries: TextPropertyEntry[] = [];
|
|
1153
|
+
if (bulk) {
|
|
1154
|
+
entries.push(
|
|
1155
|
+
styledRow([
|
|
1156
|
+
{ text: `${cap} these ${existing.length} sessions?`, style: { bold: true } },
|
|
1157
|
+
]),
|
|
1158
|
+
styledRow([{ text: "" }]),
|
|
1159
|
+
);
|
|
1160
|
+
for (const id of existing.slice(0, 8)) {
|
|
1161
|
+
const ss = orchestratorSessions.get(id)!;
|
|
1162
|
+
entries.push(
|
|
1163
|
+
styledRow([
|
|
1164
|
+
{ text: ` ${ss.label}` },
|
|
1165
|
+
{ text: diskNote(id), style: { fg: "ui.menu_disabled_fg", italic: true } },
|
|
1166
|
+
]),
|
|
1167
|
+
);
|
|
1168
|
+
}
|
|
1169
|
+
if (existing.length > 8) {
|
|
1170
|
+
entries.push(
|
|
1171
|
+
styledRow([
|
|
1172
|
+
{
|
|
1173
|
+
text: ` … and ${existing.length - 8} more`,
|
|
1174
|
+
style: { fg: "ui.menu_disabled_fg", italic: true },
|
|
1175
|
+
},
|
|
1176
|
+
]),
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
} else {
|
|
1180
|
+
const id = existing[0];
|
|
1181
|
+
const ss = id !== undefined ? orchestratorSessions.get(id) : undefined;
|
|
1182
|
+
entries.push(
|
|
1183
|
+
styledRow([
|
|
1184
|
+
{ text: `${cap} session ${ss?.label ?? ""}?`, style: { bold: true } },
|
|
1185
|
+
]),
|
|
1186
|
+
);
|
|
1187
|
+
}
|
|
1188
|
+
entries.push(
|
|
1189
|
+
styledRow([{ text: "" }]),
|
|
1190
|
+
styledRow([{ text: bulk ? "For each session this will:" : "This will:" }]),
|
|
1191
|
+
);
|
|
1192
|
+
for (const line of confirmActionLines(action)) {
|
|
1193
|
+
entries.push(styledRow([{ text: line }]));
|
|
1194
|
+
}
|
|
1195
|
+
if (action === "delete") {
|
|
1196
|
+
entries.push(
|
|
1197
|
+
styledRow([{ text: "" }]),
|
|
1198
|
+
styledRow([
|
|
1199
|
+
{
|
|
1200
|
+
text: "Uncommitted changes will be lost.",
|
|
1201
|
+
style: { fg: "ui.status_error_indicator_fg", bold: true },
|
|
1202
|
+
},
|
|
1203
|
+
]),
|
|
1204
|
+
);
|
|
1205
|
+
}
|
|
1206
|
+
return labeledSection({
|
|
1207
|
+
label: bulk ? `Confirm ${cap} — ${existing.length} sessions` : `Confirm ${cap}`,
|
|
1208
|
+
child: col(
|
|
1209
|
+
{ kind: "raw", entries },
|
|
1210
|
+
spacer(0),
|
|
1211
|
+
row(
|
|
1212
|
+
flexSpacer(),
|
|
1213
|
+
button("Cancel", { key: "confirm-cancel" }),
|
|
1214
|
+
spacer(2),
|
|
1215
|
+
button(`Confirm ${cap}`, { intent: "danger", key: `confirm-${action}` }),
|
|
1216
|
+
),
|
|
1217
|
+
),
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// The dedicated bulk selection bar (Layout B). Shown in place of the
|
|
1222
|
+
// per-session preview when two or more rows are checked. The bulk
|
|
1223
|
+
// action buttons sit at the *top* of the pane; the list of affected
|
|
1224
|
+
// sessions renders below as a scrollable `list` widget (so a long
|
|
1225
|
+
// selection scrolls — keyboard, wheel, and the draggable scrollbar —
|
|
1226
|
+
// rather than overflowing the pane). Each action's count is the
|
|
1227
|
+
// number of *eligible* members; an action with no eligible members is
|
|
1228
|
+
// disabled.
|
|
1229
|
+
function buildBulkPane(): WidgetSpec {
|
|
1230
|
+
const sel = selectedSessions();
|
|
1231
|
+
const stopN = eligibleSelected("stop").length;
|
|
1232
|
+
const archiveN = eligibleSelected("archive").length;
|
|
1233
|
+
const deleteN = eligibleSelected("delete").length;
|
|
1234
|
+
|
|
1235
|
+
const inflight = openDialog?.bulkInFlight ?? null;
|
|
1236
|
+
const actionRow = inflight
|
|
1237
|
+
? row(
|
|
1238
|
+
{
|
|
1239
|
+
kind: "raw",
|
|
1240
|
+
entries: [
|
|
1241
|
+
styledRow([
|
|
1242
|
+
{
|
|
1243
|
+
text: `${inflight.action[0].toUpperCase()}${inflight.action.slice(1)}ing ${inflight.done}/${inflight.total}…`,
|
|
1244
|
+
style: { fg: "ui.menu_disabled_fg", italic: true },
|
|
1245
|
+
},
|
|
1246
|
+
]),
|
|
1247
|
+
],
|
|
1248
|
+
},
|
|
1249
|
+
flexSpacer(),
|
|
1250
|
+
)
|
|
1251
|
+
: row(
|
|
1252
|
+
button(`Stop (${stopN})`, { key: "bulk-stop", disabled: stopN === 0 }),
|
|
1253
|
+
spacer(2),
|
|
1254
|
+
button(`Archive (${archiveN})`, {
|
|
1255
|
+
key: "bulk-archive",
|
|
1256
|
+
disabled: archiveN === 0,
|
|
1257
|
+
}),
|
|
1258
|
+
spacer(2),
|
|
1259
|
+
button(`Delete (${deleteN})`, {
|
|
1260
|
+
intent: "danger",
|
|
1261
|
+
key: "bulk-delete",
|
|
1262
|
+
disabled: deleteN === 0,
|
|
1263
|
+
}),
|
|
1264
|
+
flexSpacer(),
|
|
1265
|
+
button("Clear", { key: "bulk-clear" }),
|
|
1266
|
+
);
|
|
1267
|
+
|
|
1268
|
+
// Affected-sessions list. Flag the rows a destructive action will
|
|
1269
|
+
// skip so the count discrepancy explains itself.
|
|
1270
|
+
const items: TextPropertyEntry[] = sel.map((id) => {
|
|
1271
|
+
const ss = orchestratorSessions.get(id)!;
|
|
1272
|
+
const rowParts: StyledSegment[] = [{ text: ` ${ss.label}` }];
|
|
1273
|
+
if (id === 1) {
|
|
1274
|
+
rowParts.push({
|
|
1275
|
+
text: " · base (protected)",
|
|
1276
|
+
style: { fg: "ui.menu_disabled_fg", italic: true },
|
|
1277
|
+
});
|
|
1278
|
+
} else if (!ss.discovered && (countSiblingsAtRoot(ss.root) > 1 || ss.sharedWorktree)) {
|
|
1279
|
+
rowParts.push({
|
|
1280
|
+
text: " · shared worktree",
|
|
1281
|
+
style: { fg: "ui.menu_disabled_fg", italic: true },
|
|
1282
|
+
});
|
|
1283
|
+
} else if (ss.discovered) {
|
|
1284
|
+
rowParts.push({
|
|
1285
|
+
text: " · on-disk worktree",
|
|
1286
|
+
style: { fg: "ui.menu_disabled_fg", italic: true },
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
return styledRow(rowParts);
|
|
1290
|
+
});
|
|
1291
|
+
const itemKeys = sel.map((id) => `bulksel-${id}`);
|
|
1292
|
+
// Match the preview pane's height: content = action row (1) +
|
|
1293
|
+
// spacer (1) + list, and the embed pane reserves `listVisibleRows
|
|
1294
|
+
// + 4` for its body — so the list takes that height and the two
|
|
1295
|
+
// panes' bottom borders line up.
|
|
1296
|
+
const listRows = Math.max(3, (openDialog?.listVisibleRows ?? MIN_LIST_ROWS) + 4);
|
|
1297
|
+
|
|
1298
|
+
return labeledSection({
|
|
1299
|
+
label: `Bulk actions — ${sel.length} selected`,
|
|
1300
|
+
child: col(
|
|
1301
|
+
actionRow,
|
|
1302
|
+
spacer(0),
|
|
1303
|
+
list({
|
|
1304
|
+
items,
|
|
1305
|
+
itemKeys,
|
|
1306
|
+
// Display-only: no highlighted row, and out of the Tab cycle
|
|
1307
|
+
// (focus belongs on the action buttons). Up/Down still scroll
|
|
1308
|
+
// it via the host's smart-key forwarding, and the scrollbar
|
|
1309
|
+
// drags it.
|
|
1310
|
+
selectedIndex: -1,
|
|
1311
|
+
visibleRows: listRows,
|
|
1312
|
+
focusable: false,
|
|
1313
|
+
key: "bulk-list",
|
|
1314
|
+
}),
|
|
1315
|
+
),
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
|
|
880
1319
|
function buildOpenSpec(): WidgetSpec {
|
|
881
1320
|
if (!openDialog) return col();
|
|
882
1321
|
const filtered = openDialog.filteredIds;
|
|
@@ -890,9 +1329,11 @@ function buildOpenSpec(): WidgetSpec {
|
|
|
890
1329
|
const selIdx = filtered.length === 0
|
|
891
1330
|
? -1
|
|
892
1331
|
: Math.max(0, Math.min(openDialog.selectedIndex, filtered.length - 1));
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
1332
|
+
// Gate on the *index* (selIdx < 0 means "filter matched nothing"),
|
|
1333
|
+
// not the sign of the id: discovered worktrees carry negative ids
|
|
1334
|
+
// and must still resolve to their row here.
|
|
1335
|
+
const selectedSession = selIdx >= 0
|
|
1336
|
+
? orchestratorSessions.get(filtered[selIdx])
|
|
896
1337
|
: undefined;
|
|
897
1338
|
|
|
898
1339
|
// The "New Session" button advertises Alt+N (or whatever the
|
|
@@ -974,6 +1415,26 @@ function buildOpenSpec(): WidgetSpec {
|
|
|
974
1415
|
scopeButton,
|
|
975
1416
|
flexSpacer(),
|
|
976
1417
|
);
|
|
1418
|
+
// Per-project filter checkbox, on its own row under the Project
|
|
1419
|
+
// control: opt-in toggle that surfaces the discovered on-disk
|
|
1420
|
+
// worktree rows. A `toggle` (single `[ ]`/`[v]` — no double
|
|
1421
|
+
// bracket) that's clickable and bound to Alt+T
|
|
1422
|
+
// (`orchestrator_toggle_worktrees`, rebindable). The label carries
|
|
1423
|
+
// the live keybinding hint, mirroring the Project control's
|
|
1424
|
+
// "(Alt+P)". Inert while a confirm prompt is up.
|
|
1425
|
+
const worktreeKey = editor.getKeybindingLabel(
|
|
1426
|
+
"orchestrator_toggle_worktrees",
|
|
1427
|
+
OPEN_MODE,
|
|
1428
|
+
);
|
|
1429
|
+
const worktreeLabel = worktreeKey
|
|
1430
|
+
? `Show all worktrees (${worktreeKey})`
|
|
1431
|
+
: "Show all worktrees";
|
|
1432
|
+
const worktreeFilterRow = row(
|
|
1433
|
+
toggle(openDialog.showWorktrees, worktreeLabel, {
|
|
1434
|
+
key: openDialog.pendingConfirm !== null ? undefined : "worktree-show",
|
|
1435
|
+
}),
|
|
1436
|
+
flexSpacer(),
|
|
1437
|
+
);
|
|
977
1438
|
|
|
978
1439
|
return col(
|
|
979
1440
|
{
|
|
@@ -1028,6 +1489,7 @@ function buildOpenSpec(): WidgetSpec {
|
|
|
1028
1489
|
flexSpacer(),
|
|
1029
1490
|
),
|
|
1030
1491
|
projectControlRow,
|
|
1492
|
+
worktreeFilterRow,
|
|
1031
1493
|
filterInput,
|
|
1032
1494
|
sessionsSeparator(),
|
|
1033
1495
|
sessionsColumnHeader(),
|
|
@@ -1067,6 +1529,11 @@ function buildOpenSpec(): WidgetSpec {
|
|
|
1067
1529
|
hintBar([
|
|
1068
1530
|
{ keys: "↑↓", label: "nav" },
|
|
1069
1531
|
{ keys: "Enter", label: "dive" },
|
|
1532
|
+
{
|
|
1533
|
+
keys: editor.getKeybindingLabel("orchestrator_toggle_select", OPEN_MODE) ||
|
|
1534
|
+
"Space",
|
|
1535
|
+
label: "select",
|
|
1536
|
+
},
|
|
1070
1537
|
{
|
|
1071
1538
|
keys: scopeKey || "⌥P",
|
|
1072
1539
|
label: scope === "current" ? "all projects" : "current only",
|
|
@@ -1127,6 +1594,7 @@ function clearDialogError(): void {
|
|
|
1127
1594
|
|
|
1128
1595
|
function refreshOpenDialog(): void {
|
|
1129
1596
|
if (!openPanel || !openDialog) return;
|
|
1597
|
+
pruneSelection();
|
|
1130
1598
|
openDialog.filteredIds = filterSessions(openDialog.filter.value);
|
|
1131
1599
|
// Clamp the selection into range so a fresh filter or a
|
|
1132
1600
|
// session vanishing under us doesn't leave us pointing past
|
|
@@ -1167,6 +1635,9 @@ function openControlRoom(): void {
|
|
|
1167
1635
|
// Restore the last-used scope (defaults to "all"); the Project
|
|
1168
1636
|
// control / Alt+P updates it for next time.
|
|
1169
1637
|
scope: lastOpenScope,
|
|
1638
|
+
selectedIds: new Set<number>(),
|
|
1639
|
+
showWorktrees: lastShowWorktrees,
|
|
1640
|
+
bulkInFlight: null,
|
|
1170
1641
|
};
|
|
1171
1642
|
openDialog.filteredIds = filterSessions("");
|
|
1172
1643
|
const activeIdx = openDialog.filteredIds.indexOf(activeId);
|
|
@@ -1188,6 +1659,12 @@ function openControlRoom(): void {
|
|
|
1188
1659
|
// safe — there's nothing to act on then anyway.
|
|
1189
1660
|
openPanel.setFocusKey("visit");
|
|
1190
1661
|
editor.setEditorMode(OPEN_MODE);
|
|
1662
|
+
|
|
1663
|
+
// Discover worktrees that exist on disk but aren't open yet and
|
|
1664
|
+
// fold them into the list. Async (it shells out to git per
|
|
1665
|
+
// project); the dialog renders immediately with live sessions and
|
|
1666
|
+
// gains the discovered rows when the scan lands.
|
|
1667
|
+
void refreshDiscoveredWorktrees();
|
|
1191
1668
|
}
|
|
1192
1669
|
|
|
1193
1670
|
function closeOpenDialog(): void {
|
|
@@ -1199,33 +1676,27 @@ function closeOpenDialog(): void {
|
|
|
1199
1676
|
editor.setEditorMode(null);
|
|
1200
1677
|
}
|
|
1201
1678
|
|
|
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
|
-
}
|
|
1679
|
+
// Stop every process one session owns. Sends SIGTERM first via the
|
|
1680
|
+
// host's `signalWindow` (which fans out through the window's
|
|
1681
|
+
// process-group tracker), then follows up with SIGKILL after a short
|
|
1682
|
+
// grace period so ill-behaved agents that ignore SIGTERM still get
|
|
1683
|
+
// reaped. The session record stays put — Stop only kills processes,
|
|
1684
|
+
// it doesn't touch the worktree or the editor session. Returns false
|
|
1685
|
+
// for ids it can't stop (base session, discovered worktrees with no
|
|
1686
|
+
// live window).
|
|
1687
|
+
function stopOne(id: number): boolean {
|
|
1688
|
+
const s = orchestratorSessions.get(id);
|
|
1689
|
+
if (!s || id <= 0 || id === 1 || s.discovered) return false;
|
|
1218
1690
|
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
|
|
1691
|
+
// SIGKILL fallback for agents that ignore SIGTERM. The host's
|
|
1692
|
+
// signalWindow is idempotent on already-exited process groups, so
|
|
1693
|
+
// the second call is safe whether or not the first one took.
|
|
1694
|
+
// QuickJS has no `setTimeout`; `editor.delay(ms)` is the async
|
|
1224
1695
|
// sleep primitive, which we kick off but don't await.
|
|
1225
1696
|
void editor.delay(2000).then(() => {
|
|
1226
1697
|
editor.signalWindow(id, "SIGKILL");
|
|
1227
1698
|
});
|
|
1228
|
-
|
|
1699
|
+
return true;
|
|
1229
1700
|
}
|
|
1230
1701
|
|
|
1231
1702
|
// ---------------------------------------------------------------------
|
|
@@ -1308,136 +1779,97 @@ function pickNextActiveSession(excludeId: number): number {
|
|
|
1308
1779
|
return 1;
|
|
1309
1780
|
}
|
|
1310
1781
|
|
|
1311
|
-
//
|
|
1312
|
-
//
|
|
1313
|
-
//
|
|
1314
|
-
//
|
|
1315
|
-
//
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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;
|
|
1359
|
-
}
|
|
1360
|
-
|
|
1361
|
-
// Resolve the repo root from cwd (the user is in the
|
|
1362
|
-
// umbrella session's tree).
|
|
1363
|
-
const cwd = editor.getCwd();
|
|
1364
|
-
const top = await spawnCollect(
|
|
1365
|
-
"git",
|
|
1366
|
-
["rev-parse", "--show-toplevel"],
|
|
1367
|
-
cwd,
|
|
1368
|
-
);
|
|
1369
|
-
if (top.exit_code !== 0) {
|
|
1370
|
-
editor.setStatus("Orchestrator: archive failed — not a git repository");
|
|
1371
|
-
clearInFlight();
|
|
1372
|
-
return;
|
|
1782
|
+
// Resolve the *main* repo root a session's worktree belongs to, so
|
|
1783
|
+
// `git worktree move/remove` runs from a stable directory (never from
|
|
1784
|
+
// inside the tree being moved/removed). Prefers the canonical
|
|
1785
|
+
// `projectPath` recorded at create/discovery time, falling back to
|
|
1786
|
+
// resolving from the worktree itself.
|
|
1787
|
+
async function worktreeRepoRoot(s: AgentSession): Promise<string | null> {
|
|
1788
|
+
if (s.projectPath) {
|
|
1789
|
+
const r = await resolveCanonicalRepoRoot(s.projectPath);
|
|
1790
|
+
if (r) return r;
|
|
1373
1791
|
}
|
|
1374
|
-
|
|
1792
|
+
return await resolveCanonicalRepoRoot(s.root);
|
|
1793
|
+
}
|
|
1375
1794
|
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
editor.signalWindow(id, "SIGKILL");
|
|
1382
|
-
editor.closeWindow(id);
|
|
1795
|
+
interface LifecycleResult {
|
|
1796
|
+
ok: boolean;
|
|
1797
|
+
err?: string;
|
|
1798
|
+
repoRoot?: string;
|
|
1799
|
+
}
|
|
1383
1800
|
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1801
|
+
// Archive a single session: SIGKILL its processes (archive is a
|
|
1802
|
+
// "done with this for now" action — no graceful teardown needed since
|
|
1803
|
+
// the worktree stays on disk), close the editor session, move the
|
|
1804
|
+
// worktree to the `.archived/` graveyard, and append a manifest
|
|
1805
|
+
// entry so Unarchive can reverse it. Handles both live sessions and
|
|
1806
|
+
// discovered on-disk worktrees (the latter have no window to close).
|
|
1807
|
+
// Does NOT trigger sync — the caller batches one sync per repo after
|
|
1808
|
+
// the whole run.
|
|
1809
|
+
async function archiveOne(id: number): Promise<LifecycleResult> {
|
|
1810
|
+
const s = orchestratorSessions.get(id);
|
|
1811
|
+
if (!s) return { ok: false, err: "session gone" };
|
|
1812
|
+
if (id === 1) return { ok: false, err: "cannot archive the base session" };
|
|
1813
|
+
const repoRoot = await worktreeRepoRoot(s);
|
|
1814
|
+
if (!repoRoot) return { ok: false, err: "not a git repository" };
|
|
1815
|
+
|
|
1816
|
+
// Live session: close_window refuses to close the active window, so
|
|
1817
|
+
// switch away first, then SIGKILL the process group (so pty
|
|
1818
|
+
// children release worktree locks) and close the editor session.
|
|
1819
|
+
if (!s.discovered && id > 0) {
|
|
1820
|
+
if (id === editor.activeWindow()) {
|
|
1821
|
+
editor.setActiveWindow(pickNextActiveSession(id));
|
|
1822
|
+
}
|
|
1823
|
+
editor.signalWindow(id, "SIGKILL");
|
|
1824
|
+
editor.closeWindow(id);
|
|
1825
|
+
// Brief settle so the filesystem reflects the pty's exit before
|
|
1826
|
+
// we move the worktree out from under it.
|
|
1827
|
+
await editor.delay(250);
|
|
1828
|
+
}
|
|
1387
1829
|
|
|
1388
|
-
// git worktree move keeps git's internal bookkeeping
|
|
1389
|
-
// consistent (the new path stays registered as a worktree).
|
|
1390
1830
|
const archivedRoot = editor.pathJoin(
|
|
1391
1831
|
editor.getDataDir(),
|
|
1392
1832
|
"orchestrator",
|
|
1393
1833
|
slugify(repoRoot),
|
|
1394
1834
|
".archived",
|
|
1395
|
-
|
|
1835
|
+
s.label,
|
|
1396
1836
|
);
|
|
1397
1837
|
const parent = editor.pathDirname(archivedRoot);
|
|
1398
1838
|
if (!editor.createDir(parent)) {
|
|
1399
|
-
|
|
1400
|
-
`Orchestrator: archive failed — could not create ${parent}`,
|
|
1401
|
-
);
|
|
1402
|
-
clearInFlight();
|
|
1403
|
-
return;
|
|
1839
|
+
return { ok: false, err: `could not create ${parent}`, repoRoot };
|
|
1404
1840
|
}
|
|
1841
|
+
// git worktree move keeps git's internal bookkeeping consistent
|
|
1842
|
+
// (the new path stays registered as a worktree).
|
|
1405
1843
|
const moveRes = await spawnCollect(
|
|
1406
1844
|
"git",
|
|
1407
|
-
["-C", repoRoot, "worktree", "move",
|
|
1845
|
+
["-C", repoRoot, "worktree", "move", s.root, archivedRoot],
|
|
1408
1846
|
repoRoot,
|
|
1409
1847
|
);
|
|
1410
1848
|
if (moveRes.exit_code !== 0) {
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
clearInFlight();
|
|
1417
|
-
return;
|
|
1849
|
+
return {
|
|
1850
|
+
ok: false,
|
|
1851
|
+
err: lastNonEmptyLine(moveRes.stderr) || "worktree move failed",
|
|
1852
|
+
repoRoot,
|
|
1853
|
+
};
|
|
1418
1854
|
}
|
|
1419
1855
|
|
|
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
1856
|
const manifest = loadArchiveManifest(repoRoot);
|
|
1425
1857
|
manifest.sessions.push({
|
|
1426
|
-
label:
|
|
1858
|
+
label: s.label,
|
|
1427
1859
|
root: archivedRoot,
|
|
1428
|
-
original_root:
|
|
1429
|
-
branch:
|
|
1860
|
+
original_root: s.root,
|
|
1861
|
+
branch: s.branch || s.label,
|
|
1430
1862
|
archived_at: new Date().toISOString(),
|
|
1431
1863
|
});
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1864
|
+
saveArchiveManifest(repoRoot, manifest);
|
|
1865
|
+
|
|
1866
|
+
// A discovered row has no window_closed hook to drop it — remove it
|
|
1867
|
+
// from the model directly.
|
|
1868
|
+
if (s.discovered) {
|
|
1869
|
+
orchestratorSessions.delete(id);
|
|
1870
|
+
discoveredIdByPath.delete(s.root);
|
|
1438
1871
|
}
|
|
1439
|
-
|
|
1440
|
-
triggerSyncAsync(repoRoot);
|
|
1872
|
+
return { ok: true, repoRoot };
|
|
1441
1873
|
}
|
|
1442
1874
|
|
|
1443
1875
|
// ---------------------------------------------------------------------
|
|
@@ -1640,86 +2072,135 @@ async function buildSyncSnapshot(repoRoot: string): Promise<unknown> {
|
|
|
1640
2072
|
};
|
|
1641
2073
|
}
|
|
1642
2074
|
|
|
1643
|
-
// Delete
|
|
1644
|
-
// session, then `git worktree remove --force` to drop the
|
|
1645
|
-
// worktree from disk. If the session was archived (manifest
|
|
1646
|
-
//
|
|
1647
|
-
// recovery after this
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
const
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
if (
|
|
1659
|
-
|
|
1660
|
-
refreshOpenDialog();
|
|
2075
|
+
// Delete a single session: stop processes (SIGKILL), close the
|
|
2076
|
+
// editor session, then `git worktree remove --force` to drop the
|
|
2077
|
+
// worktree from disk. If the session was archived (manifest entry
|
|
2078
|
+
// exists), the manifest entry is dropped too. Handles discovered
|
|
2079
|
+
// on-disk worktrees (no window to close). No recovery after this
|
|
2080
|
+
// point. Does NOT trigger sync — the caller batches it.
|
|
2081
|
+
async function deleteOne(id: number): Promise<LifecycleResult> {
|
|
2082
|
+
const s = orchestratorSessions.get(id);
|
|
2083
|
+
if (!s) return { ok: false, err: "session gone" };
|
|
2084
|
+
if (id === 1) return { ok: false, err: "cannot delete the base session" };
|
|
2085
|
+
const repoRoot = await worktreeRepoRoot(s);
|
|
2086
|
+
if (!repoRoot) return { ok: false, err: "not a git repository" };
|
|
2087
|
+
|
|
2088
|
+
if (!s.discovered && id > 0) {
|
|
2089
|
+
// close_window refuses to close the active window, so swap away.
|
|
2090
|
+
if (id === editor.activeWindow()) {
|
|
2091
|
+
editor.setActiveWindow(pickNextActiveSession(id));
|
|
1661
2092
|
}
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
clearInFlight();
|
|
1666
|
-
return;
|
|
1667
|
-
}
|
|
1668
|
-
// Same auto-switch as archive — close_window refuses to close
|
|
1669
|
-
// the active window, so swap to a different session first.
|
|
1670
|
-
if (id === editor.activeWindow()) {
|
|
1671
|
-
editor.setActiveWindow(pickNextActiveSession(id));
|
|
1672
|
-
}
|
|
1673
|
-
|
|
1674
|
-
const cwd = editor.getCwd();
|
|
1675
|
-
const top = await spawnCollect(
|
|
1676
|
-
"git",
|
|
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;
|
|
2093
|
+
editor.signalWindow(id, "SIGKILL");
|
|
2094
|
+
editor.closeWindow(id);
|
|
2095
|
+
await editor.delay(250);
|
|
1684
2096
|
}
|
|
1685
|
-
const repoRoot = (top.stdout || "").trim();
|
|
1686
|
-
|
|
1687
|
-
editor.signalWindow(id, "SIGKILL");
|
|
1688
|
-
editor.closeWindow(id);
|
|
1689
|
-
await editor.delay(250);
|
|
1690
2097
|
|
|
1691
|
-
// `--force` because the worktree may have unstaged changes
|
|
1692
|
-
//
|
|
2098
|
+
// `--force` because the worktree may have unstaged changes the user
|
|
2099
|
+
// explicitly chose to discard via the confirm step.
|
|
1693
2100
|
const removeRes = await spawnCollect(
|
|
1694
2101
|
"git",
|
|
1695
|
-
["-C", repoRoot, "worktree", "remove", "--force",
|
|
2102
|
+
["-C", repoRoot, "worktree", "remove", "--force", s.root],
|
|
1696
2103
|
repoRoot,
|
|
1697
2104
|
);
|
|
1698
2105
|
if (removeRes.exit_code !== 0) {
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
clearInFlight();
|
|
1705
|
-
return;
|
|
2106
|
+
return {
|
|
2107
|
+
ok: false,
|
|
2108
|
+
err: lastNonEmptyLine(removeRes.stderr) || "worktree remove failed",
|
|
2109
|
+
repoRoot,
|
|
2110
|
+
};
|
|
1706
2111
|
}
|
|
1707
2112
|
|
|
1708
|
-
// Drop the matching manifest entry too, in case the session
|
|
1709
|
-
//
|
|
1710
|
-
//
|
|
2113
|
+
// Drop the matching manifest entry too, in case the session was
|
|
2114
|
+
// already archived (delete-from-archived is the natural way to drop
|
|
2115
|
+
// dormant sessions).
|
|
1711
2116
|
const manifest = loadArchiveManifest(repoRoot);
|
|
1712
2117
|
const before = manifest.sessions.length;
|
|
1713
|
-
manifest.sessions = manifest.sessions.filter(
|
|
1714
|
-
(e) => e.label !== session.label,
|
|
1715
|
-
);
|
|
2118
|
+
manifest.sessions = manifest.sessions.filter((e) => e.label !== s.label);
|
|
1716
2119
|
if (manifest.sessions.length !== before) {
|
|
1717
2120
|
saveArchiveManifest(repoRoot, manifest);
|
|
1718
2121
|
}
|
|
1719
2122
|
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
2123
|
+
if (s.discovered) {
|
|
2124
|
+
orchestratorSessions.delete(id);
|
|
2125
|
+
discoveredIdByPath.delete(s.root);
|
|
2126
|
+
}
|
|
2127
|
+
return { ok: true, repoRoot };
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
// Unified runner for a confirmed Stop / Archive / Delete over one or
|
|
2131
|
+
// many ids. Re-filters to eligible targets at execution time (the
|
|
2132
|
+
// selection or single row may have gone stale between confirm and
|
|
2133
|
+
// run), drives the in-flight progress markers, runs the per-id cores
|
|
2134
|
+
// sequentially, prunes acted-on ids from the selection, and triggers
|
|
2135
|
+
// one sync per touched repo at the end.
|
|
2136
|
+
async function runConfirmedAction(
|
|
2137
|
+
action: BulkAction,
|
|
2138
|
+
ids: number[],
|
|
2139
|
+
): Promise<void> {
|
|
2140
|
+
if (!openDialog) return;
|
|
2141
|
+
const targets = ids.filter((id) => bulkEligible(action, id));
|
|
2142
|
+
if (targets.length === 0) {
|
|
2143
|
+
setDialogError(`nothing eligible to ${action} in the selection`);
|
|
2144
|
+
refreshOpenDialog();
|
|
2145
|
+
return;
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
if (action === "stop") {
|
|
2149
|
+
let n = 0;
|
|
2150
|
+
for (const id of targets) if (stopOne(id)) n += 1;
|
|
2151
|
+
editor.setStatus(`Orchestrator: stop signal sent to ${n} session(s)`);
|
|
2152
|
+
// Stop leaves sessions in place; drop them from the selection so
|
|
2153
|
+
// the bulk bar reflects that the action ran.
|
|
2154
|
+
for (const id of targets) openDialog.selectedIds.delete(id);
|
|
2155
|
+
refreshOpenDialog();
|
|
2156
|
+
return;
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
const single = targets.length === 1;
|
|
2160
|
+
if (single) {
|
|
2161
|
+
openDialog.inFlight = { action, sessionId: targets[0] };
|
|
2162
|
+
} else {
|
|
2163
|
+
openDialog.bulkInFlight = { action, total: targets.length, done: 0 };
|
|
2164
|
+
}
|
|
2165
|
+
refreshOpenDialog();
|
|
2166
|
+
|
|
2167
|
+
const touchedRepos = new Set<string>();
|
|
2168
|
+
let okCount = 0;
|
|
2169
|
+
let lastErr = "";
|
|
2170
|
+
for (let i = 0; i < targets.length; i++) {
|
|
2171
|
+
const id = targets[i];
|
|
2172
|
+
const res = action === "archive" ? await archiveOne(id) : await deleteOne(id);
|
|
2173
|
+
if (res.ok) {
|
|
2174
|
+
okCount += 1;
|
|
2175
|
+
if (res.repoRoot) touchedRepos.add(res.repoRoot);
|
|
2176
|
+
} else {
|
|
2177
|
+
lastErr = res.err ?? "failed";
|
|
2178
|
+
}
|
|
2179
|
+
openDialog?.selectedIds.delete(id);
|
|
2180
|
+
if (openDialog?.bulkInFlight) openDialog.bulkInFlight.done = i + 1;
|
|
2181
|
+
refreshOpenDialog();
|
|
2182
|
+
}
|
|
2183
|
+
if (openDialog) {
|
|
2184
|
+
openDialog.inFlight = null;
|
|
2185
|
+
openDialog.bulkInFlight = null;
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
const verb = action === "archive" ? "archived" : "deleted";
|
|
2189
|
+
if (okCount === 0) {
|
|
2190
|
+
setDialogError(`${action} failed: ${lastErr || "unknown error"}`);
|
|
2191
|
+
} else if (lastErr) {
|
|
2192
|
+
setDialogError(`${verb} ${okCount}/${targets.length}; last error: ${lastErr}`);
|
|
2193
|
+
} else {
|
|
2194
|
+
editor.setStatus(`Orchestrator: ${verb} ${okCount} session(s)`);
|
|
2195
|
+
}
|
|
2196
|
+
for (const repo of touchedRepos) triggerSyncAsync(repo);
|
|
2197
|
+
refreshOpenDialog();
|
|
2198
|
+
// The batch emptied the selection, so the pane is back in
|
|
2199
|
+
// single-preview mode — restore focus to Visit (the bulk buttons
|
|
2200
|
+
// it may have been on are gone).
|
|
2201
|
+
if (openPanel && selectedSessions().length < 2 && !openDialog.pendingConfirm) {
|
|
2202
|
+
openPanel.setFocusKey("visit");
|
|
2203
|
+
}
|
|
1723
2204
|
}
|
|
1724
2205
|
|
|
1725
2206
|
// `Alt+N` from inside the picker opens the new-session form — saves
|
|
@@ -1742,6 +2223,20 @@ editor.defineMode(
|
|
|
1742
2223
|
// text; session names don't contain `/`, so that's an
|
|
1743
2224
|
// acceptable trade for the quick-focus.)
|
|
1744
2225
|
["/", "orchestrator_focus_filter"],
|
|
2226
|
+
// Space toggles the highlighted row's membership in the bulk
|
|
2227
|
+
// selection. Bound as a mode chord (not a widget smart-key) so
|
|
2228
|
+
// it's user-rebindable in the keybinding editor and fires
|
|
2229
|
+
// regardless of which control holds focus — the host's
|
|
2230
|
+
// `dispatch_floating_widget_key` defers any explicitly-bound
|
|
2231
|
+
// mode key, including bare chars, before the text-input path.
|
|
2232
|
+
// The trade (same as `/`) is that Space can't be typed into the
|
|
2233
|
+
// filter while the picker is open; session names don't contain
|
|
2234
|
+
// spaces, so that's acceptable.
|
|
2235
|
+
["Space", "orchestrator_toggle_select"],
|
|
2236
|
+
// Alt+T toggles "Show all worktrees" — the opt-in filter that
|
|
2237
|
+
// surfaces discovered on-disk worktree rows. Rebindable, same as
|
|
2238
|
+
// the scope toggle.
|
|
2239
|
+
["M-t", "orchestrator_toggle_worktrees"],
|
|
1745
2240
|
],
|
|
1746
2241
|
true,
|
|
1747
2242
|
true,
|
|
@@ -1758,6 +2253,38 @@ registerHandler("orchestrator_focus_filter", () => {
|
|
|
1758
2253
|
openPanel.setFocusKey("filter");
|
|
1759
2254
|
});
|
|
1760
2255
|
|
|
2256
|
+
// Space (rebindable): toggle the highlighted row in/out of the bulk
|
|
2257
|
+
// selection. Manages focus across the single↔bulk transition: when
|
|
2258
|
+
// the second row is checked the preview pane swaps to the bulk bar
|
|
2259
|
+
// (so the now-absent "visit" focus would otherwise be clamped to a
|
|
2260
|
+
// random tabbable), and when the selection drops back below two the
|
|
2261
|
+
// per-session preview — with its "visit" button — returns.
|
|
2262
|
+
registerHandler("orchestrator_toggle_select", () => {
|
|
2263
|
+
if (!openDialog || !openPanel) return;
|
|
2264
|
+
// Inert while a confirm prompt is up — the selection is frozen
|
|
2265
|
+
// behind the confirmation panel.
|
|
2266
|
+
if (openDialog.pendingConfirm) return;
|
|
2267
|
+
const id = openDialog.filteredIds[openDialog.selectedIndex];
|
|
2268
|
+
if (typeof id !== "number") return;
|
|
2269
|
+
const wasBulk = selectedSessions().length >= 2;
|
|
2270
|
+
if (openDialog.selectedIds.has(id)) {
|
|
2271
|
+
openDialog.selectedIds.delete(id);
|
|
2272
|
+
} else {
|
|
2273
|
+
openDialog.selectedIds.add(id);
|
|
2274
|
+
}
|
|
2275
|
+
clearDialogError();
|
|
2276
|
+
refreshOpenDialog();
|
|
2277
|
+
const isBulk = selectedSessions().length >= 2;
|
|
2278
|
+
if (!wasBulk && isBulk) {
|
|
2279
|
+
// Entering bulk mode — land focus on a bulk button (Up/Down from
|
|
2280
|
+
// a button still drives the list, so navigation keeps working).
|
|
2281
|
+
openPanel.setFocusKey("bulk-archive");
|
|
2282
|
+
} else if (wasBulk && !isBulk) {
|
|
2283
|
+
// Back to single preview — restore focus to Visit.
|
|
2284
|
+
openPanel.setFocusKey("visit");
|
|
2285
|
+
}
|
|
2286
|
+
});
|
|
2287
|
+
|
|
1761
2288
|
function toggleScope(): void {
|
|
1762
2289
|
if (!openDialog) return;
|
|
1763
2290
|
openDialog.scope = openDialog.scope === "current" ? "all" : "current";
|
|
@@ -1776,6 +2303,31 @@ function toggleScope(): void {
|
|
|
1776
2303
|
|
|
1777
2304
|
registerHandler("orchestrator_toggle_scope", toggleScope);
|
|
1778
2305
|
|
|
2306
|
+
// Flip "Show all worktrees" — reveal/hide the discovered on-disk
|
|
2307
|
+
// worktree rows. Preserves the highlighted row across the re-filter
|
|
2308
|
+
// where possible; drops now-hidden discovered rows from the bulk
|
|
2309
|
+
// selection. Shared by the Alt+T chord and the checkbox click.
|
|
2310
|
+
function toggleShowWorktrees(): void {
|
|
2311
|
+
if (!openDialog) return;
|
|
2312
|
+
openDialog.showWorktrees = !openDialog.showWorktrees;
|
|
2313
|
+
lastShowWorktrees = openDialog.showWorktrees;
|
|
2314
|
+
// Hiding worktrees shouldn't leave them lingering in the selection.
|
|
2315
|
+
if (!openDialog.showWorktrees) {
|
|
2316
|
+
for (const id of [...openDialog.selectedIds]) {
|
|
2317
|
+
if (orchestratorSessions.get(id)?.discovered) {
|
|
2318
|
+
openDialog.selectedIds.delete(id);
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
const prevId = openDialog.filteredIds[openDialog.selectedIndex];
|
|
2323
|
+
openDialog.filteredIds = filterSessions(openDialog.filter.value);
|
|
2324
|
+
const nextIdx = prevId !== undefined ? openDialog.filteredIds.indexOf(prevId) : -1;
|
|
2325
|
+
openDialog.selectedIndex = nextIdx >= 0 ? nextIdx : 0;
|
|
2326
|
+
refreshOpenDialog();
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
registerHandler("orchestrator_toggle_worktrees", toggleShowWorktrees);
|
|
2330
|
+
|
|
1779
2331
|
// =============================================================================
|
|
1780
2332
|
// New-session floating form
|
|
1781
2333
|
// =============================================================================
|
|
@@ -2078,6 +2630,130 @@ async function pathIsInsideGitWorkTree(
|
|
|
2078
2630
|
return (res.stdout || "").trim() === "true";
|
|
2079
2631
|
}
|
|
2080
2632
|
|
|
2633
|
+
// =============================================================================
|
|
2634
|
+
// Worktree classification & discovery
|
|
2635
|
+
//
|
|
2636
|
+
// Two distinct git facts drive the "attach to an existing worktree"
|
|
2637
|
+
// flows:
|
|
2638
|
+
//
|
|
2639
|
+
// * `classifyWorktree(path)` answers "is this path a *linked*
|
|
2640
|
+
// worktree, and if so what repo does it belong to?" — used by
|
|
2641
|
+
// the new-session form to attach (rather than fork) when the
|
|
2642
|
+
// user points Project Path at an existing worktree.
|
|
2643
|
+
// * `listLinkedWorktrees(repoRoot)` enumerates every linked
|
|
2644
|
+
// worktree of a repo (via `git worktree list --porcelain`) —
|
|
2645
|
+
// used to surface on-disk worktrees in the Open dialog without
|
|
2646
|
+
// the user adding them by hand.
|
|
2647
|
+
// =============================================================================
|
|
2648
|
+
|
|
2649
|
+
interface WorktreeInfo {
|
|
2650
|
+
// `git rev-parse --show-toplevel` for the path.
|
|
2651
|
+
toplevel: string;
|
|
2652
|
+
// Canonical main-worktree root (dirname of `--git-common-dir`).
|
|
2653
|
+
// This is the repo the worktree belongs to, used as the
|
|
2654
|
+
// session's `projectPath` so attached worktrees group under
|
|
2655
|
+
// their repo in the picker.
|
|
2656
|
+
mainRoot: string;
|
|
2657
|
+
// `true` when the path is a *linked* worktree (its per-worktree
|
|
2658
|
+
// git dir differs from the shared common dir), i.e. a tree
|
|
2659
|
+
// created by `git worktree add` rather than the main checkout.
|
|
2660
|
+
isLinked: boolean;
|
|
2661
|
+
// Branch checked out there (`refs/heads/<name>` short form), or
|
|
2662
|
+
// empty when detached.
|
|
2663
|
+
branch: string;
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
/// Classify `path` as a git worktree. Returns `null` when `path`
|
|
2667
|
+
/// is not inside any git work tree (the caller then treats it as a
|
|
2668
|
+
/// plain directory / shared root).
|
|
2669
|
+
async function classifyWorktree(path: string): Promise<WorktreeInfo | null> {
|
|
2670
|
+
if (!path) return null;
|
|
2671
|
+
const top = await spawnCollect("git", ["-C", path, "rev-parse", "--show-toplevel"], path);
|
|
2672
|
+
if (top.exit_code !== 0) return null;
|
|
2673
|
+
const toplevel = (top.stdout || "").trim();
|
|
2674
|
+
if (!toplevel) return null;
|
|
2675
|
+
|
|
2676
|
+
// The per-worktree git dir vs. the shared common dir: they are
|
|
2677
|
+
// equal for the main worktree and differ for every linked
|
|
2678
|
+
// worktree (`<common>/worktrees/<id>`). That difference is the
|
|
2679
|
+
// canonical "is this a linked worktree?" test.
|
|
2680
|
+
const [gitDir, commonDir] = await Promise.all([
|
|
2681
|
+
spawnCollect("git", ["-C", toplevel, "rev-parse", "--path-format=absolute", "--git-dir"], toplevel),
|
|
2682
|
+
spawnCollect(
|
|
2683
|
+
"git",
|
|
2684
|
+
["-C", toplevel, "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
2685
|
+
toplevel,
|
|
2686
|
+
),
|
|
2687
|
+
]);
|
|
2688
|
+
const gd = gitDir.exit_code === 0 ? (gitDir.stdout || "").trim() : "";
|
|
2689
|
+
const cd = commonDir.exit_code === 0 ? (commonDir.stdout || "").trim() : "";
|
|
2690
|
+
const isLinked = gd !== "" && cd !== "" && gd !== cd;
|
|
2691
|
+
const mainRoot = cd ? editor.pathDirname(cd) : toplevel;
|
|
2692
|
+
|
|
2693
|
+
const head = await spawnCollect(
|
|
2694
|
+
"git",
|
|
2695
|
+
["-C", toplevel, "rev-parse", "--abbrev-ref", "HEAD"],
|
|
2696
|
+
toplevel,
|
|
2697
|
+
);
|
|
2698
|
+
let branch = head.exit_code === 0 ? (head.stdout || "").trim() : "";
|
|
2699
|
+
if (branch === "HEAD") branch = ""; // detached
|
|
2700
|
+
|
|
2701
|
+
return { toplevel, mainRoot, isLinked, branch };
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
interface ParsedWorktree {
|
|
2705
|
+
path: string;
|
|
2706
|
+
branch: string;
|
|
2707
|
+
detached: boolean;
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
/// Parse `git worktree list --porcelain` output. Blocks are
|
|
2711
|
+
/// separated by blank lines; the first block is the main worktree,
|
|
2712
|
+
/// the rest are linked. Each block has a `worktree <path>` line
|
|
2713
|
+
/// plus `branch refs/heads/<name>` or `detached`.
|
|
2714
|
+
function parseWorktreePorcelain(stdout: string): ParsedWorktree[] {
|
|
2715
|
+
const out: ParsedWorktree[] = [];
|
|
2716
|
+
let cur: ParsedWorktree | null = null;
|
|
2717
|
+
for (const raw of (stdout || "").split(/\r?\n/)) {
|
|
2718
|
+
const line = raw.trimEnd();
|
|
2719
|
+
if (line.startsWith("worktree ")) {
|
|
2720
|
+
if (cur) out.push(cur);
|
|
2721
|
+
cur = { path: line.slice("worktree ".length), branch: "", detached: false };
|
|
2722
|
+
} else if (cur && line.startsWith("branch ")) {
|
|
2723
|
+
const ref = line.slice("branch ".length);
|
|
2724
|
+
cur.branch = ref.replace(/^refs\/heads\//, "");
|
|
2725
|
+
} else if (cur && line === "detached") {
|
|
2726
|
+
cur.detached = true;
|
|
2727
|
+
} else if (line === "" && cur) {
|
|
2728
|
+
out.push(cur);
|
|
2729
|
+
cur = null;
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
if (cur) out.push(cur);
|
|
2733
|
+
return out;
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
/// Enumerate the *linked* worktrees of `repoRoot` (excludes the
|
|
2737
|
+
/// main worktree, which is the repo's own checkout). Returns the
|
|
2738
|
+
/// parsed entries with the main-repo root resolved so callers can
|
|
2739
|
+
/// tag discovered sessions with the right `projectPath`.
|
|
2740
|
+
async function listLinkedWorktrees(
|
|
2741
|
+
repoRoot: string,
|
|
2742
|
+
): Promise<{ mainRoot: string; worktrees: ParsedWorktree[] } | null> {
|
|
2743
|
+
const res = await spawnCollect(
|
|
2744
|
+
"git",
|
|
2745
|
+
["-C", repoRoot, "worktree", "list", "--porcelain"],
|
|
2746
|
+
repoRoot,
|
|
2747
|
+
);
|
|
2748
|
+
if (res.exit_code !== 0) return null;
|
|
2749
|
+
const all = parseWorktreePorcelain(res.stdout || "");
|
|
2750
|
+
if (all.length === 0) return null;
|
|
2751
|
+
// The first entry is always the main worktree.
|
|
2752
|
+
const mainRoot = all[0].path;
|
|
2753
|
+
const worktrees = all.slice(1);
|
|
2754
|
+
return { mainRoot, worktrees };
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2081
2757
|
async function nextAutoSessionName(
|
|
2082
2758
|
repoRoot: string,
|
|
2083
2759
|
options?: { persist?: boolean },
|
|
@@ -2164,9 +2840,11 @@ function buildFormSpec(): WidgetSpec {
|
|
|
2164
2840
|
// inert when worktree creation is off.
|
|
2165
2841
|
let branchPlaceholder: string;
|
|
2166
2842
|
if (branchInert) {
|
|
2167
|
-
branchPlaceholder = worktreeEnabled
|
|
2168
|
-
? "
|
|
2169
|
-
:
|
|
2843
|
+
branchPlaceholder = !worktreeEnabled
|
|
2844
|
+
? "no git — N/A"
|
|
2845
|
+
: form.projectPathIsLinkedWorktree === true
|
|
2846
|
+
? "existing worktree — N/A"
|
|
2847
|
+
: "shared worktree — N/A";
|
|
2170
2848
|
} else if (!form.defaultBranch) {
|
|
2171
2849
|
branchPlaceholder = "detecting default branch…";
|
|
2172
2850
|
} else if (form.defaultBranchIsHeadFallback) {
|
|
@@ -2241,6 +2919,24 @@ function buildFormSpec(): WidgetSpec {
|
|
|
2241
2919
|
]),
|
|
2242
2920
|
],
|
|
2243
2921
|
},
|
|
2922
|
+
// Existing-worktree hint: when Project Path points at a linked
|
|
2923
|
+
// worktree, explain what the (un)checked box now means so the
|
|
2924
|
+
// attach behaviour isn't a silent surprise.
|
|
2925
|
+
...(form.projectPathIsLinkedWorktree === true
|
|
2926
|
+
? [{
|
|
2927
|
+
kind: "raw" as const,
|
|
2928
|
+
entries: [
|
|
2929
|
+
styledRow([
|
|
2930
|
+
{
|
|
2931
|
+
text: form.createWorktree
|
|
2932
|
+
? " ↳ existing worktree here — uncheck to attach instead of forking a new one"
|
|
2933
|
+
: " ↳ existing worktree — this session will attach to it",
|
|
2934
|
+
style: { fg: "ui.help_key_fg", italic: true },
|
|
2935
|
+
},
|
|
2936
|
+
]),
|
|
2937
|
+
],
|
|
2938
|
+
}]
|
|
2939
|
+
: []),
|
|
2244
2940
|
// === Form body: labeled, full-width inputs. ==================
|
|
2245
2941
|
// Labels are plain — the `▸` glyph used to be baked into all
|
|
2246
2942
|
// three strings and stayed put regardless of focus, which was
|
|
@@ -2380,6 +3076,7 @@ function openForm(options?: { fromPicker?: boolean }): void {
|
|
|
2380
3076
|
lastError: null,
|
|
2381
3077
|
defaultProjectPath: "",
|
|
2382
3078
|
projectPathIsGit: null,
|
|
3079
|
+
projectPathIsLinkedWorktree: null,
|
|
2383
3080
|
defaultSessionName: "",
|
|
2384
3081
|
defaultBranch: "",
|
|
2385
3082
|
defaultBranchIsHeadFallback: false,
|
|
@@ -2442,6 +3139,24 @@ async function probeProjectPathDefaults(): Promise<void> {
|
|
|
2442
3139
|
if (!form || form.probeToken !== token) return;
|
|
2443
3140
|
form.projectPathIsGit = isGit;
|
|
2444
3141
|
|
|
3142
|
+
// (2b) Existing-linked-worktree detection. When the path is a
|
|
3143
|
+
// worktree created by `git worktree add` (not the repo's main
|
|
3144
|
+
// checkout), default the checkbox to *unchecked* so the
|
|
3145
|
+
// natural action is to attach to it. Only flip on the
|
|
3146
|
+
// detection transition so we don't fight a user who
|
|
3147
|
+
// deliberately re-checks "create a new worktree".
|
|
3148
|
+
const wasLinked = form.projectPathIsLinkedWorktree;
|
|
3149
|
+
if (isGit) {
|
|
3150
|
+
const info = await classifyWorktree(effectivePath);
|
|
3151
|
+
if (!form || form.probeToken !== token) return;
|
|
3152
|
+
form.projectPathIsLinkedWorktree = info?.isLinked === true;
|
|
3153
|
+
} else {
|
|
3154
|
+
form.projectPathIsLinkedWorktree = false;
|
|
3155
|
+
}
|
|
3156
|
+
if (form.projectPathIsLinkedWorktree && wasLinked !== true) {
|
|
3157
|
+
form.createWorktree = false;
|
|
3158
|
+
}
|
|
3159
|
+
|
|
2445
3160
|
// (3) Default branch + session name probes only make sense on
|
|
2446
3161
|
// a git path. On non-git, leave both empty (the renderer
|
|
2447
3162
|
// surfaces a "no git — N/A" branch placeholder, and the
|
|
@@ -2861,14 +3576,28 @@ async function submitForm(): Promise<void> {
|
|
|
2861
3576
|
editor.setGlobalState("orchestrator.last_cmd", cmd);
|
|
2862
3577
|
}
|
|
2863
3578
|
|
|
3579
|
+
// Attach-to-existing-worktree: when the user opted out of
|
|
3580
|
+
// creating a worktree but pointed Project Path at an *existing
|
|
3581
|
+
// linked worktree* (one created by `git worktree add`, possibly
|
|
3582
|
+
// for a repo Fresh has never opened before), treat it as the
|
|
3583
|
+
// dedicated worktree it is rather than a shared root. That means
|
|
3584
|
+
// `shared_worktree = false` (so Archive / Delete can
|
|
3585
|
+
// `git worktree move` / `remove` it) and a `project_path` of the
|
|
3586
|
+
// owning repo so the session groups with its siblings. A path
|
|
3587
|
+
// that's the repo's *main* worktree, or a non-git directory, stays
|
|
3588
|
+
// shared — you can't `git worktree remove` either of those.
|
|
3589
|
+
const attachInfo = !createWorktree ? await classifyWorktree(root) : null;
|
|
3590
|
+
if (!form) return;
|
|
3591
|
+
const isLinkedAttach = attachInfo?.isLinked === true;
|
|
3592
|
+
const effectiveProjectPath = isLinkedAttach ? attachInfo!.mainRoot : projectPath;
|
|
3593
|
+
|
|
2864
3594
|
// 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.
|
|
3595
|
+
// `branchName` only exists in the worktree-create flow above; for
|
|
3596
|
+
// an attached linked worktree we report its checked-out branch;
|
|
3597
|
+
// for the shared-worktree / non-git case we leave it blank.
|
|
2869
3598
|
const reportedBranch = createWorktree
|
|
2870
3599
|
? (branchInput || sessionName)
|
|
2871
|
-
: "";
|
|
3600
|
+
: (isLinkedAttach ? attachInfo!.branch : "");
|
|
2872
3601
|
|
|
2873
3602
|
// Append the user-effective values to per-field input
|
|
2874
3603
|
// history so ↑/↓ can recall them on the next form open.
|
|
@@ -2886,7 +3615,9 @@ async function submitForm(): Promise<void> {
|
|
|
2886
3615
|
// terminal IS the new window's seed buffer, so the window is
|
|
2887
3616
|
// born with a single tab.
|
|
2888
3617
|
const argv = splitAgentCmd(cmd);
|
|
2889
|
-
|
|
3618
|
+
// Shared only when we neither created a worktree nor attached to an
|
|
3619
|
+
// existing linked one (i.e. a non-git dir or the repo's main tree).
|
|
3620
|
+
const sharedWorktree = !createWorktree && !isLinkedAttach;
|
|
2890
3621
|
try {
|
|
2891
3622
|
const result = await editor.createWindowWithTerminal({
|
|
2892
3623
|
root,
|
|
@@ -2898,17 +3629,26 @@ async function submitForm(): Promise<void> {
|
|
|
2898
3629
|
const id = result.windowId;
|
|
2899
3630
|
// `createWindowWithTerminal` already dove into the new window,
|
|
2900
3631
|
// so `setWindowState` writes to it.
|
|
2901
|
-
editor.setWindowState("project_path",
|
|
3632
|
+
editor.setWindowState("project_path", effectiveProjectPath);
|
|
2902
3633
|
editor.setWindowState("shared_worktree", sharedWorktree);
|
|
3634
|
+
// If we attached to a worktree that was sitting in the picker as
|
|
3635
|
+
// a discovered row, drop that placeholder — this live window
|
|
3636
|
+
// supersedes it.
|
|
3637
|
+
const discId = discoveredIdByPath.get(root);
|
|
3638
|
+
if (discId !== undefined) {
|
|
3639
|
+
orchestratorSessions.delete(discId);
|
|
3640
|
+
discoveredIdByPath.delete(root);
|
|
3641
|
+
}
|
|
2903
3642
|
const tracked: AgentSession = {
|
|
2904
3643
|
id,
|
|
2905
3644
|
label: sessionName,
|
|
2906
3645
|
root,
|
|
2907
|
-
projectPath,
|
|
3646
|
+
projectPath: effectiveProjectPath,
|
|
2908
3647
|
sharedWorktree,
|
|
2909
3648
|
terminalId: result.terminalId,
|
|
2910
3649
|
state: "running",
|
|
2911
3650
|
createdAt: Date.now(),
|
|
3651
|
+
branch: reportedBranch || undefined,
|
|
2912
3652
|
};
|
|
2913
3653
|
orchestratorSessions.set(id, tracked);
|
|
2914
3654
|
} catch (e) {
|
|
@@ -2920,6 +3660,54 @@ async function submitForm(): Promise<void> {
|
|
|
2920
3660
|
}
|
|
2921
3661
|
}
|
|
2922
3662
|
|
|
3663
|
+
/// Open a session in an existing worktree without creating one —
|
|
3664
|
+
/// the dive action for a discovered row, and the building block the
|
|
3665
|
+
/// new-session form reuses when the user points Project Path at an
|
|
3666
|
+
/// existing linked worktree. Spawns a bare terminal (no agent
|
|
3667
|
+
/// command) rooted at the worktree, tags the window with its
|
|
3668
|
+
/// canonical project + `shared_worktree = false` so Archive / Delete
|
|
3669
|
+
/// manage it as the real worktree it is, then drops the discovered
|
|
3670
|
+
/// placeholder (the live window supersedes it).
|
|
3671
|
+
async function attachToWorktree(opts: {
|
|
3672
|
+
root: string;
|
|
3673
|
+
projectPath: string;
|
|
3674
|
+
label: string;
|
|
3675
|
+
branch?: string;
|
|
3676
|
+
discoveredId?: number;
|
|
3677
|
+
}): Promise<void> {
|
|
3678
|
+
try {
|
|
3679
|
+
const result = await editor.createWindowWithTerminal({
|
|
3680
|
+
root: opts.root,
|
|
3681
|
+
label: opts.label,
|
|
3682
|
+
cwd: opts.root,
|
|
3683
|
+
});
|
|
3684
|
+
const id = result.windowId;
|
|
3685
|
+
editor.setWindowState("project_path", opts.projectPath);
|
|
3686
|
+
editor.setWindowState("shared_worktree", false);
|
|
3687
|
+
if (opts.discoveredId !== undefined) {
|
|
3688
|
+
orchestratorSessions.delete(opts.discoveredId);
|
|
3689
|
+
discoveredIdByPath.delete(opts.root);
|
|
3690
|
+
}
|
|
3691
|
+
orchestratorSessions.set(id, {
|
|
3692
|
+
id,
|
|
3693
|
+
label: opts.label,
|
|
3694
|
+
root: opts.root,
|
|
3695
|
+
projectPath: opts.projectPath,
|
|
3696
|
+
sharedWorktree: false,
|
|
3697
|
+
terminalId: result.terminalId,
|
|
3698
|
+
state: "running",
|
|
3699
|
+
createdAt: Date.now(),
|
|
3700
|
+
branch: opts.branch,
|
|
3701
|
+
});
|
|
3702
|
+
} catch (e) {
|
|
3703
|
+
editor.setStatus(
|
|
3704
|
+
`Orchestrator: failed to attach session — ${
|
|
3705
|
+
e instanceof Error ? e.message : String(e)
|
|
3706
|
+
}`,
|
|
3707
|
+
);
|
|
3708
|
+
}
|
|
3709
|
+
}
|
|
3710
|
+
|
|
2923
3711
|
function startNewSession(): void {
|
|
2924
3712
|
if (form) return; // already open
|
|
2925
3713
|
openForm();
|
|
@@ -3148,7 +3936,27 @@ function enterConfirm(action: "stop" | "archive" | "delete"): void {
|
|
|
3148
3936
|
}
|
|
3149
3937
|
}
|
|
3150
3938
|
}
|
|
3151
|
-
openDialog.pendingConfirm = { action,
|
|
3939
|
+
openDialog.pendingConfirm = { action, ids: [id] };
|
|
3940
|
+
openPanel.update(buildOpenSpec());
|
|
3941
|
+
openPanel.setFocusKey("confirm-cancel");
|
|
3942
|
+
}
|
|
3943
|
+
|
|
3944
|
+
// Open the confirm panel for a *bulk* action over the current
|
|
3945
|
+
// checkbox selection. Filters to the eligible members up front (so
|
|
3946
|
+
// the confirm count matches what will actually run); refuses with a
|
|
3947
|
+
// banner when nothing is eligible.
|
|
3948
|
+
function enterBulkConfirm(action: BulkAction): void {
|
|
3949
|
+
if (!openDialog || !openPanel) return;
|
|
3950
|
+
const targets = eligibleSelected(action);
|
|
3951
|
+
if (targets.length === 0) {
|
|
3952
|
+
setDialogError(`no selected session can be ${action === "stop" ? "stopped" : action + "d"}`);
|
|
3953
|
+
refreshOpenDialog();
|
|
3954
|
+
return;
|
|
3955
|
+
}
|
|
3956
|
+
// All three actions confirm — even Stop, so a bulk Stop over a
|
|
3957
|
+
// large selection isn't a single mis-key away. The confirm panel
|
|
3958
|
+
// lists the targets and shows the eligible count.
|
|
3959
|
+
openDialog.pendingConfirm = { action, ids: targets };
|
|
3152
3960
|
openPanel.update(buildOpenSpec());
|
|
3153
3961
|
openPanel.setFocusKey("confirm-cancel");
|
|
3154
3962
|
}
|
|
@@ -3307,8 +4115,11 @@ editor.on("widget_event", (e) => {
|
|
|
3307
4115
|
// on the button. Snap focus back to Visit so the user can
|
|
3308
4116
|
// press Enter to open the newly-highlighted session — the
|
|
3309
4117
|
// dialog's whole reason for being. Idempotent when focus
|
|
3310
|
-
// is already on Visit.
|
|
3311
|
-
|
|
4118
|
+
// is already on Visit. Skipped in bulk mode and during a
|
|
4119
|
+
// confirm, where "visit" isn't in the spec.
|
|
4120
|
+
if (selectedSessions().length < 2 && !openDialog.pendingConfirm) {
|
|
4121
|
+
openPanel.setFocusKey("visit");
|
|
4122
|
+
}
|
|
3312
4123
|
}
|
|
3313
4124
|
return;
|
|
3314
4125
|
}
|
|
@@ -3317,6 +4128,20 @@ editor.on("widget_event", (e) => {
|
|
|
3317
4128
|
(e.widget_key === "sessions" || e.widget_key === "visit")
|
|
3318
4129
|
) {
|
|
3319
4130
|
const id = openDialog.filteredIds[openDialog.selectedIndex];
|
|
4131
|
+
const sel = typeof id === "number" ? orchestratorSessions.get(id) : undefined;
|
|
4132
|
+
if (sel && sel.discovered) {
|
|
4133
|
+
// Discovered worktree: there's no window to switch to —
|
|
4134
|
+
// open one by attaching a fresh session to the worktree.
|
|
4135
|
+
closeOpenDialog();
|
|
4136
|
+
void attachToWorktree({
|
|
4137
|
+
root: sel.root,
|
|
4138
|
+
projectPath: sel.projectPath ?? sel.root,
|
|
4139
|
+
label: sel.label,
|
|
4140
|
+
branch: sel.branch,
|
|
4141
|
+
discoveredId: sel.id,
|
|
4142
|
+
});
|
|
4143
|
+
return;
|
|
4144
|
+
}
|
|
3320
4145
|
if (typeof id === "number" && id > 0 && id !== editor.activeWindow()) {
|
|
3321
4146
|
editor.setActiveWindow(id);
|
|
3322
4147
|
}
|
|
@@ -3337,6 +4162,12 @@ editor.on("widget_event", (e) => {
|
|
|
3337
4162
|
refreshOpenDialog();
|
|
3338
4163
|
return;
|
|
3339
4164
|
}
|
|
4165
|
+
if (e.event_type === "toggle" && e.widget_key === "worktree-show") {
|
|
4166
|
+
// The toggle widget reports the new checked state; route through
|
|
4167
|
+
// the shared flip so the Alt+T chord and the click stay in sync.
|
|
4168
|
+
toggleShowWorktrees();
|
|
4169
|
+
return;
|
|
4170
|
+
}
|
|
3340
4171
|
if (e.event_type === "activate" && e.widget_key === "stop") {
|
|
3341
4172
|
enterConfirm("stop");
|
|
3342
4173
|
return;
|
|
@@ -3349,44 +4180,47 @@ editor.on("widget_event", (e) => {
|
|
|
3349
4180
|
enterConfirm("delete");
|
|
3350
4181
|
return;
|
|
3351
4182
|
}
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
4183
|
+
// Bulk action bar (Layout B) — Stop / Archive / Delete over the
|
|
4184
|
+
// checkbox selection, plus Clear.
|
|
4185
|
+
if (e.event_type === "activate" && e.widget_key === "bulk-stop") {
|
|
4186
|
+
enterBulkConfirm("stop");
|
|
3355
4187
|
return;
|
|
3356
4188
|
}
|
|
3357
|
-
if (e.event_type === "activate" && e.widget_key === "
|
|
3358
|
-
|
|
3359
|
-
stopSelectedSession();
|
|
3360
|
-
if (openPanel) openPanel.update(buildOpenSpec());
|
|
4189
|
+
if (e.event_type === "activate" && e.widget_key === "bulk-archive") {
|
|
4190
|
+
enterBulkConfirm("archive");
|
|
3361
4191
|
return;
|
|
3362
4192
|
}
|
|
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);
|
|
4193
|
+
if (e.event_type === "activate" && e.widget_key === "bulk-delete") {
|
|
4194
|
+
enterBulkConfirm("delete");
|
|
4195
|
+
return;
|
|
4196
|
+
}
|
|
4197
|
+
if (e.event_type === "activate" && e.widget_key === "bulk-clear") {
|
|
4198
|
+
openDialog.selectedIds.clear();
|
|
3377
4199
|
refreshOpenDialog();
|
|
4200
|
+
openPanel.setFocusKey("visit");
|
|
4201
|
+
return;
|
|
4202
|
+
}
|
|
4203
|
+
if (e.event_type === "activate" && e.widget_key === "confirm-cancel") {
|
|
4204
|
+
openDialog.pendingConfirm = null;
|
|
4205
|
+
openPanel.update(buildOpenSpec());
|
|
3378
4206
|
return;
|
|
3379
4207
|
}
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
4208
|
+
// Confirmed Stop / Archive / Delete — single row or bulk batch.
|
|
4209
|
+
// The ids were captured into `pendingConfirm` by enterConfirm /
|
|
4210
|
+
// enterBulkConfirm; `runConfirmedAction` re-checks eligibility,
|
|
4211
|
+
// drives the in-flight markers, and triggers sync.
|
|
4212
|
+
if (
|
|
4213
|
+
e.event_type === "activate" &&
|
|
4214
|
+
(e.widget_key === "confirm-stop" ||
|
|
4215
|
+
e.widget_key === "confirm-archive" ||
|
|
4216
|
+
e.widget_key === "confirm-delete")
|
|
4217
|
+
) {
|
|
4218
|
+
const confirm = openDialog.pendingConfirm;
|
|
4219
|
+
openDialog.pendingConfirm = null;
|
|
4220
|
+
if (confirm) {
|
|
4221
|
+
void runConfirmedAction(confirm.action, confirm.ids);
|
|
3387
4222
|
}
|
|
3388
|
-
|
|
3389
|
-
refreshOpenDialog();
|
|
4223
|
+
if (openPanel) openPanel.update(buildOpenSpec());
|
|
3390
4224
|
return;
|
|
3391
4225
|
}
|
|
3392
4226
|
if (e.event_type === "cancel") {
|