opencode-worktree 0.1.3 → 0.2.2
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 +67 -0
- package/bin/opencode-worktree +9 -16
- package/package.json +30 -16
- package/script/build.ts +150 -0
- package/script/postinstall.mjs +86 -0
- package/script/publish.ts +11 -0
- package/src/cli.ts +10 -0
- package/src/git.ts +209 -0
- package/src/opencode.ts +20 -0
- package/src/types.ts +6 -0
- package/src/ui.ts +656 -0
- package/postinstall.mjs +0 -69
package/src/ui.ts
ADDED
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoxRenderable,
|
|
3
|
+
createCliRenderer,
|
|
4
|
+
InputRenderable,
|
|
5
|
+
InputRenderableEvents,
|
|
6
|
+
SelectRenderable,
|
|
7
|
+
SelectRenderableEvents,
|
|
8
|
+
TextRenderable,
|
|
9
|
+
type CliRenderer,
|
|
10
|
+
type KeyEvent,
|
|
11
|
+
type SelectOption,
|
|
12
|
+
} from "@opentui/core";
|
|
13
|
+
import { basename } from "node:path";
|
|
14
|
+
import {
|
|
15
|
+
createWorktree,
|
|
16
|
+
deleteWorktree,
|
|
17
|
+
getDefaultWorktreesDir,
|
|
18
|
+
hasUncommittedChanges,
|
|
19
|
+
isMainWorktree,
|
|
20
|
+
listWorktrees,
|
|
21
|
+
resolveRepoRoot,
|
|
22
|
+
unlinkWorktree,
|
|
23
|
+
} from "./git.js";
|
|
24
|
+
import { isOpenCodeAvailable, launchOpenCode } from "./opencode.js";
|
|
25
|
+
import { WorktreeInfo } from "./types.js";
|
|
26
|
+
|
|
27
|
+
type StatusLevel = "info" | "warning" | "error" | "success";
|
|
28
|
+
|
|
29
|
+
const statusColors: Record<StatusLevel, string> = {
|
|
30
|
+
info: "#94A3B8",
|
|
31
|
+
warning: "#F59E0B",
|
|
32
|
+
error: "#EF4444",
|
|
33
|
+
success: "#10B981",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const CREATE_NEW_WORKTREE_VALUE = Symbol("CREATE_NEW_WORKTREE");
|
|
37
|
+
|
|
38
|
+
type SelectionValue = WorktreeInfo | typeof CREATE_NEW_WORKTREE_VALUE;
|
|
39
|
+
|
|
40
|
+
type ConfirmAction = "unlink" | "delete" | "cancel";
|
|
41
|
+
|
|
42
|
+
const CONFIRM_UNLINK_VALUE: ConfirmAction = "unlink";
|
|
43
|
+
const CONFIRM_DELETE_VALUE: ConfirmAction = "delete";
|
|
44
|
+
const CONFIRM_CANCEL_VALUE: ConfirmAction = "cancel";
|
|
45
|
+
|
|
46
|
+
export const runApp = async (targetPath: string): Promise<void> => {
|
|
47
|
+
const renderer = await createCliRenderer({
|
|
48
|
+
exitOnCtrlC: false,
|
|
49
|
+
targetFps: 30,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
renderer.setBackgroundColor("transparent");
|
|
53
|
+
new WorktreeSelector(renderer, targetPath);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
class WorktreeSelector {
|
|
57
|
+
private selectElement: SelectRenderable;
|
|
58
|
+
private statusText: TextRenderable;
|
|
59
|
+
private instructions: TextRenderable;
|
|
60
|
+
private title: TextRenderable;
|
|
61
|
+
|
|
62
|
+
private inputContainer: BoxRenderable | null = null;
|
|
63
|
+
private branchInput: InputRenderable | null = null;
|
|
64
|
+
|
|
65
|
+
private confirmContainer: BoxRenderable | null = null;
|
|
66
|
+
private confirmSelect: SelectRenderable | null = null;
|
|
67
|
+
private confirmingWorktree: WorktreeInfo | null = null;
|
|
68
|
+
private isConfirming = false;
|
|
69
|
+
|
|
70
|
+
private opencodeAvailable = false;
|
|
71
|
+
private repoRoot: string | null = null;
|
|
72
|
+
private isCreatingWorktree = false;
|
|
73
|
+
private worktreeOptions: SelectOption[] = [];
|
|
74
|
+
|
|
75
|
+
constructor(
|
|
76
|
+
private renderer: CliRenderer,
|
|
77
|
+
private targetPath: string,
|
|
78
|
+
) {
|
|
79
|
+
// Load worktrees first to get initial options
|
|
80
|
+
this.repoRoot = resolveRepoRoot(this.targetPath);
|
|
81
|
+
this.opencodeAvailable = isOpenCodeAvailable();
|
|
82
|
+
this.worktreeOptions = this.buildInitialOptions();
|
|
83
|
+
|
|
84
|
+
this.title = new TextRenderable(renderer, {
|
|
85
|
+
id: "worktree-title",
|
|
86
|
+
position: "absolute",
|
|
87
|
+
left: 2,
|
|
88
|
+
top: 1,
|
|
89
|
+
content: "OPENCODE WORKTREES",
|
|
90
|
+
fg: "#E2E8F0",
|
|
91
|
+
});
|
|
92
|
+
this.renderer.root.add(this.title);
|
|
93
|
+
|
|
94
|
+
this.selectElement = new SelectRenderable(renderer, {
|
|
95
|
+
id: "worktree-selector",
|
|
96
|
+
position: "absolute",
|
|
97
|
+
left: 2,
|
|
98
|
+
top: 3,
|
|
99
|
+
width: 76,
|
|
100
|
+
height: 15,
|
|
101
|
+
options: this.worktreeOptions,
|
|
102
|
+
backgroundColor: "#0F172A",
|
|
103
|
+
focusedBackgroundColor: "#1E293B",
|
|
104
|
+
selectedBackgroundColor: "#1E3A5F",
|
|
105
|
+
textColor: "#E2E8F0",
|
|
106
|
+
selectedTextColor: "#38BDF8",
|
|
107
|
+
descriptionColor: "#94A3B8",
|
|
108
|
+
selectedDescriptionColor: "#E2E8F0",
|
|
109
|
+
showScrollIndicator: true,
|
|
110
|
+
wrapSelection: true,
|
|
111
|
+
showDescription: true,
|
|
112
|
+
fastScrollStep: 5,
|
|
113
|
+
});
|
|
114
|
+
this.renderer.root.add(this.selectElement);
|
|
115
|
+
|
|
116
|
+
this.statusText = new TextRenderable(renderer, {
|
|
117
|
+
id: "worktree-status",
|
|
118
|
+
position: "absolute",
|
|
119
|
+
left: 2,
|
|
120
|
+
top: 19,
|
|
121
|
+
content: this.getInitialStatusMessage(),
|
|
122
|
+
fg: this.getInitialStatusColor(),
|
|
123
|
+
});
|
|
124
|
+
this.renderer.root.add(this.statusText);
|
|
125
|
+
|
|
126
|
+
this.instructions = new TextRenderable(renderer, {
|
|
127
|
+
id: "worktree-instructions",
|
|
128
|
+
position: "absolute",
|
|
129
|
+
left: 2,
|
|
130
|
+
top: 20,
|
|
131
|
+
content:
|
|
132
|
+
"↑/↓ navigate • Enter open • d delete • n new • r refresh • q quit",
|
|
133
|
+
fg: "#64748B",
|
|
134
|
+
});
|
|
135
|
+
this.renderer.root.add(this.instructions);
|
|
136
|
+
|
|
137
|
+
this.selectElement.on(
|
|
138
|
+
SelectRenderableEvents.ITEM_SELECTED,
|
|
139
|
+
(_index: number, option: SelectOption) => {
|
|
140
|
+
// Ignore if we're in another mode
|
|
141
|
+
if (this.isConfirming || this.isCreatingWorktree) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
this.handleSelection(option.value as SelectionValue);
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
this.renderer.keyInput.on("keypress", (key: KeyEvent) => {
|
|
149
|
+
this.handleKeypress(key);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
this.selectElement.focus();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private getInitialStatusMessage(): string {
|
|
156
|
+
if (!this.repoRoot) {
|
|
157
|
+
return "No git repository found in this directory.";
|
|
158
|
+
}
|
|
159
|
+
if (!this.opencodeAvailable) {
|
|
160
|
+
return "opencode is not available on PATH.";
|
|
161
|
+
}
|
|
162
|
+
const count = this.worktreeOptions.length - 1; // subtract create option
|
|
163
|
+
if (count === 0) {
|
|
164
|
+
return "No worktrees detected. Select 'Create new worktree' to add one.";
|
|
165
|
+
}
|
|
166
|
+
return `Found ${count} worktree${count === 1 ? "" : "s"}.`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private getInitialStatusColor(): string {
|
|
170
|
+
if (!this.repoRoot || !this.opencodeAvailable) {
|
|
171
|
+
return statusColors.error;
|
|
172
|
+
}
|
|
173
|
+
return statusColors.info;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private buildInitialOptions(): SelectOption[] {
|
|
177
|
+
if (!this.repoRoot) {
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const worktrees = listWorktrees(this.repoRoot);
|
|
182
|
+
return this.buildOptions(worktrees);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private handleKeypress(key: KeyEvent): void {
|
|
186
|
+
if (key.ctrl && key.name === "c") {
|
|
187
|
+
this.cleanup(true);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Handle confirmation mode
|
|
192
|
+
if (this.isConfirming) {
|
|
193
|
+
if (key.name === "escape") {
|
|
194
|
+
this.hideConfirmDialog();
|
|
195
|
+
}
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (this.isCreatingWorktree) {
|
|
200
|
+
if (key.name === "escape") {
|
|
201
|
+
this.hideCreateWorktreeInput();
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (key.name === "q" || key.name === "escape") {
|
|
207
|
+
this.cleanup(true);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (key.name === "r") {
|
|
212
|
+
this.loadWorktrees();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 'n' for new worktree
|
|
217
|
+
if (key.name === "n") {
|
|
218
|
+
this.showCreateWorktreeInput();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 'd' for delete/unlink menu
|
|
223
|
+
if (key.name === "d") {
|
|
224
|
+
this.showDeleteConfirmation();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private handleSelection(value: SelectionValue): void {
|
|
230
|
+
if (value === CREATE_NEW_WORKTREE_VALUE) {
|
|
231
|
+
this.showCreateWorktreeInput();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const worktree = value as WorktreeInfo;
|
|
236
|
+
if (!this.opencodeAvailable) {
|
|
237
|
+
this.setStatus("opencode is not available on PATH.", "error");
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
this.cleanup(false);
|
|
242
|
+
launchOpenCode(worktree.path);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private showCreateWorktreeInput(): void {
|
|
246
|
+
this.isCreatingWorktree = true;
|
|
247
|
+
this.selectElement.visible = false;
|
|
248
|
+
this.selectElement.blur();
|
|
249
|
+
|
|
250
|
+
this.inputContainer = new BoxRenderable(this.renderer, {
|
|
251
|
+
id: "worktree-input-container",
|
|
252
|
+
position: "absolute",
|
|
253
|
+
left: 2,
|
|
254
|
+
top: 3,
|
|
255
|
+
width: 76,
|
|
256
|
+
height: 5,
|
|
257
|
+
borderStyle: "single",
|
|
258
|
+
borderColor: "#38BDF8",
|
|
259
|
+
title: "Create New Worktree",
|
|
260
|
+
titleAlignment: "center",
|
|
261
|
+
backgroundColor: "#0F172A",
|
|
262
|
+
border: true,
|
|
263
|
+
});
|
|
264
|
+
this.renderer.root.add(this.inputContainer);
|
|
265
|
+
|
|
266
|
+
const inputLabel = new TextRenderable(this.renderer, {
|
|
267
|
+
id: "worktree-input-label",
|
|
268
|
+
position: "absolute",
|
|
269
|
+
left: 1,
|
|
270
|
+
top: 1,
|
|
271
|
+
content: "Branch name:",
|
|
272
|
+
fg: "#E2E8F0",
|
|
273
|
+
});
|
|
274
|
+
this.inputContainer.add(inputLabel);
|
|
275
|
+
|
|
276
|
+
this.branchInput = new InputRenderable(this.renderer, {
|
|
277
|
+
id: "worktree-branch-input",
|
|
278
|
+
position: "absolute",
|
|
279
|
+
left: 14,
|
|
280
|
+
top: 1,
|
|
281
|
+
width: 58,
|
|
282
|
+
placeholder: "feature/my-new-branch",
|
|
283
|
+
focusedBackgroundColor: "#1E293B",
|
|
284
|
+
backgroundColor: "#1E293B",
|
|
285
|
+
});
|
|
286
|
+
this.inputContainer.add(this.branchInput);
|
|
287
|
+
|
|
288
|
+
this.branchInput.on(InputRenderableEvents.CHANGE, (value: string) => {
|
|
289
|
+
this.handleCreateWorktree(value);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
this.instructions.content = "Enter to create - Esc to cancel";
|
|
293
|
+
this.setStatus("Enter a branch name for the new worktree.", "info");
|
|
294
|
+
|
|
295
|
+
this.branchInput.focus();
|
|
296
|
+
this.renderer.requestRender();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private hideCreateWorktreeInput(): void {
|
|
300
|
+
this.isCreatingWorktree = false;
|
|
301
|
+
|
|
302
|
+
if (this.branchInput) {
|
|
303
|
+
this.branchInput.blur();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (this.inputContainer) {
|
|
307
|
+
this.renderer.root.remove(this.inputContainer.id);
|
|
308
|
+
this.inputContainer = null;
|
|
309
|
+
this.branchInput = null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
this.selectElement.visible = true;
|
|
313
|
+
this.instructions.content =
|
|
314
|
+
"↑/↓ navigate • Enter open • d delete • n new • r refresh • q quit";
|
|
315
|
+
this.selectElement.focus();
|
|
316
|
+
this.loadWorktrees();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private handleCreateWorktree(branchName: string): void {
|
|
320
|
+
const trimmed = branchName.trim();
|
|
321
|
+
if (!trimmed) {
|
|
322
|
+
this.setStatus("Branch name cannot be empty.", "error");
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (!this.repoRoot) {
|
|
327
|
+
this.setStatus("No git repository found.", "error");
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const worktreesDir = getDefaultWorktreesDir(this.repoRoot);
|
|
332
|
+
this.setStatus(`Creating worktree for branch '${trimmed}'...`, "info");
|
|
333
|
+
this.renderer.requestRender();
|
|
334
|
+
|
|
335
|
+
const result = createWorktree(this.repoRoot, trimmed, worktreesDir);
|
|
336
|
+
|
|
337
|
+
if (result.success) {
|
|
338
|
+
this.setStatus(`Worktree created at ${result.path}`, "success");
|
|
339
|
+
this.renderer.requestRender();
|
|
340
|
+
|
|
341
|
+
if (this.opencodeAvailable) {
|
|
342
|
+
this.hideCreateWorktreeInput();
|
|
343
|
+
this.cleanup(false);
|
|
344
|
+
launchOpenCode(result.path);
|
|
345
|
+
} else {
|
|
346
|
+
this.setStatus(
|
|
347
|
+
`Worktree created but opencode is not available.`,
|
|
348
|
+
"warning",
|
|
349
|
+
);
|
|
350
|
+
this.hideCreateWorktreeInput();
|
|
351
|
+
}
|
|
352
|
+
} else {
|
|
353
|
+
this.setStatus(`Failed to create worktree: ${result.error}`, "error");
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private loadWorktrees(): void {
|
|
358
|
+
this.repoRoot = resolveRepoRoot(this.targetPath);
|
|
359
|
+
if (!this.repoRoot) {
|
|
360
|
+
this.setStatus("No git repository found in this directory.", "error");
|
|
361
|
+
this.selectElement.options = [];
|
|
362
|
+
this.renderer.requestRender();
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const worktrees = listWorktrees(this.repoRoot);
|
|
367
|
+
this.selectElement.options = this.buildOptions(worktrees);
|
|
368
|
+
|
|
369
|
+
if (worktrees.length === 0) {
|
|
370
|
+
this.setStatus(
|
|
371
|
+
"No worktrees detected. Select 'Create new worktree' to add one.",
|
|
372
|
+
"info",
|
|
373
|
+
);
|
|
374
|
+
} else {
|
|
375
|
+
this.setStatus(
|
|
376
|
+
`Found ${worktrees.length} worktree${worktrees.length === 1 ? "" : "s"}.`,
|
|
377
|
+
"info",
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
this.opencodeAvailable = isOpenCodeAvailable();
|
|
382
|
+
if (!this.opencodeAvailable) {
|
|
383
|
+
this.setStatus("opencode is not available on PATH.", "error");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
this.renderer.requestRender();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private buildOptions(worktrees: WorktreeInfo[]): SelectOption[] {
|
|
390
|
+
const createOption: SelectOption = {
|
|
391
|
+
name: "+ Create new worktree",
|
|
392
|
+
description: "Create a new worktree from a branch",
|
|
393
|
+
value: CREATE_NEW_WORKTREE_VALUE,
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const worktreeOptions = worktrees.map((worktree) => {
|
|
397
|
+
const shortHead = worktree.head ? worktree.head.slice(0, 7) : "unknown";
|
|
398
|
+
const baseName = basename(worktree.path);
|
|
399
|
+
const label = worktree.branch
|
|
400
|
+
? worktree.branch
|
|
401
|
+
: worktree.isDetached
|
|
402
|
+
? `${baseName} (detached)`
|
|
403
|
+
: baseName;
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
name: label,
|
|
407
|
+
description: `${worktree.path} - ${shortHead}`,
|
|
408
|
+
value: worktree,
|
|
409
|
+
};
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
return [createOption, ...worktreeOptions];
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
private setStatus(message: string, level: StatusLevel): void {
|
|
416
|
+
this.statusText.content = message;
|
|
417
|
+
this.statusText.fg = statusColors[level];
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private getSelectedWorktree(): WorktreeInfo | null {
|
|
421
|
+
const selectedIndex = this.selectElement.getSelectedIndex();
|
|
422
|
+
const option = this.selectElement.options[selectedIndex];
|
|
423
|
+
if (!option || option.value === CREATE_NEW_WORKTREE_VALUE) {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
return option.value as WorktreeInfo;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private showDeleteConfirmation(): void {
|
|
430
|
+
const worktree = this.getSelectedWorktree();
|
|
431
|
+
if (!worktree) {
|
|
432
|
+
this.setStatus("Select a worktree to delete.", "warning");
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (!this.repoRoot) {
|
|
437
|
+
this.setStatus("No git repository found.", "error");
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Check if this is the main worktree
|
|
442
|
+
if (isMainWorktree(this.repoRoot, worktree.path)) {
|
|
443
|
+
this.setStatus("Cannot delete the main worktree.", "error");
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
this.isConfirming = true;
|
|
448
|
+
this.confirmingWorktree = worktree;
|
|
449
|
+
this.selectElement.visible = false;
|
|
450
|
+
this.selectElement.blur();
|
|
451
|
+
|
|
452
|
+
// Check for uncommitted changes
|
|
453
|
+
const isDirty = hasUncommittedChanges(worktree.path);
|
|
454
|
+
const branchDisplay = worktree.branch || basename(worktree.path);
|
|
455
|
+
|
|
456
|
+
// Build dialog title
|
|
457
|
+
const title = `Remove: ${branchDisplay}`;
|
|
458
|
+
|
|
459
|
+
this.confirmContainer = new BoxRenderable(this.renderer, {
|
|
460
|
+
id: "confirm-container",
|
|
461
|
+
position: "absolute",
|
|
462
|
+
left: 2,
|
|
463
|
+
top: 3,
|
|
464
|
+
width: 76,
|
|
465
|
+
height: isDirty ? 10 : 8,
|
|
466
|
+
borderStyle: "single",
|
|
467
|
+
borderColor: "#F59E0B",
|
|
468
|
+
title,
|
|
469
|
+
titleAlignment: "center",
|
|
470
|
+
backgroundColor: "#0F172A",
|
|
471
|
+
border: true,
|
|
472
|
+
});
|
|
473
|
+
this.renderer.root.add(this.confirmContainer);
|
|
474
|
+
|
|
475
|
+
// Warning for dirty worktree
|
|
476
|
+
let yOffset = 1;
|
|
477
|
+
if (isDirty) {
|
|
478
|
+
const warningText = new TextRenderable(this.renderer, {
|
|
479
|
+
id: "confirm-warning",
|
|
480
|
+
position: "absolute",
|
|
481
|
+
left: 1,
|
|
482
|
+
top: yOffset,
|
|
483
|
+
content: "⚠ This worktree has uncommitted changes!",
|
|
484
|
+
fg: "#F59E0B",
|
|
485
|
+
});
|
|
486
|
+
this.confirmContainer.add(warningText);
|
|
487
|
+
yOffset += 2;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const pathText = new TextRenderable(this.renderer, {
|
|
491
|
+
id: "confirm-path",
|
|
492
|
+
position: "absolute",
|
|
493
|
+
left: 1,
|
|
494
|
+
top: yOffset,
|
|
495
|
+
content: `Path: ${worktree.path}`,
|
|
496
|
+
fg: "#94A3B8",
|
|
497
|
+
});
|
|
498
|
+
this.confirmContainer.add(pathText);
|
|
499
|
+
yOffset += 2;
|
|
500
|
+
|
|
501
|
+
// Build options - Unlink is default (first)
|
|
502
|
+
const options: SelectOption[] = [
|
|
503
|
+
{
|
|
504
|
+
name: "Unlink (default)",
|
|
505
|
+
description: "Remove worktree directory, keep branch for later use",
|
|
506
|
+
value: CONFIRM_UNLINK_VALUE,
|
|
507
|
+
},
|
|
508
|
+
{
|
|
509
|
+
name: "Delete",
|
|
510
|
+
description: "Remove worktree AND delete local branch (never remote)",
|
|
511
|
+
value: CONFIRM_DELETE_VALUE,
|
|
512
|
+
},
|
|
513
|
+
{
|
|
514
|
+
name: "Cancel",
|
|
515
|
+
description: "Go back without changes",
|
|
516
|
+
value: CONFIRM_CANCEL_VALUE,
|
|
517
|
+
},
|
|
518
|
+
];
|
|
519
|
+
|
|
520
|
+
this.confirmSelect = new SelectRenderable(this.renderer, {
|
|
521
|
+
id: "confirm-select",
|
|
522
|
+
position: "absolute",
|
|
523
|
+
left: 1,
|
|
524
|
+
top: yOffset,
|
|
525
|
+
width: 72,
|
|
526
|
+
height: 4,
|
|
527
|
+
options,
|
|
528
|
+
backgroundColor: "#0F172A",
|
|
529
|
+
focusedBackgroundColor: "#1E293B",
|
|
530
|
+
selectedBackgroundColor: "#1E3A5F",
|
|
531
|
+
textColor: "#E2E8F0",
|
|
532
|
+
selectedTextColor: "#38BDF8",
|
|
533
|
+
descriptionColor: "#94A3B8",
|
|
534
|
+
selectedDescriptionColor: "#E2E8F0",
|
|
535
|
+
showDescription: true,
|
|
536
|
+
wrapSelection: true,
|
|
537
|
+
});
|
|
538
|
+
this.confirmContainer.add(this.confirmSelect);
|
|
539
|
+
|
|
540
|
+
this.confirmSelect.on(
|
|
541
|
+
SelectRenderableEvents.ITEM_SELECTED,
|
|
542
|
+
(_index: number, option: SelectOption) => {
|
|
543
|
+
this.handleConfirmAction(option.value as ConfirmAction, isDirty);
|
|
544
|
+
},
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
this.instructions.content =
|
|
548
|
+
"↑/↓ select action • Enter confirm • Esc cancel";
|
|
549
|
+
this.setStatus(
|
|
550
|
+
isDirty
|
|
551
|
+
? "Warning: Uncommitted changes will be lost!"
|
|
552
|
+
: "Choose how to remove this worktree.",
|
|
553
|
+
isDirty ? "warning" : "info",
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
this.confirmSelect.focus();
|
|
557
|
+
this.renderer.requestRender();
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
private hideConfirmDialog(): void {
|
|
561
|
+
this.isConfirming = false;
|
|
562
|
+
this.confirmingWorktree = null;
|
|
563
|
+
|
|
564
|
+
if (this.confirmSelect) {
|
|
565
|
+
this.confirmSelect.blur();
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (this.confirmContainer) {
|
|
569
|
+
this.renderer.root.remove(this.confirmContainer.id);
|
|
570
|
+
this.confirmContainer = null;
|
|
571
|
+
this.confirmSelect = null;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
this.selectElement.visible = true;
|
|
575
|
+
this.instructions.content =
|
|
576
|
+
"↑/↓ navigate • Enter open • d delete • n new • r refresh • q quit";
|
|
577
|
+
this.selectElement.focus();
|
|
578
|
+
this.loadWorktrees();
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
private handleConfirmAction(action: ConfirmAction, isDirty: boolean): void {
|
|
582
|
+
if (action === CONFIRM_CANCEL_VALUE) {
|
|
583
|
+
this.hideConfirmDialog();
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (!this.confirmingWorktree || !this.repoRoot) {
|
|
588
|
+
this.hideConfirmDialog();
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const worktree = this.confirmingWorktree;
|
|
593
|
+
const branchName = worktree.branch || basename(worktree.path);
|
|
594
|
+
|
|
595
|
+
if (action === CONFIRM_UNLINK_VALUE) {
|
|
596
|
+
// Unlink: remove worktree, keep branch
|
|
597
|
+
this.setStatus(`Unlinking worktree '${branchName}'...`, "info");
|
|
598
|
+
this.renderer.requestRender();
|
|
599
|
+
|
|
600
|
+
const result = unlinkWorktree(this.repoRoot, worktree.path, isDirty);
|
|
601
|
+
if (result.success) {
|
|
602
|
+
this.setStatus(
|
|
603
|
+
`Worktree unlinked. Branch '${branchName}' is still available.`,
|
|
604
|
+
"success",
|
|
605
|
+
);
|
|
606
|
+
} else {
|
|
607
|
+
this.setStatus(`Failed to unlink: ${result.error}`, "error");
|
|
608
|
+
}
|
|
609
|
+
} else if (action === CONFIRM_DELETE_VALUE) {
|
|
610
|
+
// Delete: remove worktree AND local branch
|
|
611
|
+
if (!worktree.branch) {
|
|
612
|
+
this.setStatus("Cannot delete branch: detached HEAD.", "error");
|
|
613
|
+
this.hideConfirmDialog();
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
this.setStatus(`Deleting worktree and branch '${branchName}'...`, "info");
|
|
618
|
+
this.renderer.requestRender();
|
|
619
|
+
|
|
620
|
+
const result = deleteWorktree(
|
|
621
|
+
this.repoRoot,
|
|
622
|
+
worktree.path,
|
|
623
|
+
worktree.branch,
|
|
624
|
+
isDirty,
|
|
625
|
+
);
|
|
626
|
+
if (result.success) {
|
|
627
|
+
this.setStatus(
|
|
628
|
+
`Worktree and local branch '${branchName}' deleted.`,
|
|
629
|
+
"success",
|
|
630
|
+
);
|
|
631
|
+
} else {
|
|
632
|
+
const stepMsg =
|
|
633
|
+
result.step === "unlink"
|
|
634
|
+
? "Failed to remove worktree"
|
|
635
|
+
: "Worktree removed but failed to delete branch";
|
|
636
|
+
this.setStatus(`${stepMsg}: ${result.error}`, "error");
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
this.hideConfirmDialog();
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
private cleanup(shouldExit: boolean): void {
|
|
644
|
+
this.selectElement.blur();
|
|
645
|
+
if (this.branchInput) {
|
|
646
|
+
this.branchInput.blur();
|
|
647
|
+
}
|
|
648
|
+
if (this.confirmSelect) {
|
|
649
|
+
this.confirmSelect.blur();
|
|
650
|
+
}
|
|
651
|
+
this.renderer.destroy();
|
|
652
|
+
if (shouldExit) {
|
|
653
|
+
process.exit(0);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
package/postinstall.mjs
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import fs from "node:fs";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import os from "node:os";
|
|
6
|
-
import { fileURLToPath } from "node:url";
|
|
7
|
-
import { createRequire } from "node:module";
|
|
8
|
-
|
|
9
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
-
const require = createRequire(import.meta.url);
|
|
11
|
-
|
|
12
|
-
const detectPlatformAndArch = () => {
|
|
13
|
-
let platform;
|
|
14
|
-
switch (os.platform()) {
|
|
15
|
-
case "darwin":
|
|
16
|
-
platform = "darwin";
|
|
17
|
-
break;
|
|
18
|
-
case "linux":
|
|
19
|
-
platform = "linux";
|
|
20
|
-
break;
|
|
21
|
-
case "win32":
|
|
22
|
-
platform = "windows";
|
|
23
|
-
break;
|
|
24
|
-
default:
|
|
25
|
-
platform = os.platform();
|
|
26
|
-
break;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
let arch;
|
|
30
|
-
switch (os.arch()) {
|
|
31
|
-
case "x64":
|
|
32
|
-
arch = "x64";
|
|
33
|
-
break;
|
|
34
|
-
case "arm64":
|
|
35
|
-
arch = "arm64";
|
|
36
|
-
break;
|
|
37
|
-
default:
|
|
38
|
-
arch = os.arch();
|
|
39
|
-
break;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
return { platform, arch };
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
const verifyBinary = () => {
|
|
46
|
-
const { platform, arch } = detectPlatformAndArch();
|
|
47
|
-
const packageName = `opencode-worktree-${platform}-${arch}`;
|
|
48
|
-
const binaryName =
|
|
49
|
-
platform === "windows" ? "opencode-worktree.exe" : "opencode-worktree";
|
|
50
|
-
|
|
51
|
-
const packageJsonPath = require.resolve(`${packageName}/package.json`);
|
|
52
|
-
const packageDir = path.dirname(packageJsonPath);
|
|
53
|
-
const binaryPath = path.join(packageDir, "bin", binaryName);
|
|
54
|
-
|
|
55
|
-
if (!fs.existsSync(binaryPath)) {
|
|
56
|
-
throw new Error(`Binary not found at ${binaryPath}`);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return binaryPath;
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
try {
|
|
63
|
-
const binaryPath = verifyBinary();
|
|
64
|
-
console.log(`opencode-worktree binary verified at: ${binaryPath}`);
|
|
65
|
-
} catch (error) {
|
|
66
|
-
console.error("Failed to setup opencode-worktree binary.");
|
|
67
|
-
console.error(error instanceof Error ? error.message : String(error));
|
|
68
|
-
process.exit(1);
|
|
69
|
-
}
|