opencode-worktree 0.2.9 → 0.3.1

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/src/ui.ts CHANGED
@@ -21,8 +21,10 @@ import {
21
21
  resolveRepoRoot,
22
22
  unlinkWorktree,
23
23
  } from "./git.js";
24
- import { isOpenCodeAvailable, launchOpenCode } from "./opencode.js";
24
+ import { isOpenCodeAvailable, launchOpenCode, openInFileManager } from "./opencode.js";
25
25
  import { WorktreeInfo } from "./types.js";
26
+ import { loadRepoConfig, saveRepoConfig, configExists, type Config } from "./config.js";
27
+ import { runPostCreateHook, type HookResult } from "./hooks.js";
26
28
 
27
29
  type StatusLevel = "info" | "warning" | "error" | "success";
28
30
 
@@ -72,6 +74,28 @@ class WorktreeSelector {
72
74
  private isCreatingWorktree = false;
73
75
  private worktreeOptions: SelectOption[] = [];
74
76
 
77
+ // Multi-select delete mode
78
+ private isSelectingForDelete = false;
79
+ private selectedForDelete: Set<string> = new Set(); // Set of worktree paths
80
+
81
+ // Hook execution state
82
+ private isRunningHook = false;
83
+ private hookOutputContainer: BoxRenderable | null = null;
84
+ private hookOutputText: TextRenderable | null = null;
85
+ private hookOutput: string[] = [];
86
+ private hookAbortFn: (() => void) | null = null;
87
+ private pendingWorktreePath: string | null = null;
88
+ private hookFailed = false;
89
+ private hookFailureSelect: SelectRenderable | null = null;
90
+
91
+ // Config editor state
92
+ private isEditingConfig = false;
93
+ private configContainer: BoxRenderable | null = null;
94
+ private configHookInput: InputRenderable | null = null;
95
+ private configOpenInput: InputRenderable | null = null;
96
+ private configActiveField: "hook" | "open" = "hook";
97
+ private isFirstTimeSetup = false;
98
+
75
99
  constructor(
76
100
  private renderer: CliRenderer,
77
101
  private targetPath: string,
@@ -129,7 +153,7 @@ class WorktreeSelector {
129
153
  left: 2,
130
154
  top: 20,
131
155
  content:
132
- "↑/↓ navigate • Enter open • d delete • n new • r refresh • q quit",
156
+ "↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit",
133
157
  fg: "#64748B",
134
158
  });
135
159
  this.renderer.root.add(this.instructions);
@@ -138,7 +162,7 @@ class WorktreeSelector {
138
162
  SelectRenderableEvents.ITEM_SELECTED,
139
163
  (_index: number, option: SelectOption) => {
140
164
  // Ignore if we're in another mode
141
- if (this.isConfirming || this.isCreatingWorktree) {
165
+ if (this.isConfirming || this.isCreatingWorktree || this.isSelectingForDelete) {
142
166
  return;
143
167
  }
144
168
  this.handleSelection(option.value as SelectionValue);
@@ -150,6 +174,11 @@ class WorktreeSelector {
150
174
  });
151
175
 
152
176
  this.selectElement.focus();
177
+
178
+ // Check for first-time setup
179
+ if (this.repoRoot && !configExists(this.repoRoot)) {
180
+ this.showFirstTimeSetup();
181
+ }
153
182
  }
154
183
 
155
184
  private getInitialStatusMessage(): string {
@@ -184,10 +213,36 @@ class WorktreeSelector {
184
213
 
185
214
  private handleKeypress(key: KeyEvent): void {
186
215
  if (key.ctrl && key.name === "c") {
216
+ // If running hook, abort it first
217
+ if (this.isRunningHook && this.hookAbortFn) {
218
+ this.hookAbortFn();
219
+ this.hookAbortFn = null;
220
+ this.setStatus("Hook aborted by user.", "warning");
221
+ this.hideHookOutput();
222
+ this.loadWorktrees(this.pendingWorktreePath || undefined);
223
+ this.selectElement.visible = true;
224
+ this.selectElement.focus();
225
+ this.instructions.content =
226
+ "↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit";
227
+ return;
228
+ }
187
229
  this.cleanup(true);
188
230
  return;
189
231
  }
190
232
 
233
+ // Handle hook running mode (only allow Ctrl+C which is handled above)
234
+ if (this.isRunningHook && !this.hookFailed) {
235
+ return;
236
+ }
237
+
238
+ // Handle hook failure mode - let the select handle input
239
+ if (this.isRunningHook && this.hookFailed) {
240
+ if (key.name === "escape") {
241
+ this.handleHookFailureChoice("cancel");
242
+ }
243
+ return;
244
+ }
245
+
191
246
  // Handle confirmation mode
192
247
  if (this.isConfirming) {
193
248
  if (key.name === "escape") {
@@ -203,6 +258,55 @@ class WorktreeSelector {
203
258
  return;
204
259
  }
205
260
 
261
+ // Handle config editing mode
262
+ if (this.isEditingConfig) {
263
+ if (key.name === "escape") {
264
+ this.hideConfigEditor();
265
+ return;
266
+ }
267
+ if (key.name === "return") {
268
+ this.handleConfigSave();
269
+ return;
270
+ }
271
+ if (key.name === "tab") {
272
+ // Switch between fields
273
+ if (this.configActiveField === "hook") {
274
+ this.configActiveField = "open";
275
+ this.configHookInput?.blur();
276
+ this.configOpenInput?.focus();
277
+ } else {
278
+ this.configActiveField = "hook";
279
+ this.configOpenInput?.blur();
280
+ this.configHookInput?.focus();
281
+ }
282
+ this.renderer.requestRender();
283
+ return;
284
+ }
285
+ return;
286
+ }
287
+
288
+ // Handle multi-select delete mode
289
+ if (this.isSelectingForDelete) {
290
+ if (key.name === "escape") {
291
+ this.exitSelectMode();
292
+ return;
293
+ }
294
+ if (key.name === "return") {
295
+ this.toggleWorktreeSelection();
296
+ return;
297
+ }
298
+ if (key.name === "d") {
299
+ // Confirm deletion of selected worktrees
300
+ this.confirmBatchDelete();
301
+ return;
302
+ }
303
+ if (key.name === "q") {
304
+ this.exitSelectMode();
305
+ return;
306
+ }
307
+ return;
308
+ }
309
+
206
310
  if (key.name === "q" || key.name === "escape") {
207
311
  this.cleanup(true);
208
312
  return;
@@ -219,9 +323,21 @@ class WorktreeSelector {
219
323
  return;
220
324
  }
221
325
 
222
- // 'd' for delete/unlink menu
326
+ // 'd' for entering delete selection mode
223
327
  if (key.name === "d") {
224
- this.showDeleteConfirmation();
328
+ this.enterSelectMode();
329
+ return;
330
+ }
331
+
332
+ // 'o' for opening worktree path in file manager
333
+ if (key.name === "o") {
334
+ this.openWorktreeInFileManager();
335
+ return;
336
+ }
337
+
338
+ // 'c' for editing config
339
+ if (key.name === "c") {
340
+ this.showConfigEditor();
225
341
  return;
226
342
  }
227
343
  }
@@ -242,6 +358,29 @@ class WorktreeSelector {
242
358
  launchOpenCode(worktree.path);
243
359
  }
244
360
 
361
+ private openWorktreeInFileManager(): void {
362
+ const worktree = this.getSelectedWorktree();
363
+ if (!worktree) {
364
+ this.setStatus("Select a worktree to open in file manager.", "warning");
365
+ return;
366
+ }
367
+
368
+ // Load config to check for custom open command
369
+ const config = this.repoRoot ? loadRepoConfig(this.repoRoot) : {};
370
+ const customCommand = config.openCommand;
371
+
372
+ const success = openInFileManager(worktree.path, customCommand);
373
+ if (success) {
374
+ if (customCommand) {
375
+ this.setStatus(`Opened ${worktree.path} with ${customCommand}.`, "success");
376
+ } else {
377
+ this.setStatus(`Opened ${worktree.path} in file manager.`, "success");
378
+ }
379
+ } else {
380
+ this.setStatus("Failed to open file manager.", "error");
381
+ }
382
+ }
383
+
245
384
  private showCreateWorktreeInput(): void {
246
385
  this.isCreatingWorktree = true;
247
386
  this.selectElement.visible = false;
@@ -296,7 +435,7 @@ class WorktreeSelector {
296
435
  this.renderer.requestRender();
297
436
  }
298
437
 
299
- private hideCreateWorktreeInput(): void {
438
+ private hideCreateWorktreeInput(selectWorktreePath?: string): void {
300
439
  this.isCreatingWorktree = false;
301
440
 
302
441
  if (this.branchInput) {
@@ -311,9 +450,9 @@ class WorktreeSelector {
311
450
 
312
451
  this.selectElement.visible = true;
313
452
  this.instructions.content =
314
- "↑/↓ navigate • Enter open • d delete • n new • r refresh • q quit";
453
+ "↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit";
315
454
  this.selectElement.focus();
316
- this.loadWorktrees();
455
+ this.loadWorktrees(selectWorktreePath);
317
456
  }
318
457
 
319
458
  private handleCreateWorktree(branchName: string): void {
@@ -336,25 +475,380 @@ class WorktreeSelector {
336
475
 
337
476
  if (result.success) {
338
477
  this.setStatus(`Worktree created at ${result.path}`, "success");
339
- this.renderer.requestRender();
340
-
341
- if (this.opencodeAvailable) {
478
+
479
+ // Check for post-create hook
480
+ const config = loadRepoConfig(this.repoRoot);
481
+ if (config.postCreateHook) {
482
+ this.pendingWorktreePath = result.path;
483
+ this.runHook(result.path, config.postCreateHook);
484
+ } else {
485
+ // No hook, launch opencode directly
342
486
  this.hideCreateWorktreeInput();
343
487
  this.cleanup(false);
344
488
  launchOpenCode(result.path);
345
- } else {
346
- this.setStatus(
347
- `Worktree created but opencode is not available.`,
348
- "warning",
349
- );
350
- this.hideCreateWorktreeInput();
351
489
  }
352
490
  } else {
353
491
  this.setStatus(`Failed to create worktree: ${result.error}`, "error");
354
492
  }
355
493
  }
356
494
 
357
- private loadWorktrees(): void {
495
+ private runHook(worktreePath: string, command: string): void {
496
+ this.isRunningHook = true;
497
+ this.hookFailed = false;
498
+ this.hookOutput = [];
499
+
500
+ // Hide create input if still visible
501
+ if (this.inputContainer) {
502
+ this.renderer.root.remove(this.inputContainer.id);
503
+ this.inputContainer = null;
504
+ this.branchInput = null;
505
+ }
506
+ this.isCreatingWorktree = false;
507
+ this.selectElement.visible = false;
508
+
509
+ // Create hook output container
510
+ this.hookOutputContainer = new BoxRenderable(this.renderer, {
511
+ id: "hook-output-container",
512
+ position: "absolute",
513
+ left: 2,
514
+ top: 3,
515
+ width: 76,
516
+ height: 14,
517
+ borderStyle: "single",
518
+ borderColor: "#38BDF8",
519
+ title: `Running: ${command}`,
520
+ titleAlignment: "left",
521
+ backgroundColor: "#0F172A",
522
+ border: true,
523
+ });
524
+ this.renderer.root.add(this.hookOutputContainer);
525
+
526
+ this.hookOutputText = new TextRenderable(this.renderer, {
527
+ id: "hook-output-text",
528
+ position: "absolute",
529
+ left: 1,
530
+ top: 1,
531
+ content: "Starting...\n",
532
+ fg: "#94A3B8",
533
+ });
534
+ this.hookOutputContainer.add(this.hookOutputText);
535
+
536
+ this.instructions.content = "Hook running... (Ctrl+C to abort)";
537
+ this.setStatus(`Executing post-create hook...`, "info");
538
+ this.renderer.requestRender();
539
+
540
+ // Run the hook with streaming output
541
+ this.hookAbortFn = runPostCreateHook(worktreePath, command, {
542
+ onOutput: (data: string) => {
543
+ this.hookOutput.push(data);
544
+ this.updateHookOutput();
545
+ },
546
+ onComplete: (result: HookResult) => {
547
+ this.hookAbortFn = null;
548
+ if (result.success) {
549
+ this.onHookSuccess();
550
+ } else {
551
+ this.onHookFailure(result.exitCode);
552
+ }
553
+ },
554
+ });
555
+ }
556
+
557
+ private updateHookOutput(): void {
558
+ if (!this.hookOutputText) return;
559
+
560
+ // Join all output and take the last N lines that fit in the container
561
+ const fullOutput = this.hookOutput.join("");
562
+ const lines = fullOutput.split("\n");
563
+ const maxLines = 11; // Container height minus borders and padding
564
+ const visibleLines = lines.slice(-maxLines);
565
+
566
+ this.hookOutputText.content = visibleLines.join("\n");
567
+ this.renderer.requestRender();
568
+ }
569
+
570
+ private onHookSuccess(): void {
571
+ this.setStatus("Hook completed successfully!", "success");
572
+ this.renderer.requestRender();
573
+
574
+ // Brief delay to show success, then launch opencode
575
+ setTimeout(() => {
576
+ this.hideHookOutput();
577
+ if (this.pendingWorktreePath) {
578
+ this.cleanup(false);
579
+ launchOpenCode(this.pendingWorktreePath);
580
+ }
581
+ }, 1000);
582
+ }
583
+
584
+ private onHookFailure(exitCode: number | null): void {
585
+ this.hookFailed = true;
586
+ const exitMsg = exitCode !== null ? ` (exit code: ${exitCode})` : "";
587
+ this.setStatus(`Hook failed${exitMsg}`, "error");
588
+
589
+ // Add failure options to the container
590
+ if (this.hookOutputContainer) {
591
+ this.hookFailureSelect = new SelectRenderable(this.renderer, {
592
+ id: "hook-failure-select",
593
+ position: "absolute",
594
+ left: 1,
595
+ top: 12,
596
+ width: 72,
597
+ height: 2,
598
+ options: [
599
+ {
600
+ name: "Open in opencode anyway",
601
+ description: "Launch opencode despite hook failure",
602
+ value: "open",
603
+ },
604
+ {
605
+ name: "Cancel",
606
+ description: "Return to worktree list",
607
+ value: "cancel",
608
+ },
609
+ ],
610
+ backgroundColor: "#0F172A",
611
+ focusedBackgroundColor: "#1E293B",
612
+ selectedBackgroundColor: "#1E3A5F",
613
+ textColor: "#E2E8F0",
614
+ selectedTextColor: "#38BDF8",
615
+ descriptionColor: "#94A3B8",
616
+ selectedDescriptionColor: "#E2E8F0",
617
+ showDescription: false,
618
+ wrapSelection: true,
619
+ });
620
+ this.hookOutputContainer.add(this.hookFailureSelect);
621
+
622
+ this.hookFailureSelect.on(
623
+ SelectRenderableEvents.ITEM_SELECTED,
624
+ (_index: number, option: SelectOption) => {
625
+ this.handleHookFailureChoice(option.value as string);
626
+ }
627
+ );
628
+
629
+ this.hookFailureSelect.focus();
630
+ }
631
+
632
+ this.instructions.content = "↑/↓ select • Enter confirm";
633
+ this.renderer.requestRender();
634
+ }
635
+
636
+ private handleHookFailureChoice(choice: string): void {
637
+ if (choice === "open" && this.pendingWorktreePath) {
638
+ this.hideHookOutput();
639
+ this.cleanup(false);
640
+ launchOpenCode(this.pendingWorktreePath);
641
+ } else {
642
+ // Cancel - return to list
643
+ this.hideHookOutput();
644
+ this.loadWorktrees(this.pendingWorktreePath || undefined);
645
+ this.selectElement.visible = true;
646
+ this.selectElement.focus();
647
+ this.instructions.content =
648
+ "↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit";
649
+ }
650
+ }
651
+
652
+ private hideHookOutput(): void {
653
+ this.isRunningHook = false;
654
+ this.hookFailed = false;
655
+ this.hookOutput = [];
656
+ this.pendingWorktreePath = null;
657
+
658
+ if (this.hookFailureSelect) {
659
+ this.hookFailureSelect.blur();
660
+ this.hookFailureSelect = null;
661
+ }
662
+
663
+ if (this.hookOutputContainer) {
664
+ this.renderer.root.remove(this.hookOutputContainer.id);
665
+ this.hookOutputContainer = null;
666
+ this.hookOutputText = null;
667
+ }
668
+ }
669
+
670
+ // ========== Config Editor Methods ==========
671
+
672
+ private showFirstTimeSetup(): void {
673
+ this.isFirstTimeSetup = true;
674
+ this.showConfigEditor();
675
+ }
676
+
677
+ private showConfigEditor(): void {
678
+ if (!this.repoRoot) {
679
+ this.setStatus("No git repository found.", "error");
680
+ return;
681
+ }
682
+
683
+ this.isEditingConfig = true;
684
+ this.configActiveField = "hook";
685
+ this.selectElement.visible = false;
686
+ this.selectElement.blur();
687
+
688
+ // Load existing config to pre-fill
689
+ const existingConfig = loadRepoConfig(this.repoRoot);
690
+
691
+ const title = this.isFirstTimeSetup
692
+ ? "First-time Setup: Project Configuration"
693
+ : "Edit Project Configuration";
694
+
695
+ this.configContainer = new BoxRenderable(this.renderer, {
696
+ id: "config-container",
697
+ position: "absolute",
698
+ left: 2,
699
+ top: 3,
700
+ width: 76,
701
+ height: 12,
702
+ borderStyle: "single",
703
+ borderColor: "#38BDF8",
704
+ title,
705
+ titleAlignment: "center",
706
+ backgroundColor: "#0F172A",
707
+ border: true,
708
+ });
709
+ this.renderer.root.add(this.configContainer);
710
+
711
+ // Post-create hook field
712
+ const hookLabel = new TextRenderable(this.renderer, {
713
+ id: "config-hook-label",
714
+ position: "absolute",
715
+ left: 1,
716
+ top: 1,
717
+ content: "Post-create hook (e.g., npm install):",
718
+ fg: "#94A3B8",
719
+ });
720
+ this.configContainer.add(hookLabel);
721
+
722
+ this.configHookInput = new InputRenderable(this.renderer, {
723
+ id: "config-hook-input",
724
+ position: "absolute",
725
+ left: 1,
726
+ top: 2,
727
+ width: 72,
728
+ placeholder: "npm install",
729
+ value: existingConfig.postCreateHook || "",
730
+ focusedBackgroundColor: "#1E293B",
731
+ backgroundColor: "#1E293B",
732
+ });
733
+ this.configContainer.add(this.configHookInput);
734
+
735
+ // Open command field
736
+ const openLabel = new TextRenderable(this.renderer, {
737
+ id: "config-open-label",
738
+ position: "absolute",
739
+ left: 1,
740
+ top: 4,
741
+ content: "Open folder command (e.g., webstorm, code):",
742
+ fg: "#94A3B8",
743
+ });
744
+ this.configContainer.add(openLabel);
745
+
746
+ this.configOpenInput = new InputRenderable(this.renderer, {
747
+ id: "config-open-input",
748
+ position: "absolute",
749
+ left: 1,
750
+ top: 5,
751
+ width: 72,
752
+ placeholder: "open (default)",
753
+ value: existingConfig.openCommand || "",
754
+ focusedBackgroundColor: "#1E293B",
755
+ backgroundColor: "#1E293B",
756
+ });
757
+ this.configContainer.add(this.configOpenInput);
758
+
759
+ // Help text
760
+ const helpText = new TextRenderable(this.renderer, {
761
+ id: "config-help",
762
+ position: "absolute",
763
+ left: 1,
764
+ top: 7,
765
+ content: "Tab to switch fields • Leave empty to use defaults",
766
+ fg: "#64748B",
767
+ });
768
+ this.configContainer.add(helpText);
769
+
770
+ this.instructions.content = "Tab switch • Enter save • Esc cancel";
771
+ this.setStatus(
772
+ this.isFirstTimeSetup
773
+ ? "Welcome! Configure your project settings."
774
+ : "Edit project configuration.",
775
+ "info"
776
+ );
777
+
778
+ // Delay focus to prevent the triggering keypress from being captured
779
+ setTimeout(() => {
780
+ this.configHookInput?.focus();
781
+ this.renderer.requestRender();
782
+ }, 0);
783
+ }
784
+
785
+ private hideConfigEditor(): void {
786
+ this.isEditingConfig = false;
787
+ this.isFirstTimeSetup = false;
788
+
789
+ if (this.configHookInput) {
790
+ this.configHookInput.blur();
791
+ }
792
+ if (this.configOpenInput) {
793
+ this.configOpenInput.blur();
794
+ }
795
+
796
+ if (this.configContainer) {
797
+ this.renderer.root.remove(this.configContainer.id);
798
+ this.configContainer = null;
799
+ this.configHookInput = null;
800
+ this.configOpenInput = null;
801
+ }
802
+
803
+ this.selectElement.visible = true;
804
+ this.instructions.content =
805
+ "↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit";
806
+
807
+ // Delay focus to prevent the Enter keypress from triggering a selection
808
+ setTimeout(() => {
809
+ this.selectElement.focus();
810
+ this.renderer.requestRender();
811
+ }, 0);
812
+ }
813
+
814
+ private handleConfigSave(): void {
815
+ if (!this.repoRoot) {
816
+ this.setStatus("No git repository found.", "error");
817
+ this.hideConfigEditor();
818
+ return;
819
+ }
820
+
821
+ const hookValue = (this.configHookInput?.value || "").trim();
822
+ const openValue = (this.configOpenInput?.value || "").trim();
823
+ const config: Config = {};
824
+
825
+ if (hookValue) {
826
+ config.postCreateHook = hookValue;
827
+ }
828
+ if (openValue) {
829
+ config.openCommand = openValue;
830
+ }
831
+
832
+ const success = saveRepoConfig(this.repoRoot, config);
833
+
834
+ if (success) {
835
+ const changes: string[] = [];
836
+ if (hookValue) changes.push(`hook: "${hookValue}"`);
837
+ if (openValue) changes.push(`open: "${openValue}"`);
838
+
839
+ if (changes.length > 0) {
840
+ this.setStatus(`Config saved: ${changes.join(", ")}`, "success");
841
+ } else {
842
+ this.setStatus("Config cleared.", "success");
843
+ }
844
+ } else {
845
+ this.setStatus("Failed to save config.", "error");
846
+ }
847
+
848
+ this.hideConfigEditor();
849
+ }
850
+
851
+ private loadWorktrees(selectWorktreePath?: string): void {
358
852
  this.repoRoot = resolveRepoRoot(this.targetPath);
359
853
  if (!this.repoRoot) {
360
854
  this.setStatus("No git repository found in this directory.", "error");
@@ -366,6 +860,17 @@ class WorktreeSelector {
366
860
  const worktrees = listWorktrees(this.repoRoot);
367
861
  this.selectElement.options = this.buildOptions(worktrees);
368
862
 
863
+ // Preselect a specific worktree if path is provided
864
+ if (selectWorktreePath) {
865
+ const index = this.selectElement.options.findIndex((opt: SelectOption) => {
866
+ if (opt.value === CREATE_NEW_WORKTREE_VALUE) return false;
867
+ return (opt.value as WorktreeInfo).path === selectWorktreePath;
868
+ });
869
+ if (index >= 0) {
870
+ this.selectElement.setSelectedIndex(index);
871
+ }
872
+ }
873
+
369
874
  if (worktrees.length === 0) {
370
875
  this.setStatus(
371
876
  "No worktrees detected. Select 'Create new worktree' to add one.",
@@ -394,24 +899,89 @@ class WorktreeSelector {
394
899
  };
395
900
 
396
901
  const worktreeOptions = worktrees.map((worktree) => {
397
- const shortHead = worktree.head ? worktree.head.slice(0, 7) : "unknown";
398
902
  const baseName = basename(worktree.path);
399
- const label = worktree.branch
903
+ const isMain = this.repoRoot && isMainWorktree(this.repoRoot, worktree.path);
904
+
905
+ // Build base label
906
+ let label = worktree.branch
400
907
  ? worktree.branch
401
908
  : worktree.isDetached
402
909
  ? `${baseName} (detached)`
403
910
  : baseName;
404
911
 
912
+ // Add status indicators
913
+ const indicators: string[] = [];
914
+ if (isMain) {
915
+ indicators.push("main");
916
+ }
917
+ if (worktree.isDirty) {
918
+ indicators.push("*");
919
+ }
920
+ if (!worktree.isOnRemote && worktree.branch && !isMain) {
921
+ indicators.push("local");
922
+ }
923
+
924
+ if (indicators.length > 0) {
925
+ label = `${label} [${indicators.join(" ")}]`;
926
+ }
927
+
928
+ // Add checkbox prefix in selection mode
929
+ let displayName = label;
930
+ if (this.isSelectingForDelete) {
931
+ const isSelected = this.selectedForDelete.has(worktree.path);
932
+ if (isMain) {
933
+ displayName = ` [main] ${worktree.branch || baseName}`;
934
+ } else {
935
+ displayName = isSelected ? `[x] ${label}` : `[ ] ${label}`;
936
+ }
937
+ }
938
+
939
+ // Build description with metadata
940
+ const descParts: string[] = [];
941
+
942
+ // Last modified date
943
+ if (worktree.lastModified) {
944
+ descParts.push(this.formatRelativeDate(worktree.lastModified));
945
+ }
946
+
947
+ // Path (shortened if too long)
948
+ const maxPathLen = 45;
949
+ const pathDisplay = worktree.path.length > maxPathLen
950
+ ? "..." + worktree.path.slice(-maxPathLen + 3)
951
+ : worktree.path;
952
+ descParts.push(pathDisplay);
953
+
405
954
  return {
406
- name: label,
407
- description: `${worktree.path} - ${shortHead}`,
955
+ name: displayName,
956
+ description: descParts.join(" | "),
408
957
  value: worktree,
409
958
  };
410
959
  });
411
960
 
961
+ // Don't show create option in delete selection mode
962
+ if (this.isSelectingForDelete) {
963
+ return worktreeOptions;
964
+ }
965
+
412
966
  return [createOption, ...worktreeOptions];
413
967
  }
414
968
 
969
+ private formatRelativeDate(date: Date): string {
970
+ const now = new Date();
971
+ const diffMs = now.getTime() - date.getTime();
972
+ const diffMins = Math.floor(diffMs / (1000 * 60));
973
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
974
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
975
+
976
+ if (diffMins < 1) return "just now";
977
+ if (diffMins < 60) return `${diffMins}m ago`;
978
+ if (diffHours < 24) return `${diffHours}h ago`;
979
+ if (diffDays < 7) return `${diffDays}d ago`;
980
+ if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`;
981
+ if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo ago`;
982
+ return `${Math.floor(diffDays / 365)}y ago`;
983
+ }
984
+
415
985
  private setStatus(message: string, level: StatusLevel): void {
416
986
  this.statusText.content = message;
417
987
  this.statusText.fg = statusColors[level];
@@ -573,7 +1143,7 @@ class WorktreeSelector {
573
1143
 
574
1144
  this.selectElement.visible = true;
575
1145
  this.instructions.content =
576
- "↑/↓ navigate • Enter open • d delete • n new • r refresh • q quit";
1146
+ "↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit";
577
1147
  this.selectElement.focus();
578
1148
  this.loadWorktrees();
579
1149
  }
@@ -640,6 +1210,307 @@ class WorktreeSelector {
640
1210
  this.hideConfirmDialog();
641
1211
  }
642
1212
 
1213
+ // ========== Multi-select delete mode methods ==========
1214
+
1215
+ private enterSelectMode(): void {
1216
+ if (!this.repoRoot) {
1217
+ this.setStatus("No git repository found.", "error");
1218
+ return;
1219
+ }
1220
+
1221
+ const worktrees = listWorktrees(this.repoRoot);
1222
+ // Filter out main worktree
1223
+ const deletableWorktrees = worktrees.filter(
1224
+ (wt) => !isMainWorktree(this.repoRoot!, wt.path)
1225
+ );
1226
+
1227
+ if (deletableWorktrees.length === 0) {
1228
+ this.setStatus("No worktrees available for deletion.", "warning");
1229
+ return;
1230
+ }
1231
+
1232
+ this.isSelectingForDelete = true;
1233
+ this.selectedForDelete.clear();
1234
+
1235
+ // Rebuild options to show checkboxes
1236
+ this.selectElement.options = this.buildOptions(worktrees);
1237
+ this.instructions.content =
1238
+ "Enter toggle selection • d confirm delete • Esc cancel";
1239
+ this.setStatus("Select worktrees to delete, then press 'd' to confirm.", "info");
1240
+ this.renderer.requestRender();
1241
+ }
1242
+
1243
+ private exitSelectMode(): void {
1244
+ this.isSelectingForDelete = false;
1245
+ this.selectedForDelete.clear();
1246
+ this.loadWorktrees();
1247
+ this.instructions.content =
1248
+ "↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit";
1249
+ this.renderer.requestRender();
1250
+ }
1251
+
1252
+ private toggleWorktreeSelection(): void {
1253
+ const selectedIndex = this.selectElement.getSelectedIndex();
1254
+ const option = this.selectElement.options[selectedIndex];
1255
+ if (!option) return;
1256
+
1257
+ const worktree = option.value as WorktreeInfo;
1258
+ if (!worktree.path) return;
1259
+
1260
+ // Prevent selecting main worktree
1261
+ if (this.repoRoot && isMainWorktree(this.repoRoot, worktree.path)) {
1262
+ this.setStatus("Cannot delete the main worktree.", "warning");
1263
+ return;
1264
+ }
1265
+
1266
+ if (this.selectedForDelete.has(worktree.path)) {
1267
+ this.selectedForDelete.delete(worktree.path);
1268
+ } else {
1269
+ this.selectedForDelete.add(worktree.path);
1270
+ }
1271
+
1272
+ // Rebuild options to update checkboxes
1273
+ if (this.repoRoot) {
1274
+ const worktrees = listWorktrees(this.repoRoot);
1275
+ this.selectElement.options = this.buildOptions(worktrees);
1276
+ // Restore selection index
1277
+ this.selectElement.setSelectedIndex(selectedIndex);
1278
+ }
1279
+
1280
+ const count = this.selectedForDelete.size;
1281
+ this.setStatus(
1282
+ count === 0
1283
+ ? "Select worktrees to delete, then press 'd' to confirm."
1284
+ : `${count} worktree${count === 1 ? "" : "s"} selected for deletion.`,
1285
+ "info"
1286
+ );
1287
+ this.renderer.requestRender();
1288
+ }
1289
+
1290
+ private confirmBatchDelete(): void {
1291
+ if (this.selectedForDelete.size === 0) {
1292
+ this.setStatus("No worktrees selected. Use Enter to select.", "warning");
1293
+ return;
1294
+ }
1295
+
1296
+ // Get the worktree info for selected paths
1297
+ if (!this.repoRoot) return;
1298
+
1299
+ const worktrees = listWorktrees(this.repoRoot);
1300
+ const toDelete = worktrees.filter((wt) =>
1301
+ this.selectedForDelete.has(wt.path)
1302
+ );
1303
+
1304
+ // Show batch confirmation dialog
1305
+ this.showBatchDeleteConfirmation(toDelete);
1306
+ }
1307
+
1308
+ private showBatchDeleteConfirmation(worktrees: WorktreeInfo[]): void {
1309
+ this.isConfirming = true;
1310
+ this.isSelectingForDelete = false;
1311
+ this.selectElement.visible = false;
1312
+ this.selectElement.blur();
1313
+
1314
+ // Check if any have uncommitted changes
1315
+ const dirtyWorktrees = worktrees.filter((wt) =>
1316
+ hasUncommittedChanges(wt.path)
1317
+ );
1318
+ const hasDirty = dirtyWorktrees.length > 0;
1319
+
1320
+ const count = worktrees.length;
1321
+ const title = `Delete ${count} worktree${count === 1 ? "" : "s"}`;
1322
+
1323
+ this.confirmContainer = new BoxRenderable(this.renderer, {
1324
+ id: "confirm-container",
1325
+ position: "absolute",
1326
+ left: 2,
1327
+ top: 3,
1328
+ width: 76,
1329
+ height: hasDirty ? 12 : 10,
1330
+ borderStyle: "single",
1331
+ borderColor: "#F59E0B",
1332
+ title,
1333
+ titleAlignment: "center",
1334
+ backgroundColor: "#0F172A",
1335
+ border: true,
1336
+ });
1337
+ this.renderer.root.add(this.confirmContainer);
1338
+
1339
+ let yOffset = 1;
1340
+
1341
+ // Warning for dirty worktrees
1342
+ if (hasDirty) {
1343
+ const warningText = new TextRenderable(this.renderer, {
1344
+ id: "confirm-warning",
1345
+ position: "absolute",
1346
+ left: 1,
1347
+ top: yOffset,
1348
+ content: `⚠ ${dirtyWorktrees.length} worktree${dirtyWorktrees.length === 1 ? " has" : "s have"} uncommitted changes!`,
1349
+ fg: "#F59E0B",
1350
+ });
1351
+ this.confirmContainer.add(warningText);
1352
+ yOffset += 2;
1353
+ }
1354
+
1355
+ // List worktrees to be deleted
1356
+ const branchNames = worktrees
1357
+ .map((wt) => wt.branch || basename(wt.path))
1358
+ .slice(0, 3);
1359
+ const displayList =
1360
+ branchNames.join(", ") + (worktrees.length > 3 ? `, +${worktrees.length - 3} more` : "");
1361
+
1362
+ const listText = new TextRenderable(this.renderer, {
1363
+ id: "confirm-list",
1364
+ position: "absolute",
1365
+ left: 1,
1366
+ top: yOffset,
1367
+ content: `Worktrees: ${displayList}`,
1368
+ fg: "#94A3B8",
1369
+ });
1370
+ this.confirmContainer.add(listText);
1371
+ yOffset += 2;
1372
+
1373
+ // Build options
1374
+ const options: SelectOption[] = [
1375
+ {
1376
+ name: "Unlink all (default)",
1377
+ description: "Remove worktree directories, keep branches for later use",
1378
+ value: CONFIRM_UNLINK_VALUE,
1379
+ },
1380
+ {
1381
+ name: "Delete all",
1382
+ description: "Remove worktrees AND delete local branches (never remote)",
1383
+ value: CONFIRM_DELETE_VALUE,
1384
+ },
1385
+ {
1386
+ name: "Cancel",
1387
+ description: "Go back without changes",
1388
+ value: CONFIRM_CANCEL_VALUE,
1389
+ },
1390
+ ];
1391
+
1392
+ this.confirmSelect = new SelectRenderable(this.renderer, {
1393
+ id: "confirm-select",
1394
+ position: "absolute",
1395
+ left: 1,
1396
+ top: yOffset,
1397
+ width: 72,
1398
+ height: 4,
1399
+ options,
1400
+ backgroundColor: "#0F172A",
1401
+ focusedBackgroundColor: "#1E293B",
1402
+ selectedBackgroundColor: "#1E3A5F",
1403
+ textColor: "#E2E8F0",
1404
+ selectedTextColor: "#38BDF8",
1405
+ descriptionColor: "#94A3B8",
1406
+ selectedDescriptionColor: "#E2E8F0",
1407
+ showDescription: true,
1408
+ wrapSelection: true,
1409
+ });
1410
+ this.confirmContainer.add(this.confirmSelect);
1411
+
1412
+ // Store worktrees for batch deletion
1413
+ const worktreesToDelete = worktrees;
1414
+
1415
+ this.confirmSelect.on(
1416
+ SelectRenderableEvents.ITEM_SELECTED,
1417
+ (_index: number, option: SelectOption) => {
1418
+ this.handleBatchConfirmAction(
1419
+ option.value as ConfirmAction,
1420
+ worktreesToDelete,
1421
+ hasDirty
1422
+ );
1423
+ }
1424
+ );
1425
+
1426
+ this.instructions.content =
1427
+ "↑/↓ select action • Enter confirm • Esc cancel";
1428
+ this.setStatus(
1429
+ hasDirty
1430
+ ? "Warning: Some worktrees have uncommitted changes!"
1431
+ : `Ready to remove ${count} worktree${count === 1 ? "" : "s"}.`,
1432
+ hasDirty ? "warning" : "info"
1433
+ );
1434
+
1435
+ this.confirmSelect.focus();
1436
+ this.renderer.requestRender();
1437
+ }
1438
+
1439
+ private handleBatchConfirmAction(
1440
+ action: ConfirmAction,
1441
+ worktrees: WorktreeInfo[],
1442
+ hasDirty: boolean
1443
+ ): void {
1444
+ if (action === CONFIRM_CANCEL_VALUE) {
1445
+ this.selectedForDelete.clear();
1446
+ this.hideConfirmDialog();
1447
+ return;
1448
+ }
1449
+
1450
+ if (!this.repoRoot) {
1451
+ this.selectedForDelete.clear();
1452
+ this.hideConfirmDialog();
1453
+ return;
1454
+ }
1455
+
1456
+ const count = worktrees.length;
1457
+ let successCount = 0;
1458
+ let failCount = 0;
1459
+
1460
+ for (const worktree of worktrees) {
1461
+ const branchName = worktree.branch || basename(worktree.path);
1462
+ const isDirty = hasUncommittedChanges(worktree.path);
1463
+
1464
+ if (action === CONFIRM_UNLINK_VALUE) {
1465
+ const result = unlinkWorktree(this.repoRoot, worktree.path, isDirty);
1466
+ if (result.success) {
1467
+ successCount++;
1468
+ } else {
1469
+ failCount++;
1470
+ }
1471
+ } else if (action === CONFIRM_DELETE_VALUE) {
1472
+ if (!worktree.branch) {
1473
+ // Can't delete branch for detached HEAD, just unlink
1474
+ const result = unlinkWorktree(this.repoRoot, worktree.path, isDirty);
1475
+ if (result.success) {
1476
+ successCount++;
1477
+ } else {
1478
+ failCount++;
1479
+ }
1480
+ } else {
1481
+ const result = deleteWorktree(
1482
+ this.repoRoot,
1483
+ worktree.path,
1484
+ worktree.branch,
1485
+ isDirty
1486
+ );
1487
+ if (result.success) {
1488
+ successCount++;
1489
+ } else {
1490
+ failCount++;
1491
+ }
1492
+ }
1493
+ }
1494
+ }
1495
+
1496
+ this.selectedForDelete.clear();
1497
+
1498
+ if (failCount === 0) {
1499
+ const actionWord = action === CONFIRM_UNLINK_VALUE ? "unlinked" : "deleted";
1500
+ this.setStatus(
1501
+ `Successfully ${actionWord} ${successCount} worktree${successCount === 1 ? "" : "s"}.`,
1502
+ "success"
1503
+ );
1504
+ } else {
1505
+ this.setStatus(
1506
+ `Completed with ${successCount} success, ${failCount} failed.`,
1507
+ "warning"
1508
+ );
1509
+ }
1510
+
1511
+ this.hideConfirmDialog();
1512
+ }
1513
+
643
1514
  private cleanup(shouldExit: boolean): void {
644
1515
  this.selectElement.blur();
645
1516
  if (this.branchInput) {