opencode-worktree 0.2.6 → 0.3.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/README.md +56 -4
- package/package.json +3 -2
- package/script/postinstall.mjs +4 -0
- package/src/config.ts +69 -0
- package/src/git.ts +76 -3
- package/src/hooks.ts +62 -0
- package/src/opencode.ts +31 -0
- package/src/types.ts +4 -0
- package/src/ui.ts +832 -23
package/README.md
CHANGED
|
@@ -4,10 +4,15 @@ Terminal UI for managing git worktrees and launching `opencode` in the selected
|
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- Lists all worktrees with branch, path, and
|
|
7
|
+
- Lists all worktrees with branch, path, and metadata
|
|
8
|
+
- Worktree metadata display: last edited time, dirty status, remote tracking
|
|
9
|
+
- Status indicators: `[main]` for main worktree, `[*]` for uncommitted changes, `[local]` for local-only branches
|
|
8
10
|
- Create new worktrees directly from the TUI
|
|
11
|
+
- Post-create hooks: automatically run commands (e.g., `npm install`) after creating a worktree
|
|
12
|
+
- Open worktree folder in file manager
|
|
9
13
|
- Unlink worktrees (remove directory, keep branch)
|
|
10
14
|
- Delete worktrees and local branches (never remote)
|
|
15
|
+
- Multi-select delete mode for batch deletion
|
|
11
16
|
- Launches `opencode` in the selected worktree
|
|
12
17
|
- Refresh list on demand
|
|
13
18
|
|
|
@@ -37,11 +42,58 @@ opencode-worktree /path/to/your/repo
|
|
|
37
42
|
## Keybindings
|
|
38
43
|
|
|
39
44
|
- `Up`/`Down` or `j`/`k`: navigate
|
|
40
|
-
- `Enter`: open selected worktree
|
|
41
|
-
- `
|
|
45
|
+
- `Enter`: open selected worktree in opencode (or toggle selection in delete mode)
|
|
46
|
+
- `o`: open worktree folder in file manager (Finder/Explorer)
|
|
47
|
+
- `d`: enter multi-select delete mode (press again to confirm deletion)
|
|
42
48
|
- `n`: create new worktree
|
|
49
|
+
- `c`: edit configuration (post-create hooks)
|
|
43
50
|
- `r`: refresh list
|
|
44
|
-
- `q` or `Esc`: quit (or cancel dialogs)
|
|
51
|
+
- `q` or `Esc`: quit (or cancel dialogs/modes)
|
|
52
|
+
|
|
53
|
+
### Multi-select delete mode
|
|
54
|
+
|
|
55
|
+
1. Press `d` to enter selection mode
|
|
56
|
+
2. Navigate with arrow keys and press `Enter` to toggle worktrees for deletion
|
|
57
|
+
3. Press `d` again to confirm and choose unlink/delete action
|
|
58
|
+
4. Press `Esc` to cancel and return to normal mode
|
|
59
|
+
|
|
60
|
+
## Configuration
|
|
61
|
+
|
|
62
|
+
You can configure per-repository settings by creating a `.opencode-worktree.json` file in your repository root.
|
|
63
|
+
|
|
64
|
+
### First-time setup
|
|
65
|
+
|
|
66
|
+
When you first run `opencode-worktree` in a repository without a configuration file, you'll be prompted to configure a post-create hook. You can also skip this step and configure it later by pressing `c`.
|
|
67
|
+
|
|
68
|
+
### Editing configuration
|
|
69
|
+
|
|
70
|
+
Press `c` at any time to edit your configuration. Currently, this allows you to set or modify the post-create hook command.
|
|
71
|
+
|
|
72
|
+
### Post-create hooks
|
|
73
|
+
|
|
74
|
+
Run a command automatically after creating a new worktree. Useful for installing dependencies.
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
{
|
|
78
|
+
"postCreateHook": "npm install"
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The hook output is streamed to the TUI in real-time. If the hook fails, you can choose to open opencode anyway or cancel.
|
|
83
|
+
|
|
84
|
+
**Examples:**
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{
|
|
88
|
+
"postCreateHook": "bun install"
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"postCreateHook": "npm install && npm run setup"
|
|
95
|
+
}
|
|
96
|
+
```
|
|
45
97
|
|
|
46
98
|
## Update notifications
|
|
47
99
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-worktree",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "TUI for managing git worktrees with opencode integration.",
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"dev": "bun src/cli.ts",
|
|
29
29
|
"build:single": "bun run script/build.ts --single",
|
|
30
30
|
"build:all": "bun run script/build.ts",
|
|
31
|
-
"release:publish": "bun run script/publish.ts"
|
|
31
|
+
"release:publish": "bun run script/publish.ts",
|
|
32
|
+
"postinstall": "node ./script/postinstall.mjs"
|
|
32
33
|
},
|
|
33
34
|
"files": [
|
|
34
35
|
"bin",
|
package/script/postinstall.mjs
CHANGED
|
@@ -77,6 +77,10 @@ const installBinary = async () => {
|
|
|
77
77
|
};
|
|
78
78
|
|
|
79
79
|
try {
|
|
80
|
+
if (process.env.CI) {
|
|
81
|
+
console.log("CI detected, skipping binary download.");
|
|
82
|
+
process.exit(0);
|
|
83
|
+
}
|
|
80
84
|
const binaryPath = await installBinary();
|
|
81
85
|
console.log(`opencode-worktree binary installed at: ${binaryPath}`);
|
|
82
86
|
} catch (error) {
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type Config = {
|
|
5
|
+
postCreateHook?: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const CONFIG_FILENAME = ".opencode-worktree.json";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Get the path to the config file for a repo
|
|
12
|
+
*/
|
|
13
|
+
export const getConfigPath = (repoRoot: string): string => {
|
|
14
|
+
return join(repoRoot, CONFIG_FILENAME);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Check if a config file exists for the repo
|
|
19
|
+
*/
|
|
20
|
+
export const configExists = (repoRoot: string): boolean => {
|
|
21
|
+
return existsSync(getConfigPath(repoRoot));
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Load per-repo configuration from .opencode-worktree.json in the repo root
|
|
26
|
+
*/
|
|
27
|
+
export const loadRepoConfig = (repoRoot: string): Config => {
|
|
28
|
+
const configPath = getConfigPath(repoRoot);
|
|
29
|
+
|
|
30
|
+
if (!existsSync(configPath)) {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const content = readFileSync(configPath, "utf8");
|
|
36
|
+
const parsed = JSON.parse(content);
|
|
37
|
+
|
|
38
|
+
// Validate the config structure
|
|
39
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const config: Config = {};
|
|
44
|
+
|
|
45
|
+
if (typeof parsed.postCreateHook === "string") {
|
|
46
|
+
config.postCreateHook = parsed.postCreateHook;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return config;
|
|
50
|
+
} catch {
|
|
51
|
+
// If we can't read or parse the config, return empty
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Save configuration to .opencode-worktree.json in the repo root
|
|
58
|
+
*/
|
|
59
|
+
export const saveRepoConfig = (repoRoot: string, config: Config): boolean => {
|
|
60
|
+
const configPath = getConfigPath(repoRoot);
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const content = JSON.stringify(config, null, 2) + "\n";
|
|
64
|
+
writeFileSync(configPath, content, "utf8");
|
|
65
|
+
return true;
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
};
|
package/src/git.ts
CHANGED
|
@@ -17,11 +17,19 @@ export const resolveRepoRoot = (cwd: string): string | null => {
|
|
|
17
17
|
export const parseWorktreeList = (output: string): WorktreeInfo[] => {
|
|
18
18
|
const lines = output.split(/\r?\n/);
|
|
19
19
|
const worktrees: WorktreeInfo[] = [];
|
|
20
|
-
let current: WorktreeInfo | null = null;
|
|
20
|
+
let current: Partial<WorktreeInfo> | null = null;
|
|
21
21
|
|
|
22
22
|
const pushCurrent = (): void => {
|
|
23
23
|
if (current?.path) {
|
|
24
|
-
worktrees.push(
|
|
24
|
+
worktrees.push({
|
|
25
|
+
path: current.path,
|
|
26
|
+
head: current.head || "",
|
|
27
|
+
branch: current.branch || null,
|
|
28
|
+
isDetached: current.isDetached || false,
|
|
29
|
+
isDirty: false,
|
|
30
|
+
isOnRemote: false,
|
|
31
|
+
lastModified: null,
|
|
32
|
+
});
|
|
25
33
|
}
|
|
26
34
|
};
|
|
27
35
|
|
|
@@ -71,7 +79,10 @@ export const listWorktrees = (cwd: string): WorktreeInfo[] => {
|
|
|
71
79
|
stdio: ["ignore", "pipe", "ignore"],
|
|
72
80
|
encoding: "utf8",
|
|
73
81
|
});
|
|
74
|
-
|
|
82
|
+
const worktrees = parseWorktreeList(output);
|
|
83
|
+
|
|
84
|
+
// Enrich each worktree with metadata
|
|
85
|
+
return worktrees.map((wt) => enrichWorktreeInfo(repoRoot, wt));
|
|
75
86
|
};
|
|
76
87
|
|
|
77
88
|
export type CreateWorktreeResult =
|
|
@@ -133,6 +144,68 @@ export const hasUncommittedChanges = (worktreePath: string): boolean => {
|
|
|
133
144
|
}
|
|
134
145
|
};
|
|
135
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Get the last commit date for a worktree
|
|
149
|
+
*/
|
|
150
|
+
export const getLastCommitDate = (worktreePath: string): Date | null => {
|
|
151
|
+
try {
|
|
152
|
+
const output = execFileSync(
|
|
153
|
+
"git",
|
|
154
|
+
["log", "-1", "--format=%ci"],
|
|
155
|
+
{
|
|
156
|
+
cwd: worktreePath,
|
|
157
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
158
|
+
encoding: "utf8",
|
|
159
|
+
}
|
|
160
|
+
);
|
|
161
|
+
const dateStr = output.trim();
|
|
162
|
+
if (!dateStr) return null;
|
|
163
|
+
return new Date(dateStr);
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Check if a branch exists on remote (origin)
|
|
171
|
+
*/
|
|
172
|
+
export const isBranchOnRemote = (
|
|
173
|
+
repoRoot: string,
|
|
174
|
+
branchName: string
|
|
175
|
+
): boolean => {
|
|
176
|
+
try {
|
|
177
|
+
const output = execFileSync(
|
|
178
|
+
"git",
|
|
179
|
+
["branch", "-r", "--list", `origin/${branchName}`],
|
|
180
|
+
{
|
|
181
|
+
cwd: repoRoot,
|
|
182
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
183
|
+
encoding: "utf8",
|
|
184
|
+
}
|
|
185
|
+
);
|
|
186
|
+
return output.trim().length > 0;
|
|
187
|
+
} catch {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Enrich worktree info with metadata (dirty status, remote status, last modified)
|
|
194
|
+
*/
|
|
195
|
+
export const enrichWorktreeInfo = (
|
|
196
|
+
repoRoot: string,
|
|
197
|
+
worktree: WorktreeInfo
|
|
198
|
+
): WorktreeInfo => {
|
|
199
|
+
return {
|
|
200
|
+
...worktree,
|
|
201
|
+
isDirty: hasUncommittedChanges(worktree.path),
|
|
202
|
+
isOnRemote: worktree.branch
|
|
203
|
+
? isBranchOnRemote(repoRoot, worktree.branch)
|
|
204
|
+
: false,
|
|
205
|
+
lastModified: getLastCommitDate(worktree.path),
|
|
206
|
+
};
|
|
207
|
+
};
|
|
208
|
+
|
|
136
209
|
/**
|
|
137
210
|
* Check if a worktree is the main worktree (the original repo clone)
|
|
138
211
|
*/
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export type HookResult = {
|
|
4
|
+
success: boolean;
|
|
5
|
+
exitCode: number | null;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type HookCallbacks = {
|
|
9
|
+
onOutput: (data: string) => void;
|
|
10
|
+
onComplete: (result: HookResult) => void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Run a post-create hook command with streaming output
|
|
15
|
+
* Returns a function to abort the hook if needed
|
|
16
|
+
*/
|
|
17
|
+
export const runPostCreateHook = (
|
|
18
|
+
worktreePath: string,
|
|
19
|
+
command: string,
|
|
20
|
+
callbacks: HookCallbacks
|
|
21
|
+
): (() => void) => {
|
|
22
|
+
const shell = process.platform === "win32" ? "cmd" : "/bin/sh";
|
|
23
|
+
const shellFlag = process.platform === "win32" ? "/c" : "-c";
|
|
24
|
+
|
|
25
|
+
const child = spawn(shell, [shellFlag, command], {
|
|
26
|
+
cwd: worktreePath,
|
|
27
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
28
|
+
env: { ...process.env },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Stream stdout
|
|
32
|
+
child.stdout?.on("data", (data: Buffer) => {
|
|
33
|
+
callbacks.onOutput(data.toString());
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Stream stderr
|
|
37
|
+
child.stderr?.on("data", (data: Buffer) => {
|
|
38
|
+
callbacks.onOutput(data.toString());
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Handle completion
|
|
42
|
+
child.on("close", (code: number | null) => {
|
|
43
|
+
callbacks.onComplete({
|
|
44
|
+
success: code === 0,
|
|
45
|
+
exitCode: code,
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Handle errors
|
|
50
|
+
child.on("error", (err: Error) => {
|
|
51
|
+
callbacks.onOutput(`Error: ${err.message}\n`);
|
|
52
|
+
callbacks.onComplete({
|
|
53
|
+
success: false,
|
|
54
|
+
exitCode: null,
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Return abort function
|
|
59
|
+
return () => {
|
|
60
|
+
child.kill("SIGTERM");
|
|
61
|
+
};
|
|
62
|
+
};
|
package/src/opencode.ts
CHANGED
|
@@ -18,3 +18,34 @@ export const launchOpenCode = (cwd: string): void => {
|
|
|
18
18
|
process.exit(exitCode);
|
|
19
19
|
});
|
|
20
20
|
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Open a path in the system file manager (Finder on macOS, xdg-open on Linux, explorer on Windows)
|
|
24
|
+
*/
|
|
25
|
+
export const openInFileManager = (path: string): boolean => {
|
|
26
|
+
const platform = process.platform;
|
|
27
|
+
let command: string;
|
|
28
|
+
let args: string[];
|
|
29
|
+
|
|
30
|
+
if (platform === "darwin") {
|
|
31
|
+
command = "open";
|
|
32
|
+
args = [path];
|
|
33
|
+
} else if (platform === "win32") {
|
|
34
|
+
command = "explorer";
|
|
35
|
+
args = [path];
|
|
36
|
+
} else {
|
|
37
|
+
// Linux and others
|
|
38
|
+
command = "xdg-open";
|
|
39
|
+
args = [path];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
spawn(command, args, {
|
|
44
|
+
detached: true,
|
|
45
|
+
stdio: "ignore",
|
|
46
|
+
}).unref();
|
|
47
|
+
return true;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
};
|
package/src/types.ts
CHANGED
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,26 @@ 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 configInput: InputRenderable | null = null;
|
|
95
|
+
private isFirstTimeSetup = false;
|
|
96
|
+
|
|
75
97
|
constructor(
|
|
76
98
|
private renderer: CliRenderer,
|
|
77
99
|
private targetPath: string,
|
|
@@ -129,7 +151,7 @@ class WorktreeSelector {
|
|
|
129
151
|
left: 2,
|
|
130
152
|
top: 20,
|
|
131
153
|
content:
|
|
132
|
-
"↑/↓ navigate • Enter open • d delete • n new •
|
|
154
|
+
"↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit",
|
|
133
155
|
fg: "#64748B",
|
|
134
156
|
});
|
|
135
157
|
this.renderer.root.add(this.instructions);
|
|
@@ -138,7 +160,7 @@ class WorktreeSelector {
|
|
|
138
160
|
SelectRenderableEvents.ITEM_SELECTED,
|
|
139
161
|
(_index: number, option: SelectOption) => {
|
|
140
162
|
// Ignore if we're in another mode
|
|
141
|
-
if (this.isConfirming || this.isCreatingWorktree) {
|
|
163
|
+
if (this.isConfirming || this.isCreatingWorktree || this.isSelectingForDelete) {
|
|
142
164
|
return;
|
|
143
165
|
}
|
|
144
166
|
this.handleSelection(option.value as SelectionValue);
|
|
@@ -150,6 +172,11 @@ class WorktreeSelector {
|
|
|
150
172
|
});
|
|
151
173
|
|
|
152
174
|
this.selectElement.focus();
|
|
175
|
+
|
|
176
|
+
// Check for first-time setup
|
|
177
|
+
if (this.repoRoot && !configExists(this.repoRoot)) {
|
|
178
|
+
this.showFirstTimeSetup();
|
|
179
|
+
}
|
|
153
180
|
}
|
|
154
181
|
|
|
155
182
|
private getInitialStatusMessage(): string {
|
|
@@ -184,10 +211,36 @@ class WorktreeSelector {
|
|
|
184
211
|
|
|
185
212
|
private handleKeypress(key: KeyEvent): void {
|
|
186
213
|
if (key.ctrl && key.name === "c") {
|
|
214
|
+
// If running hook, abort it first
|
|
215
|
+
if (this.isRunningHook && this.hookAbortFn) {
|
|
216
|
+
this.hookAbortFn();
|
|
217
|
+
this.hookAbortFn = null;
|
|
218
|
+
this.setStatus("Hook aborted by user.", "warning");
|
|
219
|
+
this.hideHookOutput();
|
|
220
|
+
this.loadWorktrees(this.pendingWorktreePath || undefined);
|
|
221
|
+
this.selectElement.visible = true;
|
|
222
|
+
this.selectElement.focus();
|
|
223
|
+
this.instructions.content =
|
|
224
|
+
"↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit";
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
187
227
|
this.cleanup(true);
|
|
188
228
|
return;
|
|
189
229
|
}
|
|
190
230
|
|
|
231
|
+
// Handle hook running mode (only allow Ctrl+C which is handled above)
|
|
232
|
+
if (this.isRunningHook && !this.hookFailed) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Handle hook failure mode - let the select handle input
|
|
237
|
+
if (this.isRunningHook && this.hookFailed) {
|
|
238
|
+
if (key.name === "escape") {
|
|
239
|
+
this.handleHookFailureChoice("cancel");
|
|
240
|
+
}
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
191
244
|
// Handle confirmation mode
|
|
192
245
|
if (this.isConfirming) {
|
|
193
246
|
if (key.name === "escape") {
|
|
@@ -203,6 +256,42 @@ class WorktreeSelector {
|
|
|
203
256
|
return;
|
|
204
257
|
}
|
|
205
258
|
|
|
259
|
+
// Handle config editing mode
|
|
260
|
+
if (this.isEditingConfig) {
|
|
261
|
+
if (key.name === "escape") {
|
|
262
|
+
this.hideConfigEditor();
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (key.name === "return") {
|
|
266
|
+
const value = this.configInput?.value || "";
|
|
267
|
+
this.handleConfigSave(value);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Handle multi-select delete mode
|
|
274
|
+
if (this.isSelectingForDelete) {
|
|
275
|
+
if (key.name === "escape") {
|
|
276
|
+
this.exitSelectMode();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (key.name === "return") {
|
|
280
|
+
this.toggleWorktreeSelection();
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (key.name === "d") {
|
|
284
|
+
// Confirm deletion of selected worktrees
|
|
285
|
+
this.confirmBatchDelete();
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (key.name === "q") {
|
|
289
|
+
this.exitSelectMode();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
206
295
|
if (key.name === "q" || key.name === "escape") {
|
|
207
296
|
this.cleanup(true);
|
|
208
297
|
return;
|
|
@@ -219,9 +308,21 @@ class WorktreeSelector {
|
|
|
219
308
|
return;
|
|
220
309
|
}
|
|
221
310
|
|
|
222
|
-
// 'd' for delete
|
|
311
|
+
// 'd' for entering delete selection mode
|
|
223
312
|
if (key.name === "d") {
|
|
224
|
-
this.
|
|
313
|
+
this.enterSelectMode();
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// 'o' for opening worktree path in file manager
|
|
318
|
+
if (key.name === "o") {
|
|
319
|
+
this.openWorktreeInFileManager();
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// 'c' for editing config
|
|
324
|
+
if (key.name === "c") {
|
|
325
|
+
this.showConfigEditor();
|
|
225
326
|
return;
|
|
226
327
|
}
|
|
227
328
|
}
|
|
@@ -242,6 +343,21 @@ class WorktreeSelector {
|
|
|
242
343
|
launchOpenCode(worktree.path);
|
|
243
344
|
}
|
|
244
345
|
|
|
346
|
+
private openWorktreeInFileManager(): void {
|
|
347
|
+
const worktree = this.getSelectedWorktree();
|
|
348
|
+
if (!worktree) {
|
|
349
|
+
this.setStatus("Select a worktree to open in file manager.", "warning");
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const success = openInFileManager(worktree.path);
|
|
354
|
+
if (success) {
|
|
355
|
+
this.setStatus(`Opened ${worktree.path} in file manager.`, "success");
|
|
356
|
+
} else {
|
|
357
|
+
this.setStatus("Failed to open file manager.", "error");
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
245
361
|
private showCreateWorktreeInput(): void {
|
|
246
362
|
this.isCreatingWorktree = true;
|
|
247
363
|
this.selectElement.visible = false;
|
|
@@ -296,7 +412,7 @@ class WorktreeSelector {
|
|
|
296
412
|
this.renderer.requestRender();
|
|
297
413
|
}
|
|
298
414
|
|
|
299
|
-
private hideCreateWorktreeInput(): void {
|
|
415
|
+
private hideCreateWorktreeInput(selectWorktreePath?: string): void {
|
|
300
416
|
this.isCreatingWorktree = false;
|
|
301
417
|
|
|
302
418
|
if (this.branchInput) {
|
|
@@ -311,9 +427,9 @@ class WorktreeSelector {
|
|
|
311
427
|
|
|
312
428
|
this.selectElement.visible = true;
|
|
313
429
|
this.instructions.content =
|
|
314
|
-
"↑/↓ navigate • Enter open • d delete • n new •
|
|
430
|
+
"↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit";
|
|
315
431
|
this.selectElement.focus();
|
|
316
|
-
this.loadWorktrees();
|
|
432
|
+
this.loadWorktrees(selectWorktreePath);
|
|
317
433
|
}
|
|
318
434
|
|
|
319
435
|
private handleCreateWorktree(branchName: string): void {
|
|
@@ -336,25 +452,341 @@ class WorktreeSelector {
|
|
|
336
452
|
|
|
337
453
|
if (result.success) {
|
|
338
454
|
this.setStatus(`Worktree created at ${result.path}`, "success");
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
455
|
+
|
|
456
|
+
// Check for post-create hook
|
|
457
|
+
const config = loadRepoConfig(this.repoRoot);
|
|
458
|
+
if (config.postCreateHook) {
|
|
459
|
+
this.pendingWorktreePath = result.path;
|
|
460
|
+
this.runHook(result.path, config.postCreateHook);
|
|
461
|
+
} else {
|
|
462
|
+
// No hook, launch opencode directly
|
|
342
463
|
this.hideCreateWorktreeInput();
|
|
343
464
|
this.cleanup(false);
|
|
344
465
|
launchOpenCode(result.path);
|
|
345
|
-
} else {
|
|
346
|
-
this.setStatus(
|
|
347
|
-
`Worktree created but opencode is not available.`,
|
|
348
|
-
"warning",
|
|
349
|
-
);
|
|
350
|
-
this.hideCreateWorktreeInput();
|
|
351
466
|
}
|
|
352
467
|
} else {
|
|
353
468
|
this.setStatus(`Failed to create worktree: ${result.error}`, "error");
|
|
354
469
|
}
|
|
355
470
|
}
|
|
356
471
|
|
|
357
|
-
private
|
|
472
|
+
private runHook(worktreePath: string, command: string): void {
|
|
473
|
+
this.isRunningHook = true;
|
|
474
|
+
this.hookFailed = false;
|
|
475
|
+
this.hookOutput = [];
|
|
476
|
+
|
|
477
|
+
// Hide create input if still visible
|
|
478
|
+
if (this.inputContainer) {
|
|
479
|
+
this.renderer.root.remove(this.inputContainer.id);
|
|
480
|
+
this.inputContainer = null;
|
|
481
|
+
this.branchInput = null;
|
|
482
|
+
}
|
|
483
|
+
this.isCreatingWorktree = false;
|
|
484
|
+
this.selectElement.visible = false;
|
|
485
|
+
|
|
486
|
+
// Create hook output container
|
|
487
|
+
this.hookOutputContainer = new BoxRenderable(this.renderer, {
|
|
488
|
+
id: "hook-output-container",
|
|
489
|
+
position: "absolute",
|
|
490
|
+
left: 2,
|
|
491
|
+
top: 3,
|
|
492
|
+
width: 76,
|
|
493
|
+
height: 14,
|
|
494
|
+
borderStyle: "single",
|
|
495
|
+
borderColor: "#38BDF8",
|
|
496
|
+
title: `Running: ${command}`,
|
|
497
|
+
titleAlignment: "left",
|
|
498
|
+
backgroundColor: "#0F172A",
|
|
499
|
+
border: true,
|
|
500
|
+
});
|
|
501
|
+
this.renderer.root.add(this.hookOutputContainer);
|
|
502
|
+
|
|
503
|
+
this.hookOutputText = new TextRenderable(this.renderer, {
|
|
504
|
+
id: "hook-output-text",
|
|
505
|
+
position: "absolute",
|
|
506
|
+
left: 1,
|
|
507
|
+
top: 1,
|
|
508
|
+
content: "Starting...\n",
|
|
509
|
+
fg: "#94A3B8",
|
|
510
|
+
});
|
|
511
|
+
this.hookOutputContainer.add(this.hookOutputText);
|
|
512
|
+
|
|
513
|
+
this.instructions.content = "Hook running... (Ctrl+C to abort)";
|
|
514
|
+
this.setStatus(`Executing post-create hook...`, "info");
|
|
515
|
+
this.renderer.requestRender();
|
|
516
|
+
|
|
517
|
+
// Run the hook with streaming output
|
|
518
|
+
this.hookAbortFn = runPostCreateHook(worktreePath, command, {
|
|
519
|
+
onOutput: (data: string) => {
|
|
520
|
+
this.hookOutput.push(data);
|
|
521
|
+
this.updateHookOutput();
|
|
522
|
+
},
|
|
523
|
+
onComplete: (result: HookResult) => {
|
|
524
|
+
this.hookAbortFn = null;
|
|
525
|
+
if (result.success) {
|
|
526
|
+
this.onHookSuccess();
|
|
527
|
+
} else {
|
|
528
|
+
this.onHookFailure(result.exitCode);
|
|
529
|
+
}
|
|
530
|
+
},
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private updateHookOutput(): void {
|
|
535
|
+
if (!this.hookOutputText) return;
|
|
536
|
+
|
|
537
|
+
// Join all output and take the last N lines that fit in the container
|
|
538
|
+
const fullOutput = this.hookOutput.join("");
|
|
539
|
+
const lines = fullOutput.split("\n");
|
|
540
|
+
const maxLines = 11; // Container height minus borders and padding
|
|
541
|
+
const visibleLines = lines.slice(-maxLines);
|
|
542
|
+
|
|
543
|
+
this.hookOutputText.content = visibleLines.join("\n");
|
|
544
|
+
this.renderer.requestRender();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private onHookSuccess(): void {
|
|
548
|
+
this.setStatus("Hook completed successfully!", "success");
|
|
549
|
+
this.renderer.requestRender();
|
|
550
|
+
|
|
551
|
+
// Brief delay to show success, then launch opencode
|
|
552
|
+
setTimeout(() => {
|
|
553
|
+
this.hideHookOutput();
|
|
554
|
+
if (this.pendingWorktreePath) {
|
|
555
|
+
this.cleanup(false);
|
|
556
|
+
launchOpenCode(this.pendingWorktreePath);
|
|
557
|
+
}
|
|
558
|
+
}, 1000);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
private onHookFailure(exitCode: number | null): void {
|
|
562
|
+
this.hookFailed = true;
|
|
563
|
+
const exitMsg = exitCode !== null ? ` (exit code: ${exitCode})` : "";
|
|
564
|
+
this.setStatus(`Hook failed${exitMsg}`, "error");
|
|
565
|
+
|
|
566
|
+
// Add failure options to the container
|
|
567
|
+
if (this.hookOutputContainer) {
|
|
568
|
+
this.hookFailureSelect = new SelectRenderable(this.renderer, {
|
|
569
|
+
id: "hook-failure-select",
|
|
570
|
+
position: "absolute",
|
|
571
|
+
left: 1,
|
|
572
|
+
top: 12,
|
|
573
|
+
width: 72,
|
|
574
|
+
height: 2,
|
|
575
|
+
options: [
|
|
576
|
+
{
|
|
577
|
+
name: "Open in opencode anyway",
|
|
578
|
+
description: "Launch opencode despite hook failure",
|
|
579
|
+
value: "open",
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
name: "Cancel",
|
|
583
|
+
description: "Return to worktree list",
|
|
584
|
+
value: "cancel",
|
|
585
|
+
},
|
|
586
|
+
],
|
|
587
|
+
backgroundColor: "#0F172A",
|
|
588
|
+
focusedBackgroundColor: "#1E293B",
|
|
589
|
+
selectedBackgroundColor: "#1E3A5F",
|
|
590
|
+
textColor: "#E2E8F0",
|
|
591
|
+
selectedTextColor: "#38BDF8",
|
|
592
|
+
descriptionColor: "#94A3B8",
|
|
593
|
+
selectedDescriptionColor: "#E2E8F0",
|
|
594
|
+
showDescription: false,
|
|
595
|
+
wrapSelection: true,
|
|
596
|
+
});
|
|
597
|
+
this.hookOutputContainer.add(this.hookFailureSelect);
|
|
598
|
+
|
|
599
|
+
this.hookFailureSelect.on(
|
|
600
|
+
SelectRenderableEvents.ITEM_SELECTED,
|
|
601
|
+
(_index: number, option: SelectOption) => {
|
|
602
|
+
this.handleHookFailureChoice(option.value as string);
|
|
603
|
+
}
|
|
604
|
+
);
|
|
605
|
+
|
|
606
|
+
this.hookFailureSelect.focus();
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
this.instructions.content = "↑/↓ select • Enter confirm";
|
|
610
|
+
this.renderer.requestRender();
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
private handleHookFailureChoice(choice: string): void {
|
|
614
|
+
if (choice === "open" && this.pendingWorktreePath) {
|
|
615
|
+
this.hideHookOutput();
|
|
616
|
+
this.cleanup(false);
|
|
617
|
+
launchOpenCode(this.pendingWorktreePath);
|
|
618
|
+
} else {
|
|
619
|
+
// Cancel - return to list
|
|
620
|
+
this.hideHookOutput();
|
|
621
|
+
this.loadWorktrees(this.pendingWorktreePath || undefined);
|
|
622
|
+
this.selectElement.visible = true;
|
|
623
|
+
this.selectElement.focus();
|
|
624
|
+
this.instructions.content =
|
|
625
|
+
"↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit";
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private hideHookOutput(): void {
|
|
630
|
+
this.isRunningHook = false;
|
|
631
|
+
this.hookFailed = false;
|
|
632
|
+
this.hookOutput = [];
|
|
633
|
+
this.pendingWorktreePath = null;
|
|
634
|
+
|
|
635
|
+
if (this.hookFailureSelect) {
|
|
636
|
+
this.hookFailureSelect.blur();
|
|
637
|
+
this.hookFailureSelect = null;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (this.hookOutputContainer) {
|
|
641
|
+
this.renderer.root.remove(this.hookOutputContainer.id);
|
|
642
|
+
this.hookOutputContainer = null;
|
|
643
|
+
this.hookOutputText = null;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// ========== Config Editor Methods ==========
|
|
648
|
+
|
|
649
|
+
private showFirstTimeSetup(): void {
|
|
650
|
+
this.isFirstTimeSetup = true;
|
|
651
|
+
this.showConfigEditor();
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
private showConfigEditor(): void {
|
|
655
|
+
if (!this.repoRoot) {
|
|
656
|
+
this.setStatus("No git repository found.", "error");
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
this.isEditingConfig = true;
|
|
661
|
+
this.selectElement.visible = false;
|
|
662
|
+
this.selectElement.blur();
|
|
663
|
+
|
|
664
|
+
// Load existing config to pre-fill
|
|
665
|
+
const existingConfig = loadRepoConfig(this.repoRoot);
|
|
666
|
+
|
|
667
|
+
const title = this.isFirstTimeSetup
|
|
668
|
+
? "First-time Setup: Configure Post-create Hook"
|
|
669
|
+
: "Edit Post-create Hook";
|
|
670
|
+
|
|
671
|
+
this.configContainer = new BoxRenderable(this.renderer, {
|
|
672
|
+
id: "config-container",
|
|
673
|
+
position: "absolute",
|
|
674
|
+
left: 2,
|
|
675
|
+
top: 3,
|
|
676
|
+
width: 76,
|
|
677
|
+
height: 8,
|
|
678
|
+
borderStyle: "single",
|
|
679
|
+
borderColor: "#38BDF8",
|
|
680
|
+
title,
|
|
681
|
+
titleAlignment: "center",
|
|
682
|
+
backgroundColor: "#0F172A",
|
|
683
|
+
border: true,
|
|
684
|
+
});
|
|
685
|
+
this.renderer.root.add(this.configContainer);
|
|
686
|
+
|
|
687
|
+
const helpText = new TextRenderable(this.renderer, {
|
|
688
|
+
id: "config-help",
|
|
689
|
+
position: "absolute",
|
|
690
|
+
left: 1,
|
|
691
|
+
top: 1,
|
|
692
|
+
content: "Command to run after creating a worktree (e.g., npm install):",
|
|
693
|
+
fg: "#94A3B8",
|
|
694
|
+
});
|
|
695
|
+
this.configContainer.add(helpText);
|
|
696
|
+
|
|
697
|
+
const skipHint = new TextRenderable(this.renderer, {
|
|
698
|
+
id: "config-skip-hint",
|
|
699
|
+
position: "absolute",
|
|
700
|
+
left: 1,
|
|
701
|
+
top: 4,
|
|
702
|
+
content: "Leave empty to skip post-create hooks.",
|
|
703
|
+
fg: "#64748B",
|
|
704
|
+
});
|
|
705
|
+
this.configContainer.add(skipHint);
|
|
706
|
+
|
|
707
|
+
this.configInput = new InputRenderable(this.renderer, {
|
|
708
|
+
id: "config-hook-input",
|
|
709
|
+
position: "absolute",
|
|
710
|
+
left: 1,
|
|
711
|
+
top: 3,
|
|
712
|
+
width: 72,
|
|
713
|
+
placeholder: "npm install",
|
|
714
|
+
value: existingConfig.postCreateHook || "",
|
|
715
|
+
focusedBackgroundColor: "#1E293B",
|
|
716
|
+
backgroundColor: "#1E293B",
|
|
717
|
+
});
|
|
718
|
+
this.configContainer.add(this.configInput);
|
|
719
|
+
|
|
720
|
+
this.instructions.content = "Enter to save • Esc to cancel";
|
|
721
|
+
this.setStatus(
|
|
722
|
+
this.isFirstTimeSetup
|
|
723
|
+
? "Welcome! Configure your post-create hook for this repository."
|
|
724
|
+
: "Edit the post-create hook command.",
|
|
725
|
+
"info"
|
|
726
|
+
);
|
|
727
|
+
|
|
728
|
+
// Delay focus to prevent the triggering keypress from being captured
|
|
729
|
+
setTimeout(() => {
|
|
730
|
+
this.configInput?.focus();
|
|
731
|
+
this.renderer.requestRender();
|
|
732
|
+
}, 0);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
private hideConfigEditor(): void {
|
|
736
|
+
this.isEditingConfig = false;
|
|
737
|
+
this.isFirstTimeSetup = false;
|
|
738
|
+
|
|
739
|
+
if (this.configInput) {
|
|
740
|
+
this.configInput.blur();
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (this.configContainer) {
|
|
744
|
+
this.renderer.root.remove(this.configContainer.id);
|
|
745
|
+
this.configContainer = null;
|
|
746
|
+
this.configInput = null;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
this.selectElement.visible = true;
|
|
750
|
+
this.instructions.content =
|
|
751
|
+
"↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit";
|
|
752
|
+
|
|
753
|
+
// Delay focus to prevent the Enter keypress from triggering a selection
|
|
754
|
+
setTimeout(() => {
|
|
755
|
+
this.selectElement.focus();
|
|
756
|
+
this.renderer.requestRender();
|
|
757
|
+
}, 0);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
private handleConfigSave(hookCommand: string): void {
|
|
761
|
+
if (!this.repoRoot) {
|
|
762
|
+
this.setStatus("No git repository found.", "error");
|
|
763
|
+
this.hideConfigEditor();
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const trimmed = hookCommand.trim();
|
|
768
|
+
const config: Config = {};
|
|
769
|
+
|
|
770
|
+
if (trimmed) {
|
|
771
|
+
config.postCreateHook = trimmed;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const success = saveRepoConfig(this.repoRoot, config);
|
|
775
|
+
|
|
776
|
+
if (success) {
|
|
777
|
+
if (trimmed) {
|
|
778
|
+
this.setStatus(`Post-create hook saved: "${trimmed}"`, "success");
|
|
779
|
+
} else {
|
|
780
|
+
this.setStatus("Post-create hook cleared.", "success");
|
|
781
|
+
}
|
|
782
|
+
} else {
|
|
783
|
+
this.setStatus("Failed to save config.", "error");
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
this.hideConfigEditor();
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
private loadWorktrees(selectWorktreePath?: string): void {
|
|
358
790
|
this.repoRoot = resolveRepoRoot(this.targetPath);
|
|
359
791
|
if (!this.repoRoot) {
|
|
360
792
|
this.setStatus("No git repository found in this directory.", "error");
|
|
@@ -366,6 +798,17 @@ class WorktreeSelector {
|
|
|
366
798
|
const worktrees = listWorktrees(this.repoRoot);
|
|
367
799
|
this.selectElement.options = this.buildOptions(worktrees);
|
|
368
800
|
|
|
801
|
+
// Preselect a specific worktree if path is provided
|
|
802
|
+
if (selectWorktreePath) {
|
|
803
|
+
const index = this.selectElement.options.findIndex((opt: SelectOption) => {
|
|
804
|
+
if (opt.value === CREATE_NEW_WORKTREE_VALUE) return false;
|
|
805
|
+
return (opt.value as WorktreeInfo).path === selectWorktreePath;
|
|
806
|
+
});
|
|
807
|
+
if (index >= 0) {
|
|
808
|
+
this.selectElement.setSelectedIndex(index);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
369
812
|
if (worktrees.length === 0) {
|
|
370
813
|
this.setStatus(
|
|
371
814
|
"No worktrees detected. Select 'Create new worktree' to add one.",
|
|
@@ -394,24 +837,89 @@ class WorktreeSelector {
|
|
|
394
837
|
};
|
|
395
838
|
|
|
396
839
|
const worktreeOptions = worktrees.map((worktree) => {
|
|
397
|
-
const shortHead = worktree.head ? worktree.head.slice(0, 7) : "unknown";
|
|
398
840
|
const baseName = basename(worktree.path);
|
|
399
|
-
const
|
|
841
|
+
const isMain = this.repoRoot && isMainWorktree(this.repoRoot, worktree.path);
|
|
842
|
+
|
|
843
|
+
// Build base label
|
|
844
|
+
let label = worktree.branch
|
|
400
845
|
? worktree.branch
|
|
401
846
|
: worktree.isDetached
|
|
402
847
|
? `${baseName} (detached)`
|
|
403
848
|
: baseName;
|
|
404
849
|
|
|
850
|
+
// Add status indicators
|
|
851
|
+
const indicators: string[] = [];
|
|
852
|
+
if (isMain) {
|
|
853
|
+
indicators.push("main");
|
|
854
|
+
}
|
|
855
|
+
if (worktree.isDirty) {
|
|
856
|
+
indicators.push("*");
|
|
857
|
+
}
|
|
858
|
+
if (!worktree.isOnRemote && worktree.branch && !isMain) {
|
|
859
|
+
indicators.push("local");
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (indicators.length > 0) {
|
|
863
|
+
label = `${label} [${indicators.join(" ")}]`;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Add checkbox prefix in selection mode
|
|
867
|
+
let displayName = label;
|
|
868
|
+
if (this.isSelectingForDelete) {
|
|
869
|
+
const isSelected = this.selectedForDelete.has(worktree.path);
|
|
870
|
+
if (isMain) {
|
|
871
|
+
displayName = ` [main] ${worktree.branch || baseName}`;
|
|
872
|
+
} else {
|
|
873
|
+
displayName = isSelected ? `[x] ${label}` : `[ ] ${label}`;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Build description with metadata
|
|
878
|
+
const descParts: string[] = [];
|
|
879
|
+
|
|
880
|
+
// Last modified date
|
|
881
|
+
if (worktree.lastModified) {
|
|
882
|
+
descParts.push(this.formatRelativeDate(worktree.lastModified));
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// Path (shortened if too long)
|
|
886
|
+
const maxPathLen = 45;
|
|
887
|
+
const pathDisplay = worktree.path.length > maxPathLen
|
|
888
|
+
? "..." + worktree.path.slice(-maxPathLen + 3)
|
|
889
|
+
: worktree.path;
|
|
890
|
+
descParts.push(pathDisplay);
|
|
891
|
+
|
|
405
892
|
return {
|
|
406
|
-
name:
|
|
407
|
-
description:
|
|
893
|
+
name: displayName,
|
|
894
|
+
description: descParts.join(" | "),
|
|
408
895
|
value: worktree,
|
|
409
896
|
};
|
|
410
897
|
});
|
|
411
898
|
|
|
899
|
+
// Don't show create option in delete selection mode
|
|
900
|
+
if (this.isSelectingForDelete) {
|
|
901
|
+
return worktreeOptions;
|
|
902
|
+
}
|
|
903
|
+
|
|
412
904
|
return [createOption, ...worktreeOptions];
|
|
413
905
|
}
|
|
414
906
|
|
|
907
|
+
private formatRelativeDate(date: Date): string {
|
|
908
|
+
const now = new Date();
|
|
909
|
+
const diffMs = now.getTime() - date.getTime();
|
|
910
|
+
const diffMins = Math.floor(diffMs / (1000 * 60));
|
|
911
|
+
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
|
912
|
+
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
913
|
+
|
|
914
|
+
if (diffMins < 1) return "just now";
|
|
915
|
+
if (diffMins < 60) return `${diffMins}m ago`;
|
|
916
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
917
|
+
if (diffDays < 7) return `${diffDays}d ago`;
|
|
918
|
+
if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`;
|
|
919
|
+
if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo ago`;
|
|
920
|
+
return `${Math.floor(diffDays / 365)}y ago`;
|
|
921
|
+
}
|
|
922
|
+
|
|
415
923
|
private setStatus(message: string, level: StatusLevel): void {
|
|
416
924
|
this.statusText.content = message;
|
|
417
925
|
this.statusText.fg = statusColors[level];
|
|
@@ -573,7 +1081,7 @@ class WorktreeSelector {
|
|
|
573
1081
|
|
|
574
1082
|
this.selectElement.visible = true;
|
|
575
1083
|
this.instructions.content =
|
|
576
|
-
"↑/↓ navigate • Enter open • d delete • n new •
|
|
1084
|
+
"↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit";
|
|
577
1085
|
this.selectElement.focus();
|
|
578
1086
|
this.loadWorktrees();
|
|
579
1087
|
}
|
|
@@ -640,6 +1148,307 @@ class WorktreeSelector {
|
|
|
640
1148
|
this.hideConfirmDialog();
|
|
641
1149
|
}
|
|
642
1150
|
|
|
1151
|
+
// ========== Multi-select delete mode methods ==========
|
|
1152
|
+
|
|
1153
|
+
private enterSelectMode(): void {
|
|
1154
|
+
if (!this.repoRoot) {
|
|
1155
|
+
this.setStatus("No git repository found.", "error");
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const worktrees = listWorktrees(this.repoRoot);
|
|
1160
|
+
// Filter out main worktree
|
|
1161
|
+
const deletableWorktrees = worktrees.filter(
|
|
1162
|
+
(wt) => !isMainWorktree(this.repoRoot!, wt.path)
|
|
1163
|
+
);
|
|
1164
|
+
|
|
1165
|
+
if (deletableWorktrees.length === 0) {
|
|
1166
|
+
this.setStatus("No worktrees available for deletion.", "warning");
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
this.isSelectingForDelete = true;
|
|
1171
|
+
this.selectedForDelete.clear();
|
|
1172
|
+
|
|
1173
|
+
// Rebuild options to show checkboxes
|
|
1174
|
+
this.selectElement.options = this.buildOptions(worktrees);
|
|
1175
|
+
this.instructions.content =
|
|
1176
|
+
"Enter toggle selection • d confirm delete • Esc cancel";
|
|
1177
|
+
this.setStatus("Select worktrees to delete, then press 'd' to confirm.", "info");
|
|
1178
|
+
this.renderer.requestRender();
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
private exitSelectMode(): void {
|
|
1182
|
+
this.isSelectingForDelete = false;
|
|
1183
|
+
this.selectedForDelete.clear();
|
|
1184
|
+
this.loadWorktrees();
|
|
1185
|
+
this.instructions.content =
|
|
1186
|
+
"↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit";
|
|
1187
|
+
this.renderer.requestRender();
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
private toggleWorktreeSelection(): void {
|
|
1191
|
+
const selectedIndex = this.selectElement.getSelectedIndex();
|
|
1192
|
+
const option = this.selectElement.options[selectedIndex];
|
|
1193
|
+
if (!option) return;
|
|
1194
|
+
|
|
1195
|
+
const worktree = option.value as WorktreeInfo;
|
|
1196
|
+
if (!worktree.path) return;
|
|
1197
|
+
|
|
1198
|
+
// Prevent selecting main worktree
|
|
1199
|
+
if (this.repoRoot && isMainWorktree(this.repoRoot, worktree.path)) {
|
|
1200
|
+
this.setStatus("Cannot delete the main worktree.", "warning");
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
if (this.selectedForDelete.has(worktree.path)) {
|
|
1205
|
+
this.selectedForDelete.delete(worktree.path);
|
|
1206
|
+
} else {
|
|
1207
|
+
this.selectedForDelete.add(worktree.path);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// Rebuild options to update checkboxes
|
|
1211
|
+
if (this.repoRoot) {
|
|
1212
|
+
const worktrees = listWorktrees(this.repoRoot);
|
|
1213
|
+
this.selectElement.options = this.buildOptions(worktrees);
|
|
1214
|
+
// Restore selection index
|
|
1215
|
+
this.selectElement.setSelectedIndex(selectedIndex);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
const count = this.selectedForDelete.size;
|
|
1219
|
+
this.setStatus(
|
|
1220
|
+
count === 0
|
|
1221
|
+
? "Select worktrees to delete, then press 'd' to confirm."
|
|
1222
|
+
: `${count} worktree${count === 1 ? "" : "s"} selected for deletion.`,
|
|
1223
|
+
"info"
|
|
1224
|
+
);
|
|
1225
|
+
this.renderer.requestRender();
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
private confirmBatchDelete(): void {
|
|
1229
|
+
if (this.selectedForDelete.size === 0) {
|
|
1230
|
+
this.setStatus("No worktrees selected. Use Enter to select.", "warning");
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// Get the worktree info for selected paths
|
|
1235
|
+
if (!this.repoRoot) return;
|
|
1236
|
+
|
|
1237
|
+
const worktrees = listWorktrees(this.repoRoot);
|
|
1238
|
+
const toDelete = worktrees.filter((wt) =>
|
|
1239
|
+
this.selectedForDelete.has(wt.path)
|
|
1240
|
+
);
|
|
1241
|
+
|
|
1242
|
+
// Show batch confirmation dialog
|
|
1243
|
+
this.showBatchDeleteConfirmation(toDelete);
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
private showBatchDeleteConfirmation(worktrees: WorktreeInfo[]): void {
|
|
1247
|
+
this.isConfirming = true;
|
|
1248
|
+
this.isSelectingForDelete = false;
|
|
1249
|
+
this.selectElement.visible = false;
|
|
1250
|
+
this.selectElement.blur();
|
|
1251
|
+
|
|
1252
|
+
// Check if any have uncommitted changes
|
|
1253
|
+
const dirtyWorktrees = worktrees.filter((wt) =>
|
|
1254
|
+
hasUncommittedChanges(wt.path)
|
|
1255
|
+
);
|
|
1256
|
+
const hasDirty = dirtyWorktrees.length > 0;
|
|
1257
|
+
|
|
1258
|
+
const count = worktrees.length;
|
|
1259
|
+
const title = `Delete ${count} worktree${count === 1 ? "" : "s"}`;
|
|
1260
|
+
|
|
1261
|
+
this.confirmContainer = new BoxRenderable(this.renderer, {
|
|
1262
|
+
id: "confirm-container",
|
|
1263
|
+
position: "absolute",
|
|
1264
|
+
left: 2,
|
|
1265
|
+
top: 3,
|
|
1266
|
+
width: 76,
|
|
1267
|
+
height: hasDirty ? 12 : 10,
|
|
1268
|
+
borderStyle: "single",
|
|
1269
|
+
borderColor: "#F59E0B",
|
|
1270
|
+
title,
|
|
1271
|
+
titleAlignment: "center",
|
|
1272
|
+
backgroundColor: "#0F172A",
|
|
1273
|
+
border: true,
|
|
1274
|
+
});
|
|
1275
|
+
this.renderer.root.add(this.confirmContainer);
|
|
1276
|
+
|
|
1277
|
+
let yOffset = 1;
|
|
1278
|
+
|
|
1279
|
+
// Warning for dirty worktrees
|
|
1280
|
+
if (hasDirty) {
|
|
1281
|
+
const warningText = new TextRenderable(this.renderer, {
|
|
1282
|
+
id: "confirm-warning",
|
|
1283
|
+
position: "absolute",
|
|
1284
|
+
left: 1,
|
|
1285
|
+
top: yOffset,
|
|
1286
|
+
content: `⚠ ${dirtyWorktrees.length} worktree${dirtyWorktrees.length === 1 ? " has" : "s have"} uncommitted changes!`,
|
|
1287
|
+
fg: "#F59E0B",
|
|
1288
|
+
});
|
|
1289
|
+
this.confirmContainer.add(warningText);
|
|
1290
|
+
yOffset += 2;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// List worktrees to be deleted
|
|
1294
|
+
const branchNames = worktrees
|
|
1295
|
+
.map((wt) => wt.branch || basename(wt.path))
|
|
1296
|
+
.slice(0, 3);
|
|
1297
|
+
const displayList =
|
|
1298
|
+
branchNames.join(", ") + (worktrees.length > 3 ? `, +${worktrees.length - 3} more` : "");
|
|
1299
|
+
|
|
1300
|
+
const listText = new TextRenderable(this.renderer, {
|
|
1301
|
+
id: "confirm-list",
|
|
1302
|
+
position: "absolute",
|
|
1303
|
+
left: 1,
|
|
1304
|
+
top: yOffset,
|
|
1305
|
+
content: `Worktrees: ${displayList}`,
|
|
1306
|
+
fg: "#94A3B8",
|
|
1307
|
+
});
|
|
1308
|
+
this.confirmContainer.add(listText);
|
|
1309
|
+
yOffset += 2;
|
|
1310
|
+
|
|
1311
|
+
// Build options
|
|
1312
|
+
const options: SelectOption[] = [
|
|
1313
|
+
{
|
|
1314
|
+
name: "Unlink all (default)",
|
|
1315
|
+
description: "Remove worktree directories, keep branches for later use",
|
|
1316
|
+
value: CONFIRM_UNLINK_VALUE,
|
|
1317
|
+
},
|
|
1318
|
+
{
|
|
1319
|
+
name: "Delete all",
|
|
1320
|
+
description: "Remove worktrees AND delete local branches (never remote)",
|
|
1321
|
+
value: CONFIRM_DELETE_VALUE,
|
|
1322
|
+
},
|
|
1323
|
+
{
|
|
1324
|
+
name: "Cancel",
|
|
1325
|
+
description: "Go back without changes",
|
|
1326
|
+
value: CONFIRM_CANCEL_VALUE,
|
|
1327
|
+
},
|
|
1328
|
+
];
|
|
1329
|
+
|
|
1330
|
+
this.confirmSelect = new SelectRenderable(this.renderer, {
|
|
1331
|
+
id: "confirm-select",
|
|
1332
|
+
position: "absolute",
|
|
1333
|
+
left: 1,
|
|
1334
|
+
top: yOffset,
|
|
1335
|
+
width: 72,
|
|
1336
|
+
height: 4,
|
|
1337
|
+
options,
|
|
1338
|
+
backgroundColor: "#0F172A",
|
|
1339
|
+
focusedBackgroundColor: "#1E293B",
|
|
1340
|
+
selectedBackgroundColor: "#1E3A5F",
|
|
1341
|
+
textColor: "#E2E8F0",
|
|
1342
|
+
selectedTextColor: "#38BDF8",
|
|
1343
|
+
descriptionColor: "#94A3B8",
|
|
1344
|
+
selectedDescriptionColor: "#E2E8F0",
|
|
1345
|
+
showDescription: true,
|
|
1346
|
+
wrapSelection: true,
|
|
1347
|
+
});
|
|
1348
|
+
this.confirmContainer.add(this.confirmSelect);
|
|
1349
|
+
|
|
1350
|
+
// Store worktrees for batch deletion
|
|
1351
|
+
const worktreesToDelete = worktrees;
|
|
1352
|
+
|
|
1353
|
+
this.confirmSelect.on(
|
|
1354
|
+
SelectRenderableEvents.ITEM_SELECTED,
|
|
1355
|
+
(_index: number, option: SelectOption) => {
|
|
1356
|
+
this.handleBatchConfirmAction(
|
|
1357
|
+
option.value as ConfirmAction,
|
|
1358
|
+
worktreesToDelete,
|
|
1359
|
+
hasDirty
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
);
|
|
1363
|
+
|
|
1364
|
+
this.instructions.content =
|
|
1365
|
+
"↑/↓ select action • Enter confirm • Esc cancel";
|
|
1366
|
+
this.setStatus(
|
|
1367
|
+
hasDirty
|
|
1368
|
+
? "Warning: Some worktrees have uncommitted changes!"
|
|
1369
|
+
: `Ready to remove ${count} worktree${count === 1 ? "" : "s"}.`,
|
|
1370
|
+
hasDirty ? "warning" : "info"
|
|
1371
|
+
);
|
|
1372
|
+
|
|
1373
|
+
this.confirmSelect.focus();
|
|
1374
|
+
this.renderer.requestRender();
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
private handleBatchConfirmAction(
|
|
1378
|
+
action: ConfirmAction,
|
|
1379
|
+
worktrees: WorktreeInfo[],
|
|
1380
|
+
hasDirty: boolean
|
|
1381
|
+
): void {
|
|
1382
|
+
if (action === CONFIRM_CANCEL_VALUE) {
|
|
1383
|
+
this.selectedForDelete.clear();
|
|
1384
|
+
this.hideConfirmDialog();
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
if (!this.repoRoot) {
|
|
1389
|
+
this.selectedForDelete.clear();
|
|
1390
|
+
this.hideConfirmDialog();
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
const count = worktrees.length;
|
|
1395
|
+
let successCount = 0;
|
|
1396
|
+
let failCount = 0;
|
|
1397
|
+
|
|
1398
|
+
for (const worktree of worktrees) {
|
|
1399
|
+
const branchName = worktree.branch || basename(worktree.path);
|
|
1400
|
+
const isDirty = hasUncommittedChanges(worktree.path);
|
|
1401
|
+
|
|
1402
|
+
if (action === CONFIRM_UNLINK_VALUE) {
|
|
1403
|
+
const result = unlinkWorktree(this.repoRoot, worktree.path, isDirty);
|
|
1404
|
+
if (result.success) {
|
|
1405
|
+
successCount++;
|
|
1406
|
+
} else {
|
|
1407
|
+
failCount++;
|
|
1408
|
+
}
|
|
1409
|
+
} else if (action === CONFIRM_DELETE_VALUE) {
|
|
1410
|
+
if (!worktree.branch) {
|
|
1411
|
+
// Can't delete branch for detached HEAD, just unlink
|
|
1412
|
+
const result = unlinkWorktree(this.repoRoot, worktree.path, isDirty);
|
|
1413
|
+
if (result.success) {
|
|
1414
|
+
successCount++;
|
|
1415
|
+
} else {
|
|
1416
|
+
failCount++;
|
|
1417
|
+
}
|
|
1418
|
+
} else {
|
|
1419
|
+
const result = deleteWorktree(
|
|
1420
|
+
this.repoRoot,
|
|
1421
|
+
worktree.path,
|
|
1422
|
+
worktree.branch,
|
|
1423
|
+
isDirty
|
|
1424
|
+
);
|
|
1425
|
+
if (result.success) {
|
|
1426
|
+
successCount++;
|
|
1427
|
+
} else {
|
|
1428
|
+
failCount++;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
this.selectedForDelete.clear();
|
|
1435
|
+
|
|
1436
|
+
if (failCount === 0) {
|
|
1437
|
+
const actionWord = action === CONFIRM_UNLINK_VALUE ? "unlinked" : "deleted";
|
|
1438
|
+
this.setStatus(
|
|
1439
|
+
`Successfully ${actionWord} ${successCount} worktree${successCount === 1 ? "" : "s"}.`,
|
|
1440
|
+
"success"
|
|
1441
|
+
);
|
|
1442
|
+
} else {
|
|
1443
|
+
this.setStatus(
|
|
1444
|
+
`Completed with ${successCount} success, ${failCount} failed.`,
|
|
1445
|
+
"warning"
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
this.hideConfirmDialog();
|
|
1450
|
+
}
|
|
1451
|
+
|
|
643
1452
|
private cleanup(shouldExit: boolean): void {
|
|
644
1453
|
this.selectElement.blur();
|
|
645
1454
|
if (this.branchInput) {
|