@jasonfutch/worktree-manager 1.0.0
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/LICENSE +21 -0
- package/README.md +186 -0
- package/dist/constants.d.ts +152 -0
- package/dist/constants.js +148 -0
- package/dist/errors.d.ts +57 -0
- package/dist/errors.js +117 -0
- package/dist/git/worktree.d.ts +57 -0
- package/dist/git/worktree.js +272 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +336 -0
- package/dist/tui/app.d.ts +39 -0
- package/dist/tui/app.js +842 -0
- package/dist/types.d.ts +37 -0
- package/dist/types.js +4 -0
- package/dist/utils/checks.d.ts +12 -0
- package/dist/utils/checks.js +109 -0
- package/dist/utils/helpers.d.ts +11 -0
- package/dist/utils/helpers.js +20 -0
- package/dist/utils/launch.d.ts +17 -0
- package/dist/utils/launch.js +223 -0
- package/dist/utils/shell.d.ts +28 -0
- package/dist/utils/shell.js +94 -0
- package/dist/version.d.ts +6 -0
- package/dist/version.js +11 -0
- package/package.json +68 -0
package/dist/tui/app.js
ADDED
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
import blessed from 'blessed';
|
|
2
|
+
import { GitWorktree } from '../git/worktree.js';
|
|
3
|
+
import { truncate } from '../utils/helpers.js';
|
|
4
|
+
import { spawn } from 'child_process';
|
|
5
|
+
import { escapeAppleScript, escapeWindowsArg } from '../utils/shell.js';
|
|
6
|
+
import { UI, PLATFORM, GIT, getInstallInstruction } from '../constants.js';
|
|
7
|
+
import { GitCommandError } from '../errors.js';
|
|
8
|
+
import { VERSION } from '../version.js';
|
|
9
|
+
export class WorktreeManagerTUI {
|
|
10
|
+
screen;
|
|
11
|
+
mainBox;
|
|
12
|
+
worktreeList;
|
|
13
|
+
detailBox;
|
|
14
|
+
statusBar;
|
|
15
|
+
helpBox;
|
|
16
|
+
inputBox = null;
|
|
17
|
+
git;
|
|
18
|
+
worktrees = [];
|
|
19
|
+
selectedIndex = 0;
|
|
20
|
+
repoPath;
|
|
21
|
+
repoName;
|
|
22
|
+
isModalOpen = false;
|
|
23
|
+
autoRefreshInterval = null;
|
|
24
|
+
AUTO_REFRESH_MS = 15000;
|
|
25
|
+
constructor(repoPath) {
|
|
26
|
+
this.repoPath = repoPath;
|
|
27
|
+
this.git = new GitWorktree(repoPath);
|
|
28
|
+
this.repoName = this.git.getRepoName();
|
|
29
|
+
// Create screen
|
|
30
|
+
this.screen = blessed.screen({
|
|
31
|
+
smartCSR: true,
|
|
32
|
+
title: `Worktree Manager - ${this.repoName}`,
|
|
33
|
+
fullUnicode: true
|
|
34
|
+
});
|
|
35
|
+
// Create main layout
|
|
36
|
+
this.mainBox = blessed.box({
|
|
37
|
+
parent: this.screen,
|
|
38
|
+
top: 0,
|
|
39
|
+
left: 0,
|
|
40
|
+
width: '100%',
|
|
41
|
+
height: '100%-1',
|
|
42
|
+
style: {
|
|
43
|
+
bg: 'default'
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
// Worktree list (left panel)
|
|
47
|
+
this.worktreeList = blessed.list({
|
|
48
|
+
parent: this.mainBox,
|
|
49
|
+
label: ` 🌳 Worktrees - ${this.repoName} `,
|
|
50
|
+
top: 0,
|
|
51
|
+
left: 0,
|
|
52
|
+
width: '50%',
|
|
53
|
+
height: '100%-3',
|
|
54
|
+
border: { type: 'line' },
|
|
55
|
+
style: {
|
|
56
|
+
border: { fg: 'cyan' },
|
|
57
|
+
selected: { bg: 'blue', fg: 'white', bold: true },
|
|
58
|
+
item: { fg: 'default' }
|
|
59
|
+
},
|
|
60
|
+
keys: false,
|
|
61
|
+
vi: false,
|
|
62
|
+
mouse: true,
|
|
63
|
+
scrollbar: {
|
|
64
|
+
ch: '│',
|
|
65
|
+
style: { fg: 'cyan' }
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
// Detail panel (right)
|
|
69
|
+
this.detailBox = blessed.box({
|
|
70
|
+
parent: this.mainBox,
|
|
71
|
+
label: ' 📋 Details ',
|
|
72
|
+
top: 0,
|
|
73
|
+
left: '50%',
|
|
74
|
+
width: '50%',
|
|
75
|
+
height: '40%',
|
|
76
|
+
border: { type: 'line' },
|
|
77
|
+
style: {
|
|
78
|
+
border: { fg: 'green' }
|
|
79
|
+
},
|
|
80
|
+
content: '',
|
|
81
|
+
tags: true,
|
|
82
|
+
padding: 1
|
|
83
|
+
});
|
|
84
|
+
// Help panel (bottom right)
|
|
85
|
+
this.helpBox = blessed.box({
|
|
86
|
+
parent: this.mainBox,
|
|
87
|
+
label: ' ⌨️ Keybindings ',
|
|
88
|
+
top: '40%',
|
|
89
|
+
left: '50%',
|
|
90
|
+
width: '50%',
|
|
91
|
+
height: '60%-3',
|
|
92
|
+
border: { type: 'line' },
|
|
93
|
+
style: {
|
|
94
|
+
border: { fg: 'yellow' }
|
|
95
|
+
},
|
|
96
|
+
tags: true,
|
|
97
|
+
padding: 1,
|
|
98
|
+
content: `{bold}Navigation{/bold}
|
|
99
|
+
{cyan-fg}↑/k{/} Move up {cyan-fg}↓/j{/} Move down
|
|
100
|
+
{cyan-fg}Enter{/} Select {cyan-fg}r{/} Refresh
|
|
101
|
+
|
|
102
|
+
{bold}Actions{/bold}
|
|
103
|
+
{green-fg}n{/} New worktree {red-fg}d{/} Delete worktree
|
|
104
|
+
{blue-fg}e{/} Open Editor {magenta-fg}a{/} Launch AI
|
|
105
|
+
{magenta-fg}t{/} Open Terminal
|
|
106
|
+
|
|
107
|
+
{bold}General{/bold}
|
|
108
|
+
{yellow-fg}q{/} Quit {yellow-fg}?{/} Help`
|
|
109
|
+
});
|
|
110
|
+
// Status bar
|
|
111
|
+
this.statusBar = blessed.box({
|
|
112
|
+
parent: this.screen,
|
|
113
|
+
bottom: 0,
|
|
114
|
+
left: 0,
|
|
115
|
+
width: '100%',
|
|
116
|
+
height: 1,
|
|
117
|
+
style: {
|
|
118
|
+
bg: 'blue',
|
|
119
|
+
fg: 'white'
|
|
120
|
+
},
|
|
121
|
+
content: ` v${VERSION} | Press ? for help | q to quit`,
|
|
122
|
+
tags: true
|
|
123
|
+
});
|
|
124
|
+
this.setupKeyBindings();
|
|
125
|
+
}
|
|
126
|
+
setupKeyBindings() {
|
|
127
|
+
// Quit
|
|
128
|
+
this.screen.key(['q', 'C-c'], () => {
|
|
129
|
+
this.stopAutoRefresh();
|
|
130
|
+
this.screen.destroy();
|
|
131
|
+
process.exit(0);
|
|
132
|
+
});
|
|
133
|
+
// Navigation
|
|
134
|
+
this.screen.key(['j', 'down'], () => this.moveSelection(1));
|
|
135
|
+
this.screen.key(['k', 'up'], () => this.moveSelection(-1));
|
|
136
|
+
// Refresh
|
|
137
|
+
this.screen.key(['r'], () => this.refresh());
|
|
138
|
+
// Create new worktree
|
|
139
|
+
this.screen.key(['n'], () => this.promptCreateWorktree());
|
|
140
|
+
// Delete worktree
|
|
141
|
+
this.screen.key(['d'], () => this.promptDeleteWorktree());
|
|
142
|
+
// Open in Editor
|
|
143
|
+
this.screen.key(['e'], () => this.openInEditor());
|
|
144
|
+
// Open terminal
|
|
145
|
+
this.screen.key(['t'], () => this.openTerminal());
|
|
146
|
+
// Launch AI
|
|
147
|
+
this.screen.key(['a'], () => this.launchAI());
|
|
148
|
+
// Enter - show details
|
|
149
|
+
this.screen.key(['enter'], () => this.showDetails());
|
|
150
|
+
// Show help modal
|
|
151
|
+
this.screen.key(['?'], () => this.showHelp());
|
|
152
|
+
// Focus list by default
|
|
153
|
+
this.worktreeList.focus();
|
|
154
|
+
}
|
|
155
|
+
moveSelection(delta) {
|
|
156
|
+
if (this.isModalOpen)
|
|
157
|
+
return;
|
|
158
|
+
const newIndex = Math.max(0, Math.min(this.worktrees.length - 1, this.selectedIndex + delta));
|
|
159
|
+
if (newIndex !== this.selectedIndex) {
|
|
160
|
+
this.selectedIndex = newIndex;
|
|
161
|
+
this.worktreeList.select(this.selectedIndex);
|
|
162
|
+
this.updateDetails();
|
|
163
|
+
this.screen.render();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
updateDetails() {
|
|
167
|
+
const wt = this.worktrees[this.selectedIndex];
|
|
168
|
+
if (!wt) {
|
|
169
|
+
this.detailBox.setContent('No worktree selected');
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const mainBadge = wt.isMain ? ' {green-fg}[MAIN]{/}' : '';
|
|
173
|
+
const lockedBadge = wt.isLocked ? ' {red-fg}[LOCKED]{/}' : '';
|
|
174
|
+
this.detailBox.setContent(`{bold}Repo:{/} {magenta-fg}${this.repoName}{/}
|
|
175
|
+
{bold}Branch:{/} {cyan-fg}${wt.branch}{/}${mainBadge}${lockedBadge}
|
|
176
|
+
{bold}Path:{/} ${wt.path}
|
|
177
|
+
{bold}Commit:{/} {yellow-fg}${wt.commit}{/}
|
|
178
|
+
{bold}Name:{/} ${wt.name}
|
|
179
|
+
{bold}Status:{/} ${wt.isBare ? 'Bare' : 'Active'}`);
|
|
180
|
+
}
|
|
181
|
+
async refresh() {
|
|
182
|
+
this.setStatus(' Loading worktrees...', 'blue');
|
|
183
|
+
try {
|
|
184
|
+
this.worktrees = await this.git.list();
|
|
185
|
+
this.updateList();
|
|
186
|
+
this.updateDetails();
|
|
187
|
+
this.setStatus(` v${VERSION} | ${this.worktrees.length} worktree${this.worktrees.length === 1 ? '' : 's'} | ? for help`);
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
const message = error instanceof GitCommandError
|
|
191
|
+
? error.getUserMessage()
|
|
192
|
+
: (error instanceof Error ? error.message : String(error));
|
|
193
|
+
this.setStatus(` Error: ${message}`, 'red');
|
|
194
|
+
}
|
|
195
|
+
this.screen.render();
|
|
196
|
+
}
|
|
197
|
+
updateList() {
|
|
198
|
+
const items = this.worktrees.map((wt) => {
|
|
199
|
+
const prefix = wt.isMain ? '★ ' : ' ';
|
|
200
|
+
const locked = wt.isLocked ? ' 🔒' : '';
|
|
201
|
+
return `${prefix}${truncate(wt.branch, UI.BRANCH_TRUNCATE_LENGTH)}${locked}`;
|
|
202
|
+
});
|
|
203
|
+
this.worktreeList.setItems(items);
|
|
204
|
+
if (this.selectedIndex >= items.length) {
|
|
205
|
+
this.selectedIndex = Math.max(0, items.length - 1);
|
|
206
|
+
}
|
|
207
|
+
this.worktreeList.select(this.selectedIndex);
|
|
208
|
+
}
|
|
209
|
+
setStatus(message, color = 'blue') {
|
|
210
|
+
this.statusBar.style.bg = color;
|
|
211
|
+
this.statusBar.setContent(message);
|
|
212
|
+
this.screen.render();
|
|
213
|
+
}
|
|
214
|
+
showConfirm(message, label, borderColor) {
|
|
215
|
+
return new Promise((resolve) => {
|
|
216
|
+
const box = blessed.box({
|
|
217
|
+
parent: this.screen,
|
|
218
|
+
left: 'center',
|
|
219
|
+
top: 'center',
|
|
220
|
+
width: UI.MODAL_WIDTH,
|
|
221
|
+
height: 5,
|
|
222
|
+
border: { type: 'line' },
|
|
223
|
+
style: {
|
|
224
|
+
border: { fg: borderColor },
|
|
225
|
+
bg: 'default'
|
|
226
|
+
},
|
|
227
|
+
label: ` ${label} `,
|
|
228
|
+
content: `${message} (y/n)`,
|
|
229
|
+
padding: { left: 1, top: 1 }
|
|
230
|
+
});
|
|
231
|
+
this.screen.render();
|
|
232
|
+
const yesHandler = () => cleanup(true);
|
|
233
|
+
const noHandler = () => cleanup(false);
|
|
234
|
+
const cleanup = (result) => {
|
|
235
|
+
this.screen.unkey('y', yesHandler);
|
|
236
|
+
this.screen.unkey('Y', yesHandler);
|
|
237
|
+
this.screen.unkey('n', noHandler);
|
|
238
|
+
this.screen.unkey('N', noHandler);
|
|
239
|
+
this.screen.unkey('escape', noHandler);
|
|
240
|
+
box.destroy();
|
|
241
|
+
this.screen.render();
|
|
242
|
+
resolve(result);
|
|
243
|
+
};
|
|
244
|
+
this.screen.key(['y', 'Y'], yesHandler);
|
|
245
|
+
this.screen.key(['n', 'N', 'escape'], noHandler);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
showTextInputConfirm(message, label, borderColor, requiredInput) {
|
|
249
|
+
return new Promise((resolve) => {
|
|
250
|
+
const box = blessed.box({
|
|
251
|
+
parent: this.screen,
|
|
252
|
+
left: 'center',
|
|
253
|
+
top: 'center',
|
|
254
|
+
width: UI.MODAL_WIDTH,
|
|
255
|
+
height: 8,
|
|
256
|
+
border: { type: 'line' },
|
|
257
|
+
style: {
|
|
258
|
+
border: { fg: borderColor },
|
|
259
|
+
bg: 'default'
|
|
260
|
+
},
|
|
261
|
+
label: ` ${label} `,
|
|
262
|
+
padding: { left: 1, top: 0 }
|
|
263
|
+
});
|
|
264
|
+
blessed.text({
|
|
265
|
+
parent: box,
|
|
266
|
+
top: 0,
|
|
267
|
+
left: 0,
|
|
268
|
+
content: message,
|
|
269
|
+
style: { fg: 'default' }
|
|
270
|
+
});
|
|
271
|
+
blessed.text({
|
|
272
|
+
parent: box,
|
|
273
|
+
top: 1,
|
|
274
|
+
left: 0,
|
|
275
|
+
content: `Type "${requiredInput}" to confirm (Esc to cancel):`,
|
|
276
|
+
style: { fg: 'gray' }
|
|
277
|
+
});
|
|
278
|
+
const input = blessed.textbox({
|
|
279
|
+
parent: box,
|
|
280
|
+
top: 2,
|
|
281
|
+
left: 0,
|
|
282
|
+
width: UI.MODAL_WIDTH - 6,
|
|
283
|
+
height: 3,
|
|
284
|
+
border: { type: 'line' },
|
|
285
|
+
style: {
|
|
286
|
+
border: { fg: 'cyan' },
|
|
287
|
+
focus: { border: { fg: 'green' } }
|
|
288
|
+
},
|
|
289
|
+
inputOnFocus: true
|
|
290
|
+
});
|
|
291
|
+
this.screen.render();
|
|
292
|
+
input.focus();
|
|
293
|
+
const cleanup = (result) => {
|
|
294
|
+
box.destroy();
|
|
295
|
+
this.screen.render();
|
|
296
|
+
resolve(result);
|
|
297
|
+
};
|
|
298
|
+
input.key(['escape'], () => cleanup(false));
|
|
299
|
+
input.key(['enter'], () => {
|
|
300
|
+
const value = input.getValue().trim();
|
|
301
|
+
cleanup(value === requiredInput);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
isProtectedBranch(branch) {
|
|
306
|
+
const normalizedBranch = branch.toLowerCase();
|
|
307
|
+
return GIT.PROTECTED_BRANCHES.some(protected_branch => normalizedBranch === protected_branch.toLowerCase());
|
|
308
|
+
}
|
|
309
|
+
showHelp() {
|
|
310
|
+
if (this.isModalOpen)
|
|
311
|
+
return;
|
|
312
|
+
this.isModalOpen = true;
|
|
313
|
+
const helpContent = `{bold}{cyan-fg}Worktree Manager v${VERSION}{/}
|
|
314
|
+
|
|
315
|
+
{bold}Navigation{/}
|
|
316
|
+
{cyan-fg}↑/k{/} Move selection up
|
|
317
|
+
{cyan-fg}↓/j{/} Move selection down
|
|
318
|
+
{cyan-fg}Enter{/} Show worktree details
|
|
319
|
+
{cyan-fg}r{/} Refresh worktree list
|
|
320
|
+
|
|
321
|
+
{bold}Worktree Actions{/}
|
|
322
|
+
{green-fg}n{/} Create new worktree
|
|
323
|
+
{red-fg}d{/} Delete selected worktree
|
|
324
|
+
|
|
325
|
+
{bold}Open in Editor{/}
|
|
326
|
+
{blue-fg}e{/} Select editor (VS Code, Cursor, Zed, etc.)
|
|
327
|
+
|
|
328
|
+
{bold}Terminal & AI{/}
|
|
329
|
+
{magenta-fg}t{/} Open terminal in worktree
|
|
330
|
+
{magenta-fg}a{/} Launch AI (Claude, Gemini, Codex)
|
|
331
|
+
|
|
332
|
+
{bold}General{/}
|
|
333
|
+
{yellow-fg}?{/} Show this help
|
|
334
|
+
{yellow-fg}q{/} Quit application
|
|
335
|
+
|
|
336
|
+
{gray-fg}Press any key to close this help...{/}`;
|
|
337
|
+
const box = blessed.box({
|
|
338
|
+
parent: this.screen,
|
|
339
|
+
left: 'center',
|
|
340
|
+
top: 'center',
|
|
341
|
+
width: UI.MODAL_WIDTH,
|
|
342
|
+
height: UI.HELP_MODAL_HEIGHT,
|
|
343
|
+
border: { type: 'line' },
|
|
344
|
+
style: {
|
|
345
|
+
border: { fg: 'cyan' },
|
|
346
|
+
bg: 'default'
|
|
347
|
+
},
|
|
348
|
+
label: ' Help ',
|
|
349
|
+
content: helpContent,
|
|
350
|
+
tags: true,
|
|
351
|
+
padding: { left: 1, top: 1 }
|
|
352
|
+
});
|
|
353
|
+
this.screen.render();
|
|
354
|
+
const closeHelp = () => {
|
|
355
|
+
this.screen.unkey('escape', closeHelp);
|
|
356
|
+
this.screen.unkey('enter', closeHelp);
|
|
357
|
+
this.screen.unkey('space', closeHelp);
|
|
358
|
+
this.screen.removeListener('keypress', closeHelp);
|
|
359
|
+
box.destroy();
|
|
360
|
+
this.isModalOpen = false;
|
|
361
|
+
this.screen.render();
|
|
362
|
+
};
|
|
363
|
+
// Listen for specific keys to close (not '?' or 'q' to avoid conflicts)
|
|
364
|
+
this.screen.key(['escape', 'enter', 'space'], closeHelp);
|
|
365
|
+
this.screen.once('keypress', closeHelp);
|
|
366
|
+
}
|
|
367
|
+
async promptCreateWorktree() {
|
|
368
|
+
if (this.isModalOpen)
|
|
369
|
+
return;
|
|
370
|
+
this.isModalOpen = true;
|
|
371
|
+
this.setStatus(' Loading branches...', 'blue');
|
|
372
|
+
// Fetch available branches
|
|
373
|
+
const branches = await this.git.getBranches();
|
|
374
|
+
if (branches.length === 0) {
|
|
375
|
+
this.setStatus(' Warning: Could not load branches. Using default "main"', 'yellow');
|
|
376
|
+
}
|
|
377
|
+
const form = blessed.form({
|
|
378
|
+
parent: this.screen,
|
|
379
|
+
keys: false,
|
|
380
|
+
left: 'center',
|
|
381
|
+
top: 'center',
|
|
382
|
+
width: 60,
|
|
383
|
+
height: UI.CREATE_FORM_HEIGHT,
|
|
384
|
+
border: { type: 'line' },
|
|
385
|
+
style: {
|
|
386
|
+
border: { fg: 'green' },
|
|
387
|
+
bg: 'default'
|
|
388
|
+
},
|
|
389
|
+
label: ' Create New Worktree '
|
|
390
|
+
});
|
|
391
|
+
blessed.text({
|
|
392
|
+
parent: form,
|
|
393
|
+
top: 1,
|
|
394
|
+
left: 2,
|
|
395
|
+
content: 'Branch name:',
|
|
396
|
+
style: { fg: 'default' }
|
|
397
|
+
});
|
|
398
|
+
const branchInput = blessed.textbox({
|
|
399
|
+
parent: form,
|
|
400
|
+
top: 2,
|
|
401
|
+
left: 2,
|
|
402
|
+
width: 54,
|
|
403
|
+
height: 3,
|
|
404
|
+
border: { type: 'line' },
|
|
405
|
+
style: {
|
|
406
|
+
border: { fg: 'cyan' },
|
|
407
|
+
focus: { border: { fg: 'green' } }
|
|
408
|
+
},
|
|
409
|
+
inputOnFocus: true
|
|
410
|
+
});
|
|
411
|
+
blessed.text({
|
|
412
|
+
parent: form,
|
|
413
|
+
top: 6,
|
|
414
|
+
left: 2,
|
|
415
|
+
content: 'Base branch:',
|
|
416
|
+
style: { fg: 'default' }
|
|
417
|
+
});
|
|
418
|
+
const baseBranchList = blessed.list({
|
|
419
|
+
parent: form,
|
|
420
|
+
top: 7,
|
|
421
|
+
left: 2,
|
|
422
|
+
width: 54,
|
|
423
|
+
height: 8,
|
|
424
|
+
border: { type: 'line' },
|
|
425
|
+
style: {
|
|
426
|
+
border: { fg: 'cyan' },
|
|
427
|
+
selected: { bg: 'blue', fg: 'white' },
|
|
428
|
+
focus: { border: { fg: 'green' } }
|
|
429
|
+
},
|
|
430
|
+
keys: true,
|
|
431
|
+
vi: true,
|
|
432
|
+
items: branches.length > 0 ? branches : ['main']
|
|
433
|
+
});
|
|
434
|
+
// Pre-select 'main' if it exists
|
|
435
|
+
const mainIndex = branches.indexOf('main');
|
|
436
|
+
if (mainIndex >= 0) {
|
|
437
|
+
baseBranchList.select(mainIndex);
|
|
438
|
+
}
|
|
439
|
+
blessed.text({
|
|
440
|
+
parent: form,
|
|
441
|
+
top: 16,
|
|
442
|
+
left: 2,
|
|
443
|
+
content: 'Tab to switch | ↑↓ to select branch | Enter to create | Esc to cancel',
|
|
444
|
+
style: { fg: 'gray' }
|
|
445
|
+
});
|
|
446
|
+
branchInput.focus();
|
|
447
|
+
this.setStatus(' Creating new worktree...', 'blue');
|
|
448
|
+
const submitForm = async () => {
|
|
449
|
+
this.isModalOpen = false;
|
|
450
|
+
const branch = branchInput.getValue().replace(/\t/g, '').trim();
|
|
451
|
+
const selectedIdx = baseBranchList.selected;
|
|
452
|
+
const branchItems = branches.length > 0 ? branches : ['main'];
|
|
453
|
+
const baseBranch = branchItems[selectedIdx] || 'main';
|
|
454
|
+
form.destroy();
|
|
455
|
+
this.worktreeList.focus();
|
|
456
|
+
if (branch) {
|
|
457
|
+
this.setStatus(` Creating worktree: ${branch} from ${baseBranch}...`, 'blue');
|
|
458
|
+
try {
|
|
459
|
+
await this.git.create({ branch, baseBranch });
|
|
460
|
+
await this.refresh();
|
|
461
|
+
this.setStatus(` Created worktree: ${branch}`, 'green');
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
const message = error instanceof GitCommandError
|
|
465
|
+
? error.getUserMessage()
|
|
466
|
+
: (error instanceof Error ? error.message : String(error));
|
|
467
|
+
this.setStatus(` Error: ${message}`, 'red');
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
this.setStatus(' Cancelled - no branch name provided', 'yellow');
|
|
472
|
+
}
|
|
473
|
+
this.screen.render();
|
|
474
|
+
};
|
|
475
|
+
const cancelForm = () => {
|
|
476
|
+
this.isModalOpen = false;
|
|
477
|
+
form.destroy();
|
|
478
|
+
this.worktreeList.focus();
|
|
479
|
+
this.setStatus(' Cancelled', 'blue');
|
|
480
|
+
this.screen.render();
|
|
481
|
+
};
|
|
482
|
+
branchInput.key(['escape'], cancelForm);
|
|
483
|
+
branchInput.key(['enter'], submitForm);
|
|
484
|
+
branchInput.key(['tab'], () => {
|
|
485
|
+
branchInput.setValue(branchInput.getValue().replace(/\t/g, ''));
|
|
486
|
+
branchInput.cancel();
|
|
487
|
+
baseBranchList.focus();
|
|
488
|
+
});
|
|
489
|
+
baseBranchList.key(['escape'], cancelForm);
|
|
490
|
+
baseBranchList.key(['enter'], submitForm);
|
|
491
|
+
baseBranchList.key(['tab'], () => {
|
|
492
|
+
branchInput.focus();
|
|
493
|
+
});
|
|
494
|
+
this.screen.render();
|
|
495
|
+
}
|
|
496
|
+
async promptDeleteWorktree() {
|
|
497
|
+
if (this.isModalOpen)
|
|
498
|
+
return;
|
|
499
|
+
const wt = this.worktrees[this.selectedIndex];
|
|
500
|
+
if (!wt)
|
|
501
|
+
return;
|
|
502
|
+
if (wt.isMain) {
|
|
503
|
+
this.setStatus(' Cannot delete main worktree', 'red');
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
this.isModalOpen = true;
|
|
507
|
+
const confirmed = await this.showConfirm(`Delete worktree "${wt.branch}"?`, 'Confirm Delete', 'red');
|
|
508
|
+
if (!confirmed) {
|
|
509
|
+
this.isModalOpen = false;
|
|
510
|
+
this.worktreeList.focus();
|
|
511
|
+
this.setStatus(' Delete cancelled', 'blue');
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const branchName = wt.branch;
|
|
515
|
+
this.setStatus(` Deleting worktree: ${branchName}...`, 'blue');
|
|
516
|
+
// Show loading indicator in center of screen
|
|
517
|
+
const loadingBox = blessed.box({
|
|
518
|
+
parent: this.screen,
|
|
519
|
+
left: 'center',
|
|
520
|
+
top: 'center',
|
|
521
|
+
width: 40,
|
|
522
|
+
height: 5,
|
|
523
|
+
border: { type: 'line' },
|
|
524
|
+
style: {
|
|
525
|
+
border: { fg: 'yellow' },
|
|
526
|
+
bg: 'default'
|
|
527
|
+
},
|
|
528
|
+
label: ' Please Wait ',
|
|
529
|
+
content: `\n Deleting worktree...\n This may take a moment.`,
|
|
530
|
+
tags: true
|
|
531
|
+
});
|
|
532
|
+
this.screen.render();
|
|
533
|
+
try {
|
|
534
|
+
await this.git.remove(wt.path, true);
|
|
535
|
+
loadingBox.destroy();
|
|
536
|
+
await this.refresh();
|
|
537
|
+
this.setStatus(` Deleted worktree: ${branchName}`, 'green');
|
|
538
|
+
// Ask about branch deletion (only for non-protected branches)
|
|
539
|
+
if (!this.isProtectedBranch(branchName)) {
|
|
540
|
+
const deleteBranch = await this.showTextInputConfirm(`Also delete branch "${branchName}"?`, 'Delete Branch', 'yellow', 'yes');
|
|
541
|
+
if (deleteBranch) {
|
|
542
|
+
this.setStatus(` Deleting branch: ${branchName}...`, 'blue');
|
|
543
|
+
try {
|
|
544
|
+
await this.git.deleteBranch(branchName, true);
|
|
545
|
+
this.setStatus(` Deleted worktree and branch: ${branchName}`, 'green');
|
|
546
|
+
}
|
|
547
|
+
catch (branchError) {
|
|
548
|
+
const message = branchError instanceof GitCommandError
|
|
549
|
+
? branchError.getUserMessage()
|
|
550
|
+
: (branchError instanceof Error ? branchError.message : String(branchError));
|
|
551
|
+
this.setStatus(` Worktree deleted, but branch deletion failed: ${message}`, 'yellow');
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
this.setStatus(` Deleted worktree: ${branchName} (branch kept)`, 'green');
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
loadingBox.destroy();
|
|
561
|
+
const message = error instanceof GitCommandError
|
|
562
|
+
? error.getUserMessage()
|
|
563
|
+
: (error instanceof Error ? error.message : String(error));
|
|
564
|
+
this.setStatus(` Error: ${message}`, 'red');
|
|
565
|
+
}
|
|
566
|
+
this.isModalOpen = false;
|
|
567
|
+
this.worktreeList.focus();
|
|
568
|
+
}
|
|
569
|
+
openInEditor() {
|
|
570
|
+
if (this.isModalOpen)
|
|
571
|
+
return;
|
|
572
|
+
const wt = this.worktrees[this.selectedIndex];
|
|
573
|
+
if (!wt)
|
|
574
|
+
return;
|
|
575
|
+
this.isModalOpen = true;
|
|
576
|
+
const editors = [
|
|
577
|
+
{ name: 'VS Code', command: 'code' },
|
|
578
|
+
{ name: 'Cursor', command: 'cursor' },
|
|
579
|
+
{ name: 'Zed', command: 'zed' },
|
|
580
|
+
{ name: 'WebStorm', command: 'webstorm' },
|
|
581
|
+
{ name: 'Sublime Text', command: 'subl' },
|
|
582
|
+
{ name: 'Neovim', command: 'nvim', terminal: true }
|
|
583
|
+
];
|
|
584
|
+
const list = blessed.list({
|
|
585
|
+
parent: this.screen,
|
|
586
|
+
left: 'center',
|
|
587
|
+
top: 'center',
|
|
588
|
+
width: UI.SELECTOR_WIDTH,
|
|
589
|
+
height: UI.EDITOR_SELECTOR_HEIGHT,
|
|
590
|
+
border: { type: 'line' },
|
|
591
|
+
style: {
|
|
592
|
+
border: { fg: 'blue' },
|
|
593
|
+
selected: { bg: 'blue', fg: 'white' },
|
|
594
|
+
bg: 'default'
|
|
595
|
+
},
|
|
596
|
+
label: ' Select Editor ',
|
|
597
|
+
keys: true,
|
|
598
|
+
vi: true,
|
|
599
|
+
items: editors.map(e => ` ${e.name} (${e.command})`)
|
|
600
|
+
});
|
|
601
|
+
list.focus();
|
|
602
|
+
this.screen.render();
|
|
603
|
+
const cleanup = () => {
|
|
604
|
+
list.destroy();
|
|
605
|
+
this.isModalOpen = false;
|
|
606
|
+
this.worktreeList.focus();
|
|
607
|
+
this.screen.render();
|
|
608
|
+
};
|
|
609
|
+
list.key(['escape'], cleanup);
|
|
610
|
+
list.key(['enter'], () => {
|
|
611
|
+
const selectedIdx = list.selected;
|
|
612
|
+
const editor = editors[selectedIdx];
|
|
613
|
+
cleanup();
|
|
614
|
+
this.openEditorTool(editor.command, editor.name, editor.terminal);
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
openEditorTool(command, name, terminal = false) {
|
|
618
|
+
const wt = this.worktrees[this.selectedIndex];
|
|
619
|
+
if (!wt)
|
|
620
|
+
return;
|
|
621
|
+
this.setStatus(` Opening ${wt.branch} in ${name}...`, 'blue');
|
|
622
|
+
const handleError = (err) => {
|
|
623
|
+
if (err.code === 'ENOENT') {
|
|
624
|
+
const hint = getInstallInstruction(command);
|
|
625
|
+
this.setStatus(` ${name} not found. ${hint}`, 'red');
|
|
626
|
+
}
|
|
627
|
+
else {
|
|
628
|
+
this.setStatus(` Error: ${err.message}`, 'red');
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
try {
|
|
632
|
+
let proc;
|
|
633
|
+
if (terminal) {
|
|
634
|
+
// Terminal-based editors need to open in a new terminal
|
|
635
|
+
if (PLATFORM.IS_MAC) {
|
|
636
|
+
const escapedPath = escapeAppleScript(wt.path);
|
|
637
|
+
const script = `
|
|
638
|
+
tell application "Terminal"
|
|
639
|
+
do script "cd '${escapedPath}' && ${command} ."
|
|
640
|
+
activate
|
|
641
|
+
end tell
|
|
642
|
+
`;
|
|
643
|
+
proc = spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' });
|
|
644
|
+
}
|
|
645
|
+
else if (PLATFORM.IS_WIN) {
|
|
646
|
+
const escapedPath = escapeWindowsArg(wt.path);
|
|
647
|
+
proc = spawn('cmd', ['/c', 'start', 'cmd', '/k', `cd /d ${escapedPath} && ${command} .`], {
|
|
648
|
+
detached: true,
|
|
649
|
+
stdio: 'ignore',
|
|
650
|
+
shell: true
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
// Linux - pass path safely via argument array
|
|
655
|
+
proc = spawn('gnome-terminal', ['--working-directory', wt.path, '--', command, '.'], {
|
|
656
|
+
detached: true,
|
|
657
|
+
stdio: 'ignore'
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
else {
|
|
662
|
+
// GUI editors can open directly - pass path via array (safe)
|
|
663
|
+
proc = spawn(command, [wt.path], { detached: true, stdio: 'ignore' });
|
|
664
|
+
}
|
|
665
|
+
proc.on('error', handleError);
|
|
666
|
+
proc.unref();
|
|
667
|
+
this.setStatus(` Opened ${wt.branch} in ${name}`, 'green');
|
|
668
|
+
}
|
|
669
|
+
catch (error) {
|
|
670
|
+
this.setStatus(` Error: ${error instanceof Error ? error.message : error}`, 'red');
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
openTerminal() {
|
|
674
|
+
const wt = this.worktrees[this.selectedIndex];
|
|
675
|
+
if (!wt)
|
|
676
|
+
return;
|
|
677
|
+
this.setStatus(` Opening terminal in ${wt.branch}...`, 'blue');
|
|
678
|
+
try {
|
|
679
|
+
if (PLATFORM.IS_MAC) {
|
|
680
|
+
// Open in new Terminal.app tab - path passed as argument (safe)
|
|
681
|
+
spawn('open', ['-a', 'Terminal', wt.path], { detached: true, stdio: 'ignore' });
|
|
682
|
+
}
|
|
683
|
+
else if (PLATFORM.IS_WIN) {
|
|
684
|
+
// Windows - escape path for cmd.exe
|
|
685
|
+
const escapedPath = escapeWindowsArg(wt.path);
|
|
686
|
+
spawn('cmd', ['/c', 'start', 'cmd', '/k', `cd /d ${escapedPath}`], {
|
|
687
|
+
detached: true,
|
|
688
|
+
stdio: 'ignore',
|
|
689
|
+
shell: true
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
else if (PLATFORM.IS_WSL) {
|
|
693
|
+
// WSL - pass path directly to wt.exe
|
|
694
|
+
spawn('wt.exe', ['-d', wt.path], { detached: true, stdio: 'ignore' });
|
|
695
|
+
}
|
|
696
|
+
else {
|
|
697
|
+
// Linux - try common terminal emulators, pass path via argument array (safe)
|
|
698
|
+
const terminals = ['gnome-terminal', 'konsole', 'xterm', 'terminator'];
|
|
699
|
+
let launched = false;
|
|
700
|
+
for (const term of terminals) {
|
|
701
|
+
try {
|
|
702
|
+
const proc = spawn(term, ['--working-directory', wt.path], {
|
|
703
|
+
detached: true,
|
|
704
|
+
stdio: 'ignore'
|
|
705
|
+
});
|
|
706
|
+
proc.on('error', () => { });
|
|
707
|
+
proc.unref();
|
|
708
|
+
launched = true;
|
|
709
|
+
break;
|
|
710
|
+
}
|
|
711
|
+
catch {
|
|
712
|
+
// Try next terminal
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
if (!launched) {
|
|
716
|
+
this.setStatus(' No terminal emulator found. Install gnome-terminal, konsole, xterm, or terminator', 'red');
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
this.setStatus(` Opened terminal in ${wt.branch}`, 'green');
|
|
721
|
+
}
|
|
722
|
+
catch (error) {
|
|
723
|
+
this.setStatus(` Error: ${error instanceof Error ? error.message : error}`, 'red');
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
launchAI() {
|
|
727
|
+
if (this.isModalOpen)
|
|
728
|
+
return;
|
|
729
|
+
const wt = this.worktrees[this.selectedIndex];
|
|
730
|
+
if (!wt)
|
|
731
|
+
return;
|
|
732
|
+
this.isModalOpen = true;
|
|
733
|
+
const aiTools = [
|
|
734
|
+
{ name: 'Claude', command: 'claude' },
|
|
735
|
+
{ name: 'Gemini', command: 'gemini' },
|
|
736
|
+
{ name: 'Codex', command: 'codex' }
|
|
737
|
+
];
|
|
738
|
+
const list = blessed.list({
|
|
739
|
+
parent: this.screen,
|
|
740
|
+
left: 'center',
|
|
741
|
+
top: 'center',
|
|
742
|
+
width: UI.SELECTOR_WIDTH,
|
|
743
|
+
height: UI.AI_SELECTOR_HEIGHT,
|
|
744
|
+
border: { type: 'line' },
|
|
745
|
+
style: {
|
|
746
|
+
border: { fg: 'magenta' },
|
|
747
|
+
selected: { bg: 'blue', fg: 'white' },
|
|
748
|
+
bg: 'default'
|
|
749
|
+
},
|
|
750
|
+
label: ' Select AI Tool ',
|
|
751
|
+
keys: true,
|
|
752
|
+
vi: true,
|
|
753
|
+
items: aiTools.map(t => ` ${t.name} (${t.command})`)
|
|
754
|
+
});
|
|
755
|
+
list.focus();
|
|
756
|
+
this.screen.render();
|
|
757
|
+
const cleanup = () => {
|
|
758
|
+
list.destroy();
|
|
759
|
+
this.isModalOpen = false;
|
|
760
|
+
this.worktreeList.focus();
|
|
761
|
+
this.screen.render();
|
|
762
|
+
};
|
|
763
|
+
list.key(['escape'], cleanup);
|
|
764
|
+
list.key(['enter'], () => {
|
|
765
|
+
const selectedIdx = list.selected;
|
|
766
|
+
const tool = aiTools[selectedIdx];
|
|
767
|
+
cleanup();
|
|
768
|
+
this.launchAITool(tool.command, tool.name);
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
launchAITool(command, name) {
|
|
772
|
+
const wt = this.worktrees[this.selectedIndex];
|
|
773
|
+
if (!wt)
|
|
774
|
+
return;
|
|
775
|
+
this.setStatus(` Launching ${name} in ${wt.branch}...`, 'blue');
|
|
776
|
+
try {
|
|
777
|
+
let proc;
|
|
778
|
+
if (PLATFORM.IS_MAC) {
|
|
779
|
+
const escapedPath = escapeAppleScript(wt.path);
|
|
780
|
+
const script = `
|
|
781
|
+
tell application "Terminal"
|
|
782
|
+
do script "cd '${escapedPath}' && ${command}"
|
|
783
|
+
activate
|
|
784
|
+
end tell
|
|
785
|
+
`;
|
|
786
|
+
proc = spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' });
|
|
787
|
+
}
|
|
788
|
+
else if (PLATFORM.IS_WIN) {
|
|
789
|
+
const escapedPath = escapeWindowsArg(wt.path);
|
|
790
|
+
proc = spawn('cmd', ['/c', 'start', 'cmd', '/k', `cd /d ${escapedPath} && ${command}`], {
|
|
791
|
+
detached: true,
|
|
792
|
+
stdio: 'ignore',
|
|
793
|
+
shell: true
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
else {
|
|
797
|
+
// Linux - pass arguments via array (safe)
|
|
798
|
+
proc = spawn('gnome-terminal', ['--working-directory', wt.path, '--', command], {
|
|
799
|
+
detached: true,
|
|
800
|
+
stdio: 'ignore'
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
proc.on('error', (err) => {
|
|
804
|
+
if (err.code === 'ENOENT') {
|
|
805
|
+
const hint = getInstallInstruction(command);
|
|
806
|
+
this.setStatus(` ${name} not found. ${hint}`, 'red');
|
|
807
|
+
}
|
|
808
|
+
});
|
|
809
|
+
proc.unref();
|
|
810
|
+
this.setStatus(` Launched ${name} in ${wt.branch}`, 'magenta');
|
|
811
|
+
}
|
|
812
|
+
catch (error) {
|
|
813
|
+
this.setStatus(` Error: ${error instanceof Error ? error.message : error}`, 'red');
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
showDetails() {
|
|
817
|
+
// Details are already shown in the detail panel
|
|
818
|
+
this.updateDetails();
|
|
819
|
+
this.screen.render();
|
|
820
|
+
}
|
|
821
|
+
startAutoRefresh() {
|
|
822
|
+
this.stopAutoRefresh();
|
|
823
|
+
this.autoRefreshInterval = setInterval(async () => {
|
|
824
|
+
// Only auto-refresh when no modal is open
|
|
825
|
+
if (!this.isModalOpen) {
|
|
826
|
+
await this.refresh();
|
|
827
|
+
}
|
|
828
|
+
}, this.AUTO_REFRESH_MS);
|
|
829
|
+
}
|
|
830
|
+
stopAutoRefresh() {
|
|
831
|
+
if (this.autoRefreshInterval) {
|
|
832
|
+
clearInterval(this.autoRefreshInterval);
|
|
833
|
+
this.autoRefreshInterval = null;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
async start() {
|
|
837
|
+
await this.refresh();
|
|
838
|
+
this.startAutoRefresh();
|
|
839
|
+
this.worktreeList.focus();
|
|
840
|
+
this.screen.render();
|
|
841
|
+
}
|
|
842
|
+
}
|