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/README.md +84 -4
- package/package.json +1 -1
- package/src/config.ts +74 -0
- package/src/git.ts +76 -3
- package/src/hooks.ts +62 -0
- package/src/opencode.ts +40 -0
- package/src/types.ts +4 -0
- package/src/ui.ts +894 -23
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 •
|
|
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
|
|
326
|
+
// 'd' for entering delete selection mode
|
|
223
327
|
if (key.name === "d") {
|
|
224
|
-
this.
|
|
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 •
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
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
|
|
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:
|
|
407
|
-
description:
|
|
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 •
|
|
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) {
|