@fresh-editor/fresh-editor 0.3.7 → 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 +97 -0
- package/README.md +0 -1
- package/package.json +1 -1
- package/plugins/audit_mode.i18n.json +28 -0
- package/plugins/audit_mode.ts +34 -3
- package/plugins/config-schema.json +7 -0
- package/plugins/dashboard.ts +3 -3
- package/plugins/env-manager.ts +168 -0
- package/plugins/examples/bookmarks.ts +3 -2
- package/plugins/git_log.ts +58 -75
- package/plugins/lib/finder.ts +57 -10
- package/plugins/lib/fresh.d.ts +150 -3
- package/plugins/lib/widgets.ts +8 -0
- package/plugins/live_diff.ts +12 -17
- package/plugins/live_grep.i18n.json +349 -27
- package/plugins/live_grep.ts +596 -141
- package/plugins/orchestrator.ts +1506 -534
- package/plugins/pkg.ts +168 -3
- package/plugins/schemas/theme.schema.json +53 -14
- package/plugins/theme_editor.i18n.json +84 -84
- package/plugins/tsconfig.json +1 -0
- package/plugins/vi_mode.ts +3 -3
- package/themes/light.json +1 -1
- package/themes/terminal.json +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,26 +96,24 @@ interface AgentSession {
|
|
|
84
96
|
|
|
85
97
|
const orchestratorSessions = new Map<number, AgentSession>();
|
|
86
98
|
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
// the
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
| null = null;
|
|
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
|
+
}
|
|
107
117
|
|
|
108
118
|
// New-session form state. `null` ⇒ the floating form isn't
|
|
109
119
|
// open. Each field's `value` + `cursor` mirrors what the host
|
|
@@ -139,6 +149,14 @@ interface NewSessionForm {
|
|
|
139
149
|
// (checkbox disabled, branch field inert). `null`: probe
|
|
140
150
|
// in flight (keep checkbox in its last-known state).
|
|
141
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;
|
|
142
160
|
// Concrete session name the auto-generator would produce
|
|
143
161
|
// for the current Project Path (e.g. "session-3"). Surfaced
|
|
144
162
|
// as the Session Name placeholder so the user sees the
|
|
@@ -229,10 +247,34 @@ interface OpenDialogState {
|
|
|
229
247
|
// anchor it needs.
|
|
230
248
|
originalActiveSession: number;
|
|
231
249
|
// When non-null, the preview pane swaps to a confirmation
|
|
232
|
-
// panel for the named action against the
|
|
233
|
-
//
|
|
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.
|
|
234
255
|
pendingConfirm:
|
|
235
|
-
| { 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 }
|
|
236
278
|
| null;
|
|
237
279
|
// Rows the embed reserves and rows the sessions list shows.
|
|
238
280
|
// Captured once at dialog-open from the editor's viewport so
|
|
@@ -267,9 +309,30 @@ interface OpenDialogState {
|
|
|
267
309
|
// too easy to skip over when the user's eyes are on the dialog.
|
|
268
310
|
// Cleared on the next nav / filter change.
|
|
269
311
|
lastError: string | null;
|
|
312
|
+
// Which sessions the list foregrounds:
|
|
313
|
+
// - "current": only sessions belonging to the active window's
|
|
314
|
+
// project (the default — launching in project B shouldn't
|
|
315
|
+
// bury you under project A's sessions). A trailing affordance
|
|
316
|
+
// row advertises how many sessions live in other projects.
|
|
317
|
+
// - "all": every session, across every project, each row
|
|
318
|
+
// labeled with its project so cross-project rows are obvious.
|
|
319
|
+
// Toggled with the scope key (⌥P by default). The filter input
|
|
320
|
+
// always searches globally regardless of scope, so typing a name
|
|
321
|
+
// from another project still surfaces it.
|
|
322
|
+
scope: "current" | "all";
|
|
270
323
|
}
|
|
271
324
|
let openDialog: OpenDialogState | null = null;
|
|
272
325
|
let openPanel: FloatingWidgetPanel | null = null;
|
|
326
|
+
// Scope is remembered across opens of the picker (module state
|
|
327
|
+
// survives dialog close). Defaults to "all" so the picker opens
|
|
328
|
+
// showing every session; flipping it with the Project control / Alt+P
|
|
329
|
+
// updates this and the next open honours it.
|
|
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;
|
|
273
336
|
const OPEN_MODE = "orchestrator-open";
|
|
274
337
|
|
|
275
338
|
// =============================================================================
|
|
@@ -303,9 +366,120 @@ function reconcileSessions(): void {
|
|
|
303
366
|
if (s.shared_worktree != null) existing.sharedWorktree = s.shared_worktree;
|
|
304
367
|
}
|
|
305
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`.
|
|
306
374
|
for (const id of orchestratorSessions.keys()) {
|
|
307
|
-
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);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
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;
|
|
308
481
|
}
|
|
482
|
+
if (openPanel) refreshOpenDialog();
|
|
309
483
|
}
|
|
310
484
|
|
|
311
485
|
// =============================================================================
|
|
@@ -346,14 +520,90 @@ function ageString(createdAt: number): string {
|
|
|
346
520
|
// The picker is cross-project by design — every session is a
|
|
347
521
|
// candidate regardless of which project the active window
|
|
348
522
|
// points at — so there is no project-scope filter here.
|
|
523
|
+
// Project a session belongs to, as a comparison key. Prefer the
|
|
524
|
+
// canonical `projectPath` recorded at create time; fall back to
|
|
525
|
+
// the session root for sessions that predate the field (the base
|
|
526
|
+
// session, externally-created windows).
|
|
527
|
+
function projectKeyOf(s: AgentSession): string {
|
|
528
|
+
return s.projectPath ?? s.root;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// The project the user is currently "in" — the active window's
|
|
532
|
+
// project. Falls back to the editor cwd when the active window
|
|
533
|
+
// isn't a tracked session (shouldn't normally happen, but keeps
|
|
534
|
+
// scoping well-defined).
|
|
535
|
+
function currentProjectKey(): string {
|
|
536
|
+
const s = orchestratorSessions.get(editor.activeWindow());
|
|
537
|
+
return s ? projectKeyOf(s) : editor.getCwd();
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Short, human-readable label for a project key — the trailing
|
|
541
|
+
// `parent/base` of the path, matching the new-session form's
|
|
542
|
+
// `deriveProjectLabel` style.
|
|
543
|
+
function projectLabel(key: string): string {
|
|
544
|
+
const base = editor.pathBasename(key);
|
|
545
|
+
const parent = editor.pathBasename(editor.pathDirname(key));
|
|
546
|
+
if (parent && parent !== base) return `${parent}/${base}`;
|
|
547
|
+
return base || key;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Resolve the id list for the current filter + scope.
|
|
551
|
+
//
|
|
552
|
+
// Scope only constrains the *empty-filter* view: with no needle
|
|
553
|
+
// and `scope === "current"`, the list shows just the active
|
|
554
|
+
// project's sessions (current project first, by id). As soon as
|
|
555
|
+
// the user types, the search goes global regardless of scope —
|
|
556
|
+
// hiding a session the user is explicitly searching for would be
|
|
557
|
+
// the worse surprise. `scope === "all"` always shows everything,
|
|
558
|
+
// sorted by project (current project first) so rows are grouped
|
|
559
|
+
// rather than interleaved.
|
|
349
560
|
function filterSessions(needle: string): number[] {
|
|
350
561
|
reconcileSessions();
|
|
351
|
-
const
|
|
352
|
-
|
|
562
|
+
const scope = openDialog?.scope ?? "current";
|
|
563
|
+
const showWorktrees = openDialog?.showWorktrees ?? false;
|
|
564
|
+
const cur = currentProjectKey();
|
|
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;
|
|
574
|
+
|
|
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.
|
|
580
|
+
const byProjectThenId = (a: number, b: number): number => {
|
|
581
|
+
const sa = orchestratorSessions.get(a)!;
|
|
582
|
+
const sb = orchestratorSessions.get(b)!;
|
|
583
|
+
const aCur = projectKeyOf(sa) === cur ? 0 : 1;
|
|
584
|
+
const bCur = projectKeyOf(sb) === cur ? 0 : 1;
|
|
585
|
+
if (aCur !== bCur) return aCur - bCur;
|
|
586
|
+
const ka = projectKeyOf(sa);
|
|
587
|
+
const kb = projectKeyOf(sb);
|
|
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;
|
|
592
|
+
return a - b;
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
if (!needle) {
|
|
596
|
+
const ids = allIds.slice().sort(byProjectThenId);
|
|
597
|
+
if (scope === "current") {
|
|
598
|
+
return ids.filter((id) => projectKeyOf(orchestratorSessions.get(id)!) === cur);
|
|
599
|
+
}
|
|
600
|
+
return ids;
|
|
601
|
+
}
|
|
602
|
+
|
|
353
603
|
const n = needle.toLowerCase();
|
|
354
604
|
type Scored = { id: number; score: number; len: number };
|
|
355
605
|
const matches: Scored[] = [];
|
|
356
|
-
for (const id of
|
|
606
|
+
for (const id of allIds) {
|
|
357
607
|
const s = orchestratorSessions.get(id)!;
|
|
358
608
|
const label = s.label.toLowerCase();
|
|
359
609
|
const root = s.root.toLowerCase();
|
|
@@ -365,53 +615,105 @@ function filterSessions(needle: string): number[] {
|
|
|
365
615
|
matches.push({ id, score: 2, len: label.length });
|
|
366
616
|
}
|
|
367
617
|
}
|
|
368
|
-
|
|
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
|
+
);
|
|
369
625
|
return matches.map((m) => m.id);
|
|
370
626
|
}
|
|
371
627
|
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
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;
|
|
635
|
+
|
|
636
|
+
// Header row above the session list: `NAME … PROJECT`.
|
|
637
|
+
function sessionsColumnHeader(): WidgetSpec {
|
|
638
|
+
return {
|
|
639
|
+
kind: "raw",
|
|
640
|
+
entries: [
|
|
641
|
+
styledRow([
|
|
642
|
+
{
|
|
643
|
+
// 4-space lead aligns under the per-row `[ ] ` checkbox.
|
|
644
|
+
text: " " + "NAME".padEnd(LIST_NAME_W) + "PROJECT",
|
|
645
|
+
style: { fg: "ui.menu_disabled_fg" },
|
|
646
|
+
},
|
|
647
|
+
]),
|
|
648
|
+
],
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
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.
|
|
377
658
|
function renderListItem(id: number, activeId: number): TextPropertyEntry {
|
|
378
659
|
const s = orchestratorSessions.get(id);
|
|
379
660
|
if (!s) {
|
|
380
|
-
return styledRow([{ text:
|
|
661
|
+
return styledRow([{ text: "(unknown)" }]);
|
|
381
662
|
}
|
|
382
663
|
const isActive = id === activeId;
|
|
383
664
|
const isBase = id === 1;
|
|
384
|
-
const
|
|
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
|
+
};
|
|
677
|
+
|
|
385
678
|
const entries: { text: string; style?: Record<string, unknown> }[] = [
|
|
386
|
-
|
|
679
|
+
checkbox,
|
|
387
680
|
{
|
|
388
|
-
text:
|
|
681
|
+
text: s.label,
|
|
389
682
|
style: isActive
|
|
390
|
-
? { fg: "ui.
|
|
391
|
-
:
|
|
683
|
+
? { fg: "ui.help_key_fg", bold: true }
|
|
684
|
+
: isDiscovered
|
|
685
|
+
? { fg: "ui.menu_disabled_fg" }
|
|
686
|
+
: undefined,
|
|
392
687
|
},
|
|
393
|
-
{ text: ` ${s.label}` },
|
|
394
688
|
];
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
|
|
398
|
-
// badge makes that special status visible up-front instead of
|
|
399
|
-
// surfacing as "Archive disabled, why?" after a Tab.
|
|
689
|
+
// Visible width of the NAME column so far (label + badges), used
|
|
690
|
+
// to pad out to LIST_NAME_W before the PROJECT column.
|
|
691
|
+
let nameWidth = s.label.length;
|
|
400
692
|
if (isBase) {
|
|
693
|
+
entries.push({ text: " BASE", style: { fg: "ui.help_key_fg", bold: true } });
|
|
694
|
+
nameWidth += 5;
|
|
695
|
+
}
|
|
696
|
+
if (s.sharedWorktree || countSiblingsAtRoot(s.root) > 1) {
|
|
697
|
+
entries.push({ text: " ⇄", style: { fg: "ui.menu_disabled_fg" } });
|
|
698
|
+
nameWidth += 2;
|
|
699
|
+
}
|
|
700
|
+
if (isDiscovered) {
|
|
401
701
|
entries.push({
|
|
402
|
-
text: "
|
|
403
|
-
style: { fg: "ui.
|
|
702
|
+
text: " · on-disk",
|
|
703
|
+
style: { fg: "ui.menu_disabled_fg", italic: true },
|
|
404
704
|
});
|
|
705
|
+
nameWidth += 10;
|
|
405
706
|
}
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
707
|
+
// PROJECT column: basename for cross-project rows only; current-
|
|
708
|
+
// project rows leave it blank (the whole list is one project when
|
|
709
|
+
// scoped, so this column is empty then).
|
|
710
|
+
const proj = projectKeyOf(s);
|
|
711
|
+
if (proj !== currentProjectKey()) {
|
|
712
|
+
const pad = Math.max(1, LIST_NAME_W - nameWidth);
|
|
713
|
+
entries.push({ text: " ".repeat(pad) });
|
|
412
714
|
entries.push({
|
|
413
|
-
text:
|
|
414
|
-
style: { fg: "ui.menu_disabled_fg" },
|
|
715
|
+
text: editor.pathBasename(proj),
|
|
716
|
+
style: { fg: "ui.menu_disabled_fg", italic: true },
|
|
415
717
|
});
|
|
416
718
|
}
|
|
417
719
|
return styledRow(entries as Parameters<typeof styledRow>[0]);
|
|
@@ -446,7 +748,7 @@ function buildPreviewEntries(
|
|
|
446
748
|
{
|
|
447
749
|
text: stateText,
|
|
448
750
|
style: isActive
|
|
449
|
-
? { fg: "ui.
|
|
751
|
+
? { fg: "ui.help_key_fg", bold: true }
|
|
450
752
|
: { fg: "ui.menu_disabled_fg" },
|
|
451
753
|
},
|
|
452
754
|
{ text: " " },
|
|
@@ -508,26 +810,98 @@ function countSiblingsAtRoot(root: string): number {
|
|
|
508
810
|
return n;
|
|
509
811
|
}
|
|
510
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
|
+
|
|
511
879
|
// Blank-row separator used inside the Sessions column between
|
|
512
880
|
// the filter, the new-session button, and the list.
|
|
513
881
|
function sessionsSeparator(): WidgetSpec {
|
|
514
882
|
return spacer(0);
|
|
515
883
|
}
|
|
516
884
|
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
|
|
521
|
-
|
|
885
|
+
// Smallest list height we'll show even when there are only a
|
|
886
|
+
// couple of sessions — keeps the preview pane (which matches the
|
|
887
|
+
// list height) usable rather than collapsing to a sliver.
|
|
888
|
+
const MIN_LIST_ROWS = 6;
|
|
889
|
+
|
|
890
|
+
// Upper bound on session rows for this terminal — the list height
|
|
891
|
+
// when the panel is at its full `heightPct: 90` budget. Sized off
|
|
892
|
+
// the full terminal (not the active buffer's viewport — that
|
|
893
|
+
// shrinks with vertical splits and made the picker collapse to
|
|
894
|
+
// ~half its budget).
|
|
895
|
+
function maxListRowsForScreen(): number {
|
|
522
896
|
const screen = editor.getScreenSize();
|
|
523
897
|
const h = screen.height > 0 ? screen.height : 30;
|
|
524
898
|
const panelH = Math.floor(h * 0.9);
|
|
525
|
-
// panel borders (2) +
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
// shows something.
|
|
530
|
-
return Math.max(
|
|
899
|
+
// Chrome that isn't list rows: panel borders (2) + title (1) +
|
|
900
|
+
// spacer (1) + footer (1) + sessions-section borders (2) +
|
|
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);
|
|
531
905
|
}
|
|
532
906
|
|
|
533
907
|
// Compose the right-hand preview pane. Normally it shows info
|
|
@@ -572,130 +946,28 @@ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
|
|
|
572
946
|
),
|
|
573
947
|
});
|
|
574
948
|
}
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
text: `Stop session [${s.id}] ${s.label}?`,
|
|
587
|
-
style: { bold: true },
|
|
588
|
-
},
|
|
589
|
-
]),
|
|
590
|
-
styledRow([{ text: "" }]),
|
|
591
|
-
styledRow([{ text: "This will:" }]),
|
|
592
|
-
styledRow([{ text: " • send SIGTERM to all session processes" }]),
|
|
593
|
-
styledRow([{ text: " • SIGKILL after a short grace period" }]),
|
|
594
|
-
styledRow([{ text: "" }]),
|
|
595
|
-
styledRow([{ text: "The worktree and session record remain." }]),
|
|
596
|
-
],
|
|
597
|
-
},
|
|
598
|
-
spacer(0),
|
|
599
|
-
row(
|
|
600
|
-
flexSpacer(),
|
|
601
|
-
button("Cancel", { key: "confirm-cancel" }),
|
|
602
|
-
spacer(2),
|
|
603
|
-
button("Confirm Stop", {
|
|
604
|
-
intent: "danger",
|
|
605
|
-
key: "confirm-stop",
|
|
606
|
-
}),
|
|
607
|
-
),
|
|
608
|
-
),
|
|
609
|
-
});
|
|
610
|
-
}
|
|
611
|
-
if (action === "archive") {
|
|
612
|
-
return labeledSection({
|
|
613
|
-
label: "Confirm Archive",
|
|
614
|
-
child: col(
|
|
615
|
-
{
|
|
616
|
-
kind: "raw",
|
|
617
|
-
entries: [
|
|
618
|
-
styledRow([
|
|
619
|
-
{
|
|
620
|
-
text: `Archive session [${s.id}] ${s.label}?`,
|
|
621
|
-
style: { bold: true },
|
|
622
|
-
},
|
|
623
|
-
]),
|
|
624
|
-
styledRow([{ text: "" }]),
|
|
625
|
-
styledRow([{ text: "This will:" }]),
|
|
626
|
-
styledRow([{ text: " • SIGKILL all session processes" }]),
|
|
627
|
-
styledRow([{ text: " • close the editor session" }]),
|
|
628
|
-
styledRow([{ text: " • move the worktree to .archived/" }]),
|
|
629
|
-
styledRow([{ text: "" }]),
|
|
630
|
-
styledRow([{ text: "Reversible via Unarchive." }]),
|
|
631
|
-
],
|
|
632
|
-
},
|
|
633
|
-
spacer(0),
|
|
634
|
-
row(
|
|
635
|
-
flexSpacer(),
|
|
636
|
-
button("Cancel", { key: "confirm-cancel" }),
|
|
637
|
-
spacer(2),
|
|
638
|
-
button("Confirm Archive", {
|
|
639
|
-
intent: "danger",
|
|
640
|
-
key: "confirm-archive",
|
|
641
|
-
}),
|
|
642
|
-
),
|
|
643
|
-
),
|
|
644
|
-
});
|
|
645
|
-
}
|
|
646
|
-
if (action === "delete") {
|
|
647
|
-
return labeledSection({
|
|
648
|
-
label: "Confirm Delete",
|
|
649
|
-
child: col(
|
|
650
|
-
{
|
|
651
|
-
kind: "raw",
|
|
652
|
-
entries: [
|
|
653
|
-
styledRow([
|
|
654
|
-
{
|
|
655
|
-
text: `Delete session [${s.id}] ${s.label}?`,
|
|
656
|
-
style: { bold: true },
|
|
657
|
-
},
|
|
658
|
-
]),
|
|
659
|
-
styledRow([{ text: "" }]),
|
|
660
|
-
styledRow([{ text: "This will:" }]),
|
|
661
|
-
styledRow([{ text: " • stop all session processes" }]),
|
|
662
|
-
styledRow([{ text: " • run `git worktree remove`" }]),
|
|
663
|
-
styledRow([{ text: " • drop the session record" }]),
|
|
664
|
-
styledRow([{ text: "" }]),
|
|
665
|
-
styledRow([
|
|
666
|
-
{
|
|
667
|
-
text: "Uncommitted changes will be lost.",
|
|
668
|
-
style: {
|
|
669
|
-
fg: "ui.status_error_indicator_fg",
|
|
670
|
-
bold: true,
|
|
671
|
-
},
|
|
672
|
-
},
|
|
673
|
-
]),
|
|
674
|
-
],
|
|
675
|
-
},
|
|
676
|
-
spacer(0),
|
|
677
|
-
row(
|
|
678
|
-
flexSpacer(),
|
|
679
|
-
button("Cancel", { key: "confirm-cancel" }),
|
|
680
|
-
spacer(2),
|
|
681
|
-
button("Confirm Delete", {
|
|
682
|
-
intent: "danger",
|
|
683
|
-
key: "confirm-delete",
|
|
684
|
-
}),
|
|
685
|
-
),
|
|
686
|
-
),
|
|
687
|
-
});
|
|
688
|
-
}
|
|
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();
|
|
689
960
|
}
|
|
690
961
|
// Match the sessions column's content height so the two panes'
|
|
691
|
-
// bottom borders land on the same row. Sessions column inside
|
|
692
|
-
//
|
|
693
|
-
// (1) + separator (1) +
|
|
694
|
-
// +
|
|
695
|
-
// + embedRows, so embedRows must equal
|
|
696
|
-
// When details ARE shown, two info rows + a
|
|
697
|
-
// more lines — `_DETAILS_CHROME_ROWS` accounts
|
|
698
|
-
|
|
962
|
+
// bottom borders land on the same row. Sessions column inside its
|
|
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;
|
|
699
971
|
const detailsOn = openDialog?.showDetails ?? false;
|
|
700
972
|
const _DETAILS_CHROME_ROWS = 3; // 2 info rows + 1 spacer
|
|
701
973
|
const embedRows = Math.max(
|
|
@@ -728,6 +1000,48 @@ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
|
|
|
728
1000
|
// turns details on, pressing `[ Preview ]` turns them off
|
|
729
1001
|
// (back to compact).
|
|
730
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
|
+
}
|
|
731
1045
|
// Per-action availability. The row always renders all four
|
|
732
1046
|
// buttons (no layout shift between selections), but each is
|
|
733
1047
|
// marked disabled when its action would be refused against the
|
|
@@ -783,43 +1097,243 @@ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
|
|
|
783
1097
|
// its worktree would close the editor / break the user's current
|
|
784
1098
|
// tree, so Stop / Archive / Delete refuse against it.
|
|
785
1099
|
const sectionLabel = isBase
|
|
786
|
-
?
|
|
787
|
-
:
|
|
1100
|
+
? `${s.label} — BASE (editor session)`
|
|
1101
|
+
: s.label;
|
|
788
1102
|
return labeledSection({
|
|
789
1103
|
label: sectionLabel,
|
|
790
1104
|
child: body,
|
|
791
1105
|
});
|
|
792
1106
|
}
|
|
793
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
|
+
|
|
794
1319
|
function buildOpenSpec(): WidgetSpec {
|
|
795
1320
|
if (!openDialog) return col();
|
|
796
|
-
// Re-derive row counts on every spec build as a fallback for the
|
|
797
|
-
// resize hook not always firing reliably through tmux's SIGWINCH
|
|
798
|
-
// propagation (Finding I). **One-way ratchet**: only adopt the
|
|
799
|
-
// new value when it's *larger* than the current one. The
|
|
800
|
-
// `editor.getViewport()` height shrinks while the picker is
|
|
801
|
-
// mounted (the floating panel covers part of the buffer area),
|
|
802
|
-
// and naively re-reading it on every refresh fed that shrink
|
|
803
|
-
// back into the dialog size — pressing Up/Down caused the
|
|
804
|
-
// picker to oscillate smaller on every keystroke. A real
|
|
805
|
-
// terminal-grow event still flows through because the new
|
|
806
|
-
// viewport height exceeds the cached value; a spurious shrink
|
|
807
|
-
// (because the panel itself is up) is ignored.
|
|
808
|
-
const liveListVisibleRows = openListVisibleRows();
|
|
809
|
-
if (liveListVisibleRows > openDialog.listVisibleRows) {
|
|
810
|
-
openDialog.listVisibleRows = liveListVisibleRows;
|
|
811
|
-
openDialog.embedRows = Math.max(3, liveListVisibleRows - 5);
|
|
812
|
-
}
|
|
813
1321
|
const filtered = openDialog.filteredIds;
|
|
1322
|
+
// Fill the panel's full height budget (the list pads with blank
|
|
1323
|
+
// rows when there are few sessions) so the dialog stays
|
|
1324
|
+
// vertically full rather than collapsing to a short floating box.
|
|
1325
|
+
openDialog.listVisibleRows = maxListRowsForScreen();
|
|
814
1326
|
const activeId = editor.activeWindow();
|
|
815
1327
|
const items = filtered.map((id) => renderListItem(id, activeId));
|
|
816
1328
|
const itemKeys = filtered.map(String);
|
|
817
1329
|
const selIdx = filtered.length === 0
|
|
818
1330
|
? -1
|
|
819
1331
|
: Math.max(0, Math.min(openDialog.selectedIndex, filtered.length - 1));
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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])
|
|
823
1337
|
: undefined;
|
|
824
1338
|
|
|
825
1339
|
// The "New Session" button advertises Alt+N (or whatever the
|
|
@@ -838,9 +1352,7 @@ function buildOpenSpec(): WidgetSpec {
|
|
|
838
1352
|
"orchestrator_open_new_from_picker",
|
|
839
1353
|
OPEN_MODE,
|
|
840
1354
|
);
|
|
841
|
-
const newLabel = newKey
|
|
842
|
-
? `+ New Session ${newKey}`
|
|
843
|
-
: "+ New Session";
|
|
1355
|
+
const newLabel = newKey ? `+ New ${newKey}` : "+ New";
|
|
844
1356
|
const inConfirm = openDialog.pendingConfirm !== null;
|
|
845
1357
|
// While a confirmation prompt is up the filter is rendered
|
|
846
1358
|
// without a `key`. The host's `collect_tabbable` only adds
|
|
@@ -853,7 +1365,8 @@ function buildOpenSpec(): WidgetSpec {
|
|
|
853
1365
|
const filterInput = text({
|
|
854
1366
|
value: openDialog.filter.value,
|
|
855
1367
|
cursorByte: openDialog.filter.cursor,
|
|
856
|
-
|
|
1368
|
+
label: "Filter",
|
|
1369
|
+
placeholder: "type to search… ( / )",
|
|
857
1370
|
fullWidth: true,
|
|
858
1371
|
key: inConfirm ? undefined : "filter",
|
|
859
1372
|
});
|
|
@@ -874,6 +1387,55 @@ function buildOpenSpec(): WidgetSpec {
|
|
|
874
1387
|
],
|
|
875
1388
|
}
|
|
876
1389
|
: null;
|
|
1390
|
+
|
|
1391
|
+
// Scope chrome. The title keeps the active project visible; the
|
|
1392
|
+
// `Project:` control below is the clickable scope switch.
|
|
1393
|
+
const scope = openDialog.scope;
|
|
1394
|
+
const curKey = currentProjectKey();
|
|
1395
|
+
const curName = projectLabel(curKey);
|
|
1396
|
+
const scopeKey = editor.getKeybindingLabel("orchestrator_toggle_scope", OPEN_MODE);
|
|
1397
|
+
const titleSuffix = scope === "current" ? ` — ${curName}` : " — all projects";
|
|
1398
|
+
const sectionLabel = "Sessions";
|
|
1399
|
+
// `Project:` control — a visible, clickable scope switch with the
|
|
1400
|
+
// Alt+P hint baked into the button label. Shows the current
|
|
1401
|
+
// project's name when scoped, "All" when showing every project.
|
|
1402
|
+
// Inert while a confirm prompt is up so it can't steal focus.
|
|
1403
|
+
const scopeWord = scope === "current" ? editor.pathBasename(curKey) : "All";
|
|
1404
|
+
const scopeButtonLabel = scopeKey ? `${scopeWord} ▾ (${scopeKey})` : `${scopeWord} ▾`;
|
|
1405
|
+
const scopeButton = button(scopeButtonLabel, {
|
|
1406
|
+
key: openDialog.pendingConfirm !== null ? undefined : "scope-toggle",
|
|
1407
|
+
});
|
|
1408
|
+
const projectControlRow = row(
|
|
1409
|
+
{
|
|
1410
|
+
kind: "raw",
|
|
1411
|
+
entries: [
|
|
1412
|
+
styledRow([{ text: "Project: ", style: { fg: "ui.menu_disabled_fg" } }]),
|
|
1413
|
+
],
|
|
1414
|
+
},
|
|
1415
|
+
scopeButton,
|
|
1416
|
+
flexSpacer(),
|
|
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
|
+
);
|
|
1438
|
+
|
|
877
1439
|
return col(
|
|
878
1440
|
{
|
|
879
1441
|
kind: "raw",
|
|
@@ -883,6 +1445,10 @@ function buildOpenSpec(): WidgetSpec {
|
|
|
883
1445
|
text: "ORCHESTRATOR :: Sessions",
|
|
884
1446
|
style: { fg: "ui.popup_border_fg", bold: true },
|
|
885
1447
|
},
|
|
1448
|
+
{
|
|
1449
|
+
text: titleSuffix,
|
|
1450
|
+
style: { fg: "ui.menu_disabled_fg" },
|
|
1451
|
+
},
|
|
886
1452
|
]),
|
|
887
1453
|
],
|
|
888
1454
|
},
|
|
@@ -896,14 +1462,18 @@ function buildOpenSpec(): WidgetSpec {
|
|
|
896
1462
|
// the dialog.
|
|
897
1463
|
row(
|
|
898
1464
|
labeledSection({
|
|
899
|
-
label:
|
|
900
|
-
|
|
901
|
-
//
|
|
902
|
-
//
|
|
903
|
-
//
|
|
904
|
-
|
|
905
|
-
//
|
|
906
|
-
//
|
|
1465
|
+
label: sectionLabel,
|
|
1466
|
+
// 34% (was 25%): wide enough that the per-row project tag in
|
|
1467
|
+
// the all-projects view (`· <project>`) and longer session
|
|
1468
|
+
// labels render without truncating to `· tmp_o…`. The preview
|
|
1469
|
+
// pane still keeps the majority for the live window embed.
|
|
1470
|
+
widthPct: 34,
|
|
1471
|
+
// Sessions column: New button, Project (scope) control,
|
|
1472
|
+
// Filter, separator, column header, list. The button is
|
|
1473
|
+
// first so it gets initial focus (Enter immediately opens the
|
|
1474
|
+
// new session form). Separators are long `─` strings that the
|
|
1475
|
+
// renderer truncates to the column's inner width — no need to
|
|
1476
|
+
// measure cells from the plugin side.
|
|
907
1477
|
child: col(
|
|
908
1478
|
row(
|
|
909
1479
|
button(newLabel, {
|
|
@@ -918,13 +1488,20 @@ function buildOpenSpec(): WidgetSpec {
|
|
|
918
1488
|
}),
|
|
919
1489
|
flexSpacer(),
|
|
920
1490
|
),
|
|
921
|
-
|
|
1491
|
+
projectControlRow,
|
|
1492
|
+
worktreeFilterRow,
|
|
922
1493
|
filterInput,
|
|
923
1494
|
sessionsSeparator(),
|
|
1495
|
+
sessionsColumnHeader(),
|
|
924
1496
|
list({
|
|
925
1497
|
items,
|
|
926
1498
|
itemKeys,
|
|
927
1499
|
selectedIndex: selIdx,
|
|
1500
|
+
// `listVisibleRows` is the fitted list height; the 5 rows
|
|
1501
|
+
// of column chrome above it (New / Project / Filter /
|
|
1502
|
+
// separator / header) and the matching preview embed are
|
|
1503
|
+
// accounted for separately so both panes stay the same
|
|
1504
|
+
// height and the footer hint stays on-screen.
|
|
928
1505
|
visibleRows: openDialog.listVisibleRows,
|
|
929
1506
|
// Excluded from the Tab cycle — Up/Down on the
|
|
930
1507
|
// filter input forwards to this list via host
|
|
@@ -944,8 +1521,7 @@ function buildOpenSpec(): WidgetSpec {
|
|
|
944
1521
|
),
|
|
945
1522
|
}),
|
|
946
1523
|
// Preview pane has no explicit width — picks up the
|
|
947
|
-
// remaining
|
|
948
|
-
// 25%.
|
|
1524
|
+
// remaining width by default since the sessions list took 34%.
|
|
949
1525
|
buildPreviewPane(selectedSession),
|
|
950
1526
|
),
|
|
951
1527
|
row(
|
|
@@ -953,6 +1529,15 @@ function buildOpenSpec(): WidgetSpec {
|
|
|
953
1529
|
hintBar([
|
|
954
1530
|
{ keys: "↑↓", label: "nav" },
|
|
955
1531
|
{ keys: "Enter", label: "dive" },
|
|
1532
|
+
{
|
|
1533
|
+
keys: editor.getKeybindingLabel("orchestrator_toggle_select", OPEN_MODE) ||
|
|
1534
|
+
"Space",
|
|
1535
|
+
label: "select",
|
|
1536
|
+
},
|
|
1537
|
+
{
|
|
1538
|
+
keys: scopeKey || "⌥P",
|
|
1539
|
+
label: scope === "current" ? "all projects" : "current only",
|
|
1540
|
+
},
|
|
956
1541
|
{ keys: "Tab", label: "focus" },
|
|
957
1542
|
{ keys: "Esc", label: "close" },
|
|
958
1543
|
]),
|
|
@@ -1009,6 +1594,7 @@ function clearDialogError(): void {
|
|
|
1009
1594
|
|
|
1010
1595
|
function refreshOpenDialog(): void {
|
|
1011
1596
|
if (!openPanel || !openDialog) return;
|
|
1597
|
+
pruneSelection();
|
|
1012
1598
|
openDialog.filteredIds = filterSessions(openDialog.filter.value);
|
|
1013
1599
|
// Clamp the selection into range so a fresh filter or a
|
|
1014
1600
|
// session vanishing under us doesn't leave us pointing past
|
|
@@ -1032,7 +1618,9 @@ function openControlRoom(): void {
|
|
|
1032
1618
|
if (openPanel) return;
|
|
1033
1619
|
reconcileSessions();
|
|
1034
1620
|
const activeId = editor.activeWindow();
|
|
1035
|
-
|
|
1621
|
+
// Seed with the screen-max; buildOpenSpec refits to the session
|
|
1622
|
+
// count on the first render (and every render after).
|
|
1623
|
+
const listVisibleRows = maxListRowsForScreen();
|
|
1036
1624
|
openDialog = {
|
|
1037
1625
|
filter: { value: "", cursor: 0 },
|
|
1038
1626
|
filteredIds: [],
|
|
@@ -1040,19 +1628,16 @@ function openControlRoom(): void {
|
|
|
1040
1628
|
originalActiveSession: activeId,
|
|
1041
1629
|
pendingConfirm: null,
|
|
1042
1630
|
listVisibleRows,
|
|
1043
|
-
|
|
1044
|
-
// + 2 info rows + 1 spacer = 4 rows reserved above the embed.
|
|
1045
|
-
// Preview chrome above the embed: 1 button row + 1 spacer + 2
|
|
1046
|
-
// info rows + 1 spacer = 5 rows. The labeledSection's top/bottom
|
|
1047
|
-
// borders match the sessions list's, so subtracting just the
|
|
1048
|
-
// chrome makes the preview pane's apparent height match the
|
|
1049
|
-
// list pane's (`visible_rows + 2 borders`) exactly. Floored at
|
|
1050
|
-
// 3 so a tiny terminal still leaves enough rows for the embed
|
|
1051
|
-
// to paint something meaningful.
|
|
1052
|
-
embedRows: Math.max(3, listVisibleRows - 5),
|
|
1631
|
+
embedRows: Math.max(3, listVisibleRows + 3),
|
|
1053
1632
|
showDetails: false,
|
|
1054
1633
|
inFlight: null,
|
|
1055
1634
|
lastError: null,
|
|
1635
|
+
// Restore the last-used scope (defaults to "all"); the Project
|
|
1636
|
+
// control / Alt+P updates it for next time.
|
|
1637
|
+
scope: lastOpenScope,
|
|
1638
|
+
selectedIds: new Set<number>(),
|
|
1639
|
+
showWorktrees: lastShowWorktrees,
|
|
1640
|
+
bulkInFlight: null,
|
|
1056
1641
|
};
|
|
1057
1642
|
openDialog.filteredIds = filterSessions("");
|
|
1058
1643
|
const activeIdx = openDialog.filteredIds.indexOf(activeId);
|
|
@@ -1074,6 +1659,12 @@ function openControlRoom(): void {
|
|
|
1074
1659
|
// safe — there's nothing to act on then anyway.
|
|
1075
1660
|
openPanel.setFocusKey("visit");
|
|
1076
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();
|
|
1077
1668
|
}
|
|
1078
1669
|
|
|
1079
1670
|
function closeOpenDialog(): void {
|
|
@@ -1085,33 +1676,27 @@ function closeOpenDialog(): void {
|
|
|
1085
1676
|
editor.setEditorMode(null);
|
|
1086
1677
|
}
|
|
1087
1678
|
|
|
1088
|
-
// Stop every process
|
|
1089
|
-
//
|
|
1090
|
-
//
|
|
1091
|
-
//
|
|
1092
|
-
//
|
|
1093
|
-
//
|
|
1094
|
-
// it
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
const
|
|
1098
|
-
if (
|
|
1099
|
-
if (id === 1) {
|
|
1100
|
-
setDialogError("cannot stop the base session");
|
|
1101
|
-
refreshOpenDialog();
|
|
1102
|
-
return;
|
|
1103
|
-
}
|
|
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;
|
|
1104
1690
|
editor.signalWindow(id, "SIGTERM");
|
|
1105
|
-
// SIGKILL fallback for agents that ignore SIGTERM. The
|
|
1106
|
-
//
|
|
1107
|
-
//
|
|
1108
|
-
//
|
|
1109
|
-
// 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
|
|
1110
1695
|
// sleep primitive, which we kick off but don't await.
|
|
1111
1696
|
void editor.delay(2000).then(() => {
|
|
1112
1697
|
editor.signalWindow(id, "SIGKILL");
|
|
1113
1698
|
});
|
|
1114
|
-
|
|
1699
|
+
return true;
|
|
1115
1700
|
}
|
|
1116
1701
|
|
|
1117
1702
|
// ---------------------------------------------------------------------
|
|
@@ -1194,136 +1779,97 @@ function pickNextActiveSession(excludeId: number): number {
|
|
|
1194
1779
|
return 1;
|
|
1195
1780
|
}
|
|
1196
1781
|
|
|
1197
|
-
//
|
|
1198
|
-
//
|
|
1199
|
-
//
|
|
1200
|
-
//
|
|
1201
|
-
//
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
// the currently selected row — used by the legacy direct-call
|
|
1207
|
-
// entry points. Once the row is hidden synchronously after
|
|
1208
|
-
// confirm, `filteredIds[selectedIndex]` no longer points at the
|
|
1209
|
-
// session being archived (it shifts to whatever is now under
|
|
1210
|
-
// the cursor).
|
|
1211
|
-
const id = typeof explicitId === "number"
|
|
1212
|
-
? explicitId
|
|
1213
|
-
: openDialog.filteredIds[openDialog.selectedIndex];
|
|
1214
|
-
// Clear the in-flight marker so the preview pane stops showing
|
|
1215
|
-
// "Archiving…" if the operation refuses or fails. After
|
|
1216
|
-
// `closeWindow` succeeds the row is gone from `listWindows()`
|
|
1217
|
-
// anyway, so clearing then is harmless.
|
|
1218
|
-
const clearInFlight = () => {
|
|
1219
|
-
if (
|
|
1220
|
-
openDialog?.inFlight && typeof id === "number" &&
|
|
1221
|
-
openDialog.inFlight.sessionId === id
|
|
1222
|
-
) {
|
|
1223
|
-
openDialog.inFlight = null;
|
|
1224
|
-
refreshOpenDialog();
|
|
1225
|
-
}
|
|
1226
|
-
};
|
|
1227
|
-
if (typeof id !== "number" || id <= 0) return;
|
|
1228
|
-
if (id === 1) {
|
|
1229
|
-
setDialogError("cannot archive the base session");
|
|
1230
|
-
clearInFlight();
|
|
1231
|
-
return;
|
|
1232
|
-
}
|
|
1233
|
-
// close_window refuses to close the active window; swap to a
|
|
1234
|
-
// different session first. The pick prefers something already
|
|
1235
|
-
// in the dialog's current filter, falls back to the base
|
|
1236
|
-
// session — both always exist (base is undeletable, and we'd
|
|
1237
|
-
// have nothing to archive without at least one session).
|
|
1238
|
-
if (id === editor.activeWindow()) {
|
|
1239
|
-
editor.setActiveWindow(pickNextActiveSession(id));
|
|
1240
|
-
}
|
|
1241
|
-
const session = orchestratorSessions.get(id);
|
|
1242
|
-
if (!session) {
|
|
1243
|
-
clearInFlight();
|
|
1244
|
-
return;
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
// Resolve the repo root from cwd (the user is in the
|
|
1248
|
-
// umbrella session's tree).
|
|
1249
|
-
const cwd = editor.getCwd();
|
|
1250
|
-
const top = await spawnCollect(
|
|
1251
|
-
"git",
|
|
1252
|
-
["rev-parse", "--show-toplevel"],
|
|
1253
|
-
cwd,
|
|
1254
|
-
);
|
|
1255
|
-
if (top.exit_code !== 0) {
|
|
1256
|
-
editor.setStatus("Orchestrator: archive failed — not a git repository");
|
|
1257
|
-
clearInFlight();
|
|
1258
|
-
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;
|
|
1259
1791
|
}
|
|
1260
|
-
|
|
1792
|
+
return await resolveCanonicalRepoRoot(s.root);
|
|
1793
|
+
}
|
|
1261
1794
|
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
editor.signalWindow(id, "SIGKILL");
|
|
1268
|
-
editor.closeWindow(id);
|
|
1795
|
+
interface LifecycleResult {
|
|
1796
|
+
ok: boolean;
|
|
1797
|
+
err?: string;
|
|
1798
|
+
repoRoot?: string;
|
|
1799
|
+
}
|
|
1269
1800
|
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
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
|
+
}
|
|
1273
1829
|
|
|
1274
|
-
// git worktree move keeps git's internal bookkeeping
|
|
1275
|
-
// consistent (the new path stays registered as a worktree).
|
|
1276
1830
|
const archivedRoot = editor.pathJoin(
|
|
1277
1831
|
editor.getDataDir(),
|
|
1278
1832
|
"orchestrator",
|
|
1279
1833
|
slugify(repoRoot),
|
|
1280
1834
|
".archived",
|
|
1281
|
-
|
|
1835
|
+
s.label,
|
|
1282
1836
|
);
|
|
1283
1837
|
const parent = editor.pathDirname(archivedRoot);
|
|
1284
1838
|
if (!editor.createDir(parent)) {
|
|
1285
|
-
|
|
1286
|
-
`Orchestrator: archive failed — could not create ${parent}`,
|
|
1287
|
-
);
|
|
1288
|
-
clearInFlight();
|
|
1289
|
-
return;
|
|
1839
|
+
return { ok: false, err: `could not create ${parent}`, repoRoot };
|
|
1290
1840
|
}
|
|
1841
|
+
// git worktree move keeps git's internal bookkeeping consistent
|
|
1842
|
+
// (the new path stays registered as a worktree).
|
|
1291
1843
|
const moveRes = await spawnCollect(
|
|
1292
1844
|
"git",
|
|
1293
|
-
["-C", repoRoot, "worktree", "move",
|
|
1845
|
+
["-C", repoRoot, "worktree", "move", s.root, archivedRoot],
|
|
1294
1846
|
repoRoot,
|
|
1295
1847
|
);
|
|
1296
1848
|
if (moveRes.exit_code !== 0) {
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
clearInFlight();
|
|
1303
|
-
return;
|
|
1849
|
+
return {
|
|
1850
|
+
ok: false,
|
|
1851
|
+
err: lastNonEmptyLine(moveRes.stderr) || "worktree move failed",
|
|
1852
|
+
repoRoot,
|
|
1853
|
+
};
|
|
1304
1854
|
}
|
|
1305
1855
|
|
|
1306
|
-
// Append manifest entry. The branch info is best-effort:
|
|
1307
|
-
// we assume Orchestrator's convention of branch==label (set in
|
|
1308
|
-
// the new-session form) until a session knows its branch
|
|
1309
|
-
// separately.
|
|
1310
1856
|
const manifest = loadArchiveManifest(repoRoot);
|
|
1311
1857
|
manifest.sessions.push({
|
|
1312
|
-
label:
|
|
1858
|
+
label: s.label,
|
|
1313
1859
|
root: archivedRoot,
|
|
1314
|
-
original_root:
|
|
1315
|
-
branch:
|
|
1860
|
+
original_root: s.root,
|
|
1861
|
+
branch: s.branch || s.label,
|
|
1316
1862
|
archived_at: new Date().toISOString(),
|
|
1317
1863
|
});
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
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);
|
|
1324
1871
|
}
|
|
1325
|
-
|
|
1326
|
-
triggerSyncAsync(repoRoot);
|
|
1872
|
+
return { ok: true, repoRoot };
|
|
1327
1873
|
}
|
|
1328
1874
|
|
|
1329
1875
|
// ---------------------------------------------------------------------
|
|
@@ -1526,86 +2072,135 @@ async function buildSyncSnapshot(repoRoot: string): Promise<unknown> {
|
|
|
1526
2072
|
};
|
|
1527
2073
|
}
|
|
1528
2074
|
|
|
1529
|
-
// Delete
|
|
1530
|
-
// session, then `git worktree remove --force` to drop the
|
|
1531
|
-
// worktree from disk. If the session was archived (manifest
|
|
1532
|
-
//
|
|
1533
|
-
// recovery after this
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
const
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
if (
|
|
1545
|
-
|
|
1546
|
-
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));
|
|
1547
2092
|
}
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
clearInFlight();
|
|
1552
|
-
return;
|
|
1553
|
-
}
|
|
1554
|
-
// Same auto-switch as archive — close_window refuses to close
|
|
1555
|
-
// the active window, so swap to a different session first.
|
|
1556
|
-
if (id === editor.activeWindow()) {
|
|
1557
|
-
editor.setActiveWindow(pickNextActiveSession(id));
|
|
1558
|
-
}
|
|
1559
|
-
|
|
1560
|
-
const cwd = editor.getCwd();
|
|
1561
|
-
const top = await spawnCollect(
|
|
1562
|
-
"git",
|
|
1563
|
-
["rev-parse", "--show-toplevel"],
|
|
1564
|
-
cwd,
|
|
1565
|
-
);
|
|
1566
|
-
if (top.exit_code !== 0) {
|
|
1567
|
-
editor.setStatus("Orchestrator: delete failed — not a git repository");
|
|
1568
|
-
clearInFlight();
|
|
1569
|
-
return;
|
|
2093
|
+
editor.signalWindow(id, "SIGKILL");
|
|
2094
|
+
editor.closeWindow(id);
|
|
2095
|
+
await editor.delay(250);
|
|
1570
2096
|
}
|
|
1571
|
-
const repoRoot = (top.stdout || "").trim();
|
|
1572
|
-
|
|
1573
|
-
editor.signalWindow(id, "SIGKILL");
|
|
1574
|
-
editor.closeWindow(id);
|
|
1575
|
-
await editor.delay(250);
|
|
1576
2097
|
|
|
1577
|
-
// `--force` because the worktree may have unstaged changes
|
|
1578
|
-
//
|
|
2098
|
+
// `--force` because the worktree may have unstaged changes the user
|
|
2099
|
+
// explicitly chose to discard via the confirm step.
|
|
1579
2100
|
const removeRes = await spawnCollect(
|
|
1580
2101
|
"git",
|
|
1581
|
-
["-C", repoRoot, "worktree", "remove", "--force",
|
|
2102
|
+
["-C", repoRoot, "worktree", "remove", "--force", s.root],
|
|
1582
2103
|
repoRoot,
|
|
1583
2104
|
);
|
|
1584
2105
|
if (removeRes.exit_code !== 0) {
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
clearInFlight();
|
|
1591
|
-
return;
|
|
2106
|
+
return {
|
|
2107
|
+
ok: false,
|
|
2108
|
+
err: lastNonEmptyLine(removeRes.stderr) || "worktree remove failed",
|
|
2109
|
+
repoRoot,
|
|
2110
|
+
};
|
|
1592
2111
|
}
|
|
1593
2112
|
|
|
1594
|
-
// Drop the matching manifest entry too, in case the session
|
|
1595
|
-
//
|
|
1596
|
-
//
|
|
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).
|
|
1597
2116
|
const manifest = loadArchiveManifest(repoRoot);
|
|
1598
2117
|
const before = manifest.sessions.length;
|
|
1599
|
-
manifest.sessions = manifest.sessions.filter(
|
|
1600
|
-
(e) => e.label !== session.label,
|
|
1601
|
-
);
|
|
2118
|
+
manifest.sessions = manifest.sessions.filter((e) => e.label !== s.label);
|
|
1602
2119
|
if (manifest.sessions.length !== before) {
|
|
1603
2120
|
saveArchiveManifest(repoRoot, manifest);
|
|
1604
2121
|
}
|
|
1605
2122
|
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
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
|
+
}
|
|
1609
2204
|
}
|
|
1610
2205
|
|
|
1611
2206
|
// `Alt+N` from inside the picker opens the new-session form — saves
|
|
@@ -1616,7 +2211,33 @@ async function deleteConfirmedSession(): Promise<void> {
|
|
|
1616
2211
|
// since OPEN_MODE doesn't claim them here.
|
|
1617
2212
|
editor.defineMode(
|
|
1618
2213
|
OPEN_MODE,
|
|
1619
|
-
[
|
|
2214
|
+
[
|
|
2215
|
+
["M-n", "orchestrator_open_new_from_picker"],
|
|
2216
|
+
// Scope toggle: flip the list between "current project only"
|
|
2217
|
+
// and "all projects". Registered as a mode chord so it's
|
|
2218
|
+
// user-rebindable and renders cross-platform (⌥P / Alt+P).
|
|
2219
|
+
["M-p", "orchestrator_toggle_scope"],
|
|
2220
|
+
// `/` jumps focus to the filter input — the familiar
|
|
2221
|
+
// search-focus shortcut. (As a mode chord it's intercepted even
|
|
2222
|
+
// while the filter has focus, so `/` can't be typed as filter
|
|
2223
|
+
// text; session names don't contain `/`, so that's an
|
|
2224
|
+
// acceptable trade for the quick-focus.)
|
|
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"],
|
|
2240
|
+
],
|
|
1620
2241
|
true,
|
|
1621
2242
|
true,
|
|
1622
2243
|
);
|
|
@@ -1627,6 +2248,86 @@ registerHandler("orchestrator_open_new_from_picker", () => {
|
|
|
1627
2248
|
openForm({ fromPicker: true });
|
|
1628
2249
|
});
|
|
1629
2250
|
|
|
2251
|
+
registerHandler("orchestrator_focus_filter", () => {
|
|
2252
|
+
if (!openDialog || !openPanel) return;
|
|
2253
|
+
openPanel.setFocusKey("filter");
|
|
2254
|
+
});
|
|
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
|
+
|
|
2288
|
+
function toggleScope(): void {
|
|
2289
|
+
if (!openDialog) return;
|
|
2290
|
+
openDialog.scope = openDialog.scope === "current" ? "all" : "current";
|
|
2291
|
+
// Remember the choice for the next time the picker opens.
|
|
2292
|
+
lastOpenScope = openDialog.scope;
|
|
2293
|
+
// Keep the highlighted session selected across the scope flip
|
|
2294
|
+
// when it survives into the new list; otherwise fall back to the
|
|
2295
|
+
// top. The filter value is untouched — toggling scope with an
|
|
2296
|
+
// active filter just widens/narrows the global-search base.
|
|
2297
|
+
const prevId = openDialog.filteredIds[openDialog.selectedIndex];
|
|
2298
|
+
openDialog.filteredIds = filterSessions(openDialog.filter.value);
|
|
2299
|
+
const nextIdx = prevId !== undefined ? openDialog.filteredIds.indexOf(prevId) : -1;
|
|
2300
|
+
openDialog.selectedIndex = nextIdx >= 0 ? nextIdx : 0;
|
|
2301
|
+
refreshOpenDialog();
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
registerHandler("orchestrator_toggle_scope", toggleScope);
|
|
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
|
+
|
|
1630
2331
|
// =============================================================================
|
|
1631
2332
|
// New-session floating form
|
|
1632
2333
|
// =============================================================================
|
|
@@ -1929,6 +2630,130 @@ async function pathIsInsideGitWorkTree(
|
|
|
1929
2630
|
return (res.stdout || "").trim() === "true";
|
|
1930
2631
|
}
|
|
1931
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
|
+
|
|
1932
2757
|
async function nextAutoSessionName(
|
|
1933
2758
|
repoRoot: string,
|
|
1934
2759
|
options?: { persist?: boolean },
|
|
@@ -2015,9 +2840,11 @@ function buildFormSpec(): WidgetSpec {
|
|
|
2015
2840
|
// inert when worktree creation is off.
|
|
2016
2841
|
let branchPlaceholder: string;
|
|
2017
2842
|
if (branchInert) {
|
|
2018
|
-
branchPlaceholder = worktreeEnabled
|
|
2019
|
-
? "
|
|
2020
|
-
:
|
|
2843
|
+
branchPlaceholder = !worktreeEnabled
|
|
2844
|
+
? "no git — N/A"
|
|
2845
|
+
: form.projectPathIsLinkedWorktree === true
|
|
2846
|
+
? "existing worktree — N/A"
|
|
2847
|
+
: "shared worktree — N/A";
|
|
2021
2848
|
} else if (!form.defaultBranch) {
|
|
2022
2849
|
branchPlaceholder = "detecting default branch…";
|
|
2023
2850
|
} else if (form.defaultBranchIsHeadFallback) {
|
|
@@ -2092,6 +2919,24 @@ function buildFormSpec(): WidgetSpec {
|
|
|
2092
2919
|
]),
|
|
2093
2920
|
],
|
|
2094
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
|
+
: []),
|
|
2095
2940
|
// === Form body: labeled, full-width inputs. ==================
|
|
2096
2941
|
// Labels are plain — the `▸` glyph used to be baked into all
|
|
2097
2942
|
// three strings and stayed put regardless of focus, which was
|
|
@@ -2210,7 +3055,6 @@ function renderForm(): void {
|
|
|
2210
3055
|
}
|
|
2211
3056
|
|
|
2212
3057
|
function openForm(options?: { fromPicker?: boolean }): void {
|
|
2213
|
-
pendingNewSession = null;
|
|
2214
3058
|
const lastCmd =
|
|
2215
3059
|
(editor.getGlobalState("orchestrator.last_cmd") as string | undefined) ?? "";
|
|
2216
3060
|
form = {
|
|
@@ -2232,6 +3076,7 @@ function openForm(options?: { fromPicker?: boolean }): void {
|
|
|
2232
3076
|
lastError: null,
|
|
2233
3077
|
defaultProjectPath: "",
|
|
2234
3078
|
projectPathIsGit: null,
|
|
3079
|
+
projectPathIsLinkedWorktree: null,
|
|
2235
3080
|
defaultSessionName: "",
|
|
2236
3081
|
defaultBranch: "",
|
|
2237
3082
|
defaultBranchIsHeadFallback: false,
|
|
@@ -2294,6 +3139,24 @@ async function probeProjectPathDefaults(): Promise<void> {
|
|
|
2294
3139
|
if (!form || form.probeToken !== token) return;
|
|
2295
3140
|
form.projectPathIsGit = isGit;
|
|
2296
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
|
+
|
|
2297
3160
|
// (3) Default branch + session name probes only make sense on
|
|
2298
3161
|
// a git path. On non-git, leave both empty (the renderer
|
|
2299
3162
|
// surfaces a "no git — N/A" branch placeholder, and the
|
|
@@ -2713,14 +3576,28 @@ async function submitForm(): Promise<void> {
|
|
|
2713
3576
|
editor.setGlobalState("orchestrator.last_cmd", cmd);
|
|
2714
3577
|
}
|
|
2715
3578
|
|
|
2716
|
-
//
|
|
2717
|
-
//
|
|
2718
|
-
//
|
|
2719
|
-
//
|
|
2720
|
-
//
|
|
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
|
+
|
|
3594
|
+
// Branch / cmd values used for the per-window state record —
|
|
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.
|
|
2721
3598
|
const reportedBranch = createWorktree
|
|
2722
3599
|
? (branchInput || sessionName)
|
|
2723
|
-
: "";
|
|
3600
|
+
: (isLinkedAttach ? attachInfo!.branch : "");
|
|
2724
3601
|
|
|
2725
3602
|
// Append the user-effective values to per-field input
|
|
2726
3603
|
// history so ↑/↓ can recall them on the next form open.
|
|
@@ -2729,16 +3606,106 @@ async function submitForm(): Promise<void> {
|
|
|
2729
3606
|
if (cmd) appendHistory("cmd", cmd);
|
|
2730
3607
|
if (createWorktree) appendHistory("branch", reportedBranch);
|
|
2731
3608
|
|
|
2732
|
-
pendingNewSession = {
|
|
2733
|
-
label: sessionName,
|
|
2734
|
-
branch: reportedBranch,
|
|
2735
|
-
cmd,
|
|
2736
|
-
root,
|
|
2737
|
-
projectPath,
|
|
2738
|
-
sharedWorktree: !createWorktree,
|
|
2739
|
-
};
|
|
2740
3609
|
closeForm();
|
|
2741
|
-
|
|
3610
|
+
|
|
3611
|
+
// Spawn the new window + agent terminal atomically. Compared to
|
|
3612
|
+
// the legacy `createWindow → window_created hook → createTerminal`
|
|
3613
|
+
// chain this avoids the transient `[No Name]` tab the host's
|
|
3614
|
+
// eager seed used to leave alongside the agent terminal: the
|
|
3615
|
+
// terminal IS the new window's seed buffer, so the window is
|
|
3616
|
+
// born with a single tab.
|
|
3617
|
+
const argv = splitAgentCmd(cmd);
|
|
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;
|
|
3621
|
+
try {
|
|
3622
|
+
const result = await editor.createWindowWithTerminal({
|
|
3623
|
+
root,
|
|
3624
|
+
label: sessionName,
|
|
3625
|
+
cwd: root,
|
|
3626
|
+
command: argv.length > 0 ? argv : undefined,
|
|
3627
|
+
title: argv.length > 0 ? argv[0] : undefined,
|
|
3628
|
+
});
|
|
3629
|
+
const id = result.windowId;
|
|
3630
|
+
// `createWindowWithTerminal` already dove into the new window,
|
|
3631
|
+
// so `setWindowState` writes to it.
|
|
3632
|
+
editor.setWindowState("project_path", effectiveProjectPath);
|
|
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
|
+
}
|
|
3642
|
+
const tracked: AgentSession = {
|
|
3643
|
+
id,
|
|
3644
|
+
label: sessionName,
|
|
3645
|
+
root,
|
|
3646
|
+
projectPath: effectiveProjectPath,
|
|
3647
|
+
sharedWorktree,
|
|
3648
|
+
terminalId: result.terminalId,
|
|
3649
|
+
state: "running",
|
|
3650
|
+
createdAt: Date.now(),
|
|
3651
|
+
branch: reportedBranch || undefined,
|
|
3652
|
+
};
|
|
3653
|
+
orchestratorSessions.set(id, tracked);
|
|
3654
|
+
} catch (e) {
|
|
3655
|
+
editor.setStatus(
|
|
3656
|
+
`Orchestrator: failed to start session — ${
|
|
3657
|
+
e instanceof Error ? e.message : String(e)
|
|
3658
|
+
}`,
|
|
3659
|
+
);
|
|
3660
|
+
}
|
|
3661
|
+
}
|
|
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
|
+
}
|
|
2742
3709
|
}
|
|
2743
3710
|
|
|
2744
3711
|
function startNewSession(): void {
|
|
@@ -2969,7 +3936,27 @@ function enterConfirm(action: "stop" | "archive" | "delete"): void {
|
|
|
2969
3936
|
}
|
|
2970
3937
|
}
|
|
2971
3938
|
}
|
|
2972
|
-
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 };
|
|
2973
3960
|
openPanel.update(buildOpenSpec());
|
|
2974
3961
|
openPanel.setFocusKey("confirm-cancel");
|
|
2975
3962
|
}
|
|
@@ -3128,8 +4115,11 @@ editor.on("widget_event", (e) => {
|
|
|
3128
4115
|
// on the button. Snap focus back to Visit so the user can
|
|
3129
4116
|
// press Enter to open the newly-highlighted session — the
|
|
3130
4117
|
// dialog's whole reason for being. Idempotent when focus
|
|
3131
|
-
// is already on Visit.
|
|
3132
|
-
|
|
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
|
+
}
|
|
3133
4123
|
}
|
|
3134
4124
|
return;
|
|
3135
4125
|
}
|
|
@@ -3138,6 +4128,20 @@ editor.on("widget_event", (e) => {
|
|
|
3138
4128
|
(e.widget_key === "sessions" || e.widget_key === "visit")
|
|
3139
4129
|
) {
|
|
3140
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
|
+
}
|
|
3141
4145
|
if (typeof id === "number" && id > 0 && id !== editor.activeWindow()) {
|
|
3142
4146
|
editor.setActiveWindow(id);
|
|
3143
4147
|
}
|
|
@@ -3149,11 +4153,21 @@ editor.on("widget_event", (e) => {
|
|
|
3149
4153
|
openForm({ fromPicker: true });
|
|
3150
4154
|
return;
|
|
3151
4155
|
}
|
|
4156
|
+
if (e.event_type === "activate" && e.widget_key === "scope-toggle") {
|
|
4157
|
+
toggleScope();
|
|
4158
|
+
return;
|
|
4159
|
+
}
|
|
3152
4160
|
if (e.event_type === "activate" && e.widget_key === "toggle-details") {
|
|
3153
4161
|
openDialog.showDetails = !openDialog.showDetails;
|
|
3154
4162
|
refreshOpenDialog();
|
|
3155
4163
|
return;
|
|
3156
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
|
+
}
|
|
3157
4171
|
if (e.event_type === "activate" && e.widget_key === "stop") {
|
|
3158
4172
|
enterConfirm("stop");
|
|
3159
4173
|
return;
|
|
@@ -3166,44 +4180,47 @@ editor.on("widget_event", (e) => {
|
|
|
3166
4180
|
enterConfirm("delete");
|
|
3167
4181
|
return;
|
|
3168
4182
|
}
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
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");
|
|
3172
4187
|
return;
|
|
3173
4188
|
}
|
|
3174
|
-
if (e.event_type === "activate" && e.widget_key === "
|
|
3175
|
-
|
|
3176
|
-
stopSelectedSession();
|
|
3177
|
-
if (openPanel) openPanel.update(buildOpenSpec());
|
|
4189
|
+
if (e.event_type === "activate" && e.widget_key === "bulk-archive") {
|
|
4190
|
+
enterBulkConfirm("archive");
|
|
3178
4191
|
return;
|
|
3179
4192
|
}
|
|
3180
|
-
if (e.event_type === "activate" && e.widget_key === "
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
// is still the source of truth and will drop it on
|
|
3187
|
-
// `closeWindow`, which is intentional: a slightly-laggy real
|
|
3188
|
-
// state beats a synchronously faked one that can desync from
|
|
3189
|
-
// git reality (e.g. when `git worktree move` fails).
|
|
3190
|
-
if (typeof id === "number" && id > 0) {
|
|
3191
|
-
openDialog.inFlight = { action: "archive", sessionId: id };
|
|
3192
|
-
}
|
|
3193
|
-
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();
|
|
3194
4199
|
refreshOpenDialog();
|
|
4200
|
+
openPanel.setFocusKey("visit");
|
|
3195
4201
|
return;
|
|
3196
4202
|
}
|
|
3197
|
-
if (e.event_type === "activate" && e.widget_key === "confirm-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
4203
|
+
if (e.event_type === "activate" && e.widget_key === "confirm-cancel") {
|
|
4204
|
+
openDialog.pendingConfirm = null;
|
|
4205
|
+
openPanel.update(buildOpenSpec());
|
|
4206
|
+
return;
|
|
4207
|
+
}
|
|
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);
|
|
3204
4222
|
}
|
|
3205
|
-
|
|
3206
|
-
refreshOpenDialog();
|
|
4223
|
+
if (openPanel) openPanel.update(buildOpenSpec());
|
|
3207
4224
|
return;
|
|
3208
4225
|
}
|
|
3209
4226
|
if (e.event_type === "cancel") {
|
|
@@ -3262,57 +4279,13 @@ function killSelected(): void {
|
|
|
3262
4279
|
// Lifecycle hook handlers
|
|
3263
4280
|
// =============================================================================
|
|
3264
4281
|
|
|
3265
|
-
editor.on("window_created",
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
pendingNewSession = null;
|
|
3273
|
-
// Dive into the new session FIRST so its terminal_manager is
|
|
3274
|
-
// the editor-active one. Subsequent `createTerminal` /
|
|
3275
|
-
// `sendTerminalInput` calls then resolve against the new
|
|
3276
|
-
// session's window without needing a cross-window terminal
|
|
3277
|
-
// lookup. Creating a session is a visit-now action anyway —
|
|
3278
|
-
// the dive isn't user-visible flicker, it's the desired
|
|
3279
|
-
// landing state.
|
|
3280
|
-
editor.setActiveWindow(id);
|
|
3281
|
-
// Record the new session's project_path / shared_worktree
|
|
3282
|
-
// into per-window plugin state — these survive editor
|
|
3283
|
-
// restarts via `orchestrator_persistence.rs`, and feed the
|
|
3284
|
-
// Open dialog's "this project" filter on the next launch.
|
|
3285
|
-
// `setWindowState` writes to the active window, which we
|
|
3286
|
-
// just set above.
|
|
3287
|
-
editor.setWindowState("project_path", intent.projectPath);
|
|
3288
|
-
editor.setWindowState("shared_worktree", intent.sharedWorktree);
|
|
3289
|
-
// When the user provided a non-empty agent command, spawn it as
|
|
3290
|
-
// the PTY child directly (no shell middleman). Tab title reads
|
|
3291
|
-
// the command name ("python3", "claude", ...) instead of the
|
|
3292
|
-
// generic "*Terminal N*". When `cmd` is empty the host picks
|
|
3293
|
-
// the user's shell as before.
|
|
3294
|
-
const argv = splitAgentCmd(intent.cmd);
|
|
3295
|
-
const term = await editor.createTerminal({
|
|
3296
|
-
cwd: intent.root,
|
|
3297
|
-
focus: false,
|
|
3298
|
-
command: argv.length > 0 ? argv : undefined,
|
|
3299
|
-
title: argv.length > 0 ? argv[0] : undefined,
|
|
3300
|
-
});
|
|
3301
|
-
const tracked: AgentSession = {
|
|
3302
|
-
id,
|
|
3303
|
-
label: intent.label,
|
|
3304
|
-
root: intent.root,
|
|
3305
|
-
terminalId: term.terminalId,
|
|
3306
|
-
state: "running",
|
|
3307
|
-
createdAt: Date.now(),
|
|
3308
|
-
};
|
|
3309
|
-
orchestratorSessions.set(id, tracked);
|
|
3310
|
-
// Legacy `sendTerminalInput` path is no longer needed when the
|
|
3311
|
-
// command is spawned directly. Kept for the shell-only case
|
|
3312
|
-
// would be `editor.sendTerminalInput(term.terminalId, "\n")` to
|
|
3313
|
-
// wake up the prompt, but that's unnecessary — the shell prints
|
|
3314
|
-
// its own prompt on startup.
|
|
3315
|
-
}
|
|
4282
|
+
editor.on("window_created", () => {
|
|
4283
|
+
// The orchestrator's own new-session flow uses
|
|
4284
|
+
// `createWindowWithTerminal` (atomic — populates the window
|
|
4285
|
+
// before returning), so by the time this hook fires for one of
|
|
4286
|
+
// our spawns the session is already tracked. Other plugins or
|
|
4287
|
+
// host actions creating windows just need the picker to
|
|
4288
|
+
// refresh.
|
|
3316
4289
|
refreshOpenDialog();
|
|
3317
4290
|
});
|
|
3318
4291
|
|
|
@@ -3336,9 +4309,8 @@ editor.on("active_window_changed", () => {
|
|
|
3336
4309
|
// viewport at the same time.
|
|
3337
4310
|
editor.on("resize", () => {
|
|
3338
4311
|
if (openDialog && openPanel) {
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
openDialog.embedRows = Math.max(3, listVisibleRows - 5);
|
|
4312
|
+
// buildOpenSpec refits `listVisibleRows` to the session count
|
|
4313
|
+
// (bounded by the new screen budget) on the refresh below.
|
|
3342
4314
|
refreshOpenDialog();
|
|
3343
4315
|
}
|
|
3344
4316
|
});
|