ccdock 0.1.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 +195 -0
- package/package.json +48 -0
- package/src/agent/hook.ts +134 -0
- package/src/config/config.ts +58 -0
- package/src/main.ts +65 -0
- package/src/sidebar.ts +556 -0
- package/src/tui/ansi.ts +148 -0
- package/src/tui/input.ts +116 -0
- package/src/tui/render.ts +321 -0
- package/src/tui/wizard.ts +166 -0
- package/src/types.ts +86 -0
- package/src/workspace/editor.ts +32 -0
- package/src/workspace/state.ts +150 -0
- package/src/workspace/window.ts +323 -0
- package/src/worktree/manager.ts +120 -0
- package/src/worktree/scanner.ts +82 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {
|
|
2
|
+
focusAndPositionEditor,
|
|
3
|
+
getSidebarBounds,
|
|
4
|
+
positionEditorWindow,
|
|
5
|
+
editorWindowExists,
|
|
6
|
+
} from "./window.ts";
|
|
7
|
+
|
|
8
|
+
export async function openEditor(worktreePath: string, editor: string): Promise<void> {
|
|
9
|
+
Bun.spawn([editor, "--new-window", worktreePath], {
|
|
10
|
+
stdout: "ignore",
|
|
11
|
+
stderr: "ignore",
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const basename = worktreePath.split("/").pop() ?? "";
|
|
15
|
+
|
|
16
|
+
// Wait for the VS Code window to actually appear (up to 10 seconds)
|
|
17
|
+
for (let i = 0; i < 20; i++) {
|
|
18
|
+
await Bun.sleep(500);
|
|
19
|
+
if (await editorWindowExists(basename)) break;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Position the new window next to the sidebar
|
|
23
|
+
const sidebar = await getSidebarBounds();
|
|
24
|
+
if (sidebar) {
|
|
25
|
+
await positionEditorWindow(basename, sidebar);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function focusEditor(worktreePath: string, _editor: string): Promise<boolean> {
|
|
30
|
+
const basename = worktreePath.split("/").pop() ?? "";
|
|
31
|
+
return focusAndPositionEditor(basename);
|
|
32
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
unlinkSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import type { AgentState, WorkspaceSession } from "../types.ts";
|
|
11
|
+
|
|
12
|
+
const STATE_DIR_NAME = "ccdock";
|
|
13
|
+
const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
|
|
14
|
+
|
|
15
|
+
export function getStateDir(): string {
|
|
16
|
+
const home = process.env.HOME ?? "";
|
|
17
|
+
const stateBase = process.env.XDG_STATE_HOME ?? join(home, ".local", "state");
|
|
18
|
+
return join(stateBase, STATE_DIR_NAME);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getSessionsDir(): string {
|
|
22
|
+
return join(getStateDir(), "sessions");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getAgentsDir(): string {
|
|
26
|
+
return join(getStateDir(), "agents");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let dirsEnsured = false;
|
|
30
|
+
|
|
31
|
+
function ensureDirs(): void {
|
|
32
|
+
if (dirsEnsured) return;
|
|
33
|
+
const sessionsDir = getSessionsDir();
|
|
34
|
+
const agentsDir = getAgentsDir();
|
|
35
|
+
if (!existsSync(sessionsDir)) {
|
|
36
|
+
mkdirSync(sessionsDir, { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
if (!existsSync(agentsDir)) {
|
|
39
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
40
|
+
}
|
|
41
|
+
dirsEnsured = true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function saveSession(session: WorkspaceSession): void {
|
|
45
|
+
ensureDirs();
|
|
46
|
+
const filePath = join(getSessionsDir(), `${session.id}.json`);
|
|
47
|
+
const { agents: _agents, editorState: _editorState, ...serializable } = session;
|
|
48
|
+
writeFileSync(filePath, JSON.stringify(serializable, null, 2));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function loadSessions(): WorkspaceSession[] {
|
|
52
|
+
ensureDirs();
|
|
53
|
+
const sessionsDir = getSessionsDir();
|
|
54
|
+
const files = readdirSync(sessionsDir).filter((f) => f.endsWith(".json"));
|
|
55
|
+
const sessions: WorkspaceSession[] = [];
|
|
56
|
+
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
try {
|
|
59
|
+
const raw = readFileSync(join(sessionsDir, file), "utf-8");
|
|
60
|
+
const parsed = JSON.parse(raw) as WorkspaceSession;
|
|
61
|
+
parsed.agents = [];
|
|
62
|
+
parsed.editorState = parsed.editorState ?? "closed";
|
|
63
|
+
sessions.push(parsed);
|
|
64
|
+
} catch {
|
|
65
|
+
// Skip malformed files
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return sessions.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function deleteSession(id: string): void {
|
|
73
|
+
const filePath = join(getSessionsDir(), `${id}.json`);
|
|
74
|
+
if (existsSync(filePath)) {
|
|
75
|
+
unlinkSync(filePath);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function loadAgentStates(): AgentState[] {
|
|
80
|
+
ensureDirs();
|
|
81
|
+
const agentsDir = getAgentsDir();
|
|
82
|
+
const files = readdirSync(agentsDir).filter((f) => f.endsWith(".json"));
|
|
83
|
+
const states: AgentState[] = [];
|
|
84
|
+
|
|
85
|
+
for (const file of files) {
|
|
86
|
+
try {
|
|
87
|
+
const raw = readFileSync(join(agentsDir, file), "utf-8");
|
|
88
|
+
const parsed = JSON.parse(raw) as AgentState;
|
|
89
|
+
states.push(parsed);
|
|
90
|
+
} catch {
|
|
91
|
+
// Skip malformed files
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return states;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function cleanStaleAgents(): void {
|
|
99
|
+
ensureDirs();
|
|
100
|
+
const agentsDir = getAgentsDir();
|
|
101
|
+
const files = readdirSync(agentsDir).filter((f) => f.endsWith(".json"));
|
|
102
|
+
const now = Date.now();
|
|
103
|
+
|
|
104
|
+
for (const file of files) {
|
|
105
|
+
try {
|
|
106
|
+
const filePath = join(agentsDir, file);
|
|
107
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
108
|
+
const parsed = JSON.parse(raw) as AgentState;
|
|
109
|
+
if (now - parsed.updatedAt > STALE_THRESHOLD_MS) {
|
|
110
|
+
unlinkSync(filePath);
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
// Skip
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function findSessionByPath(cwd: string): string {
|
|
119
|
+
const sessions = loadSessions();
|
|
120
|
+
for (const session of sessions) {
|
|
121
|
+
if (cwd.startsWith(session.worktreePath)) {
|
|
122
|
+
return session.id;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return "";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function readAgentState(filename: string): AgentState | null {
|
|
129
|
+
const filePath = join(getAgentsDir(), filename);
|
|
130
|
+
if (!existsSync(filePath)) return null;
|
|
131
|
+
try {
|
|
132
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
133
|
+
return JSON.parse(raw) as AgentState;
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function writeAgentState(state: AgentState, filename: string): void {
|
|
140
|
+
ensureDirs();
|
|
141
|
+
const filePath = join(getAgentsDir(), filename);
|
|
142
|
+
writeFileSync(filePath, JSON.stringify(state, null, 2));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function removeAgentFile(filename: string): void {
|
|
146
|
+
const filePath = join(getAgentsDir(), filename);
|
|
147
|
+
if (existsSync(filePath)) {
|
|
148
|
+
unlinkSync(filePath);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* macOS native window management via AppleScript/osascript.
|
|
3
|
+
* macOS native window management via AppleScript/System Events.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
interface WindowBounds {
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function runOsascript(script: string): Promise<string> {
|
|
14
|
+
const proc = Bun.spawn(["osascript", "-e", script], {
|
|
15
|
+
stdout: "pipe",
|
|
16
|
+
stderr: "pipe",
|
|
17
|
+
});
|
|
18
|
+
const out = await new Response(proc.stdout).text();
|
|
19
|
+
await proc.exited;
|
|
20
|
+
return out.trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get the bounds of the sidebar terminal window (the frontmost Ghostty window).
|
|
25
|
+
*/
|
|
26
|
+
export async function getSidebarBounds(): Promise<WindowBounds | null> {
|
|
27
|
+
try {
|
|
28
|
+
const result = await runOsascript(`
|
|
29
|
+
tell application "System Events"
|
|
30
|
+
tell process "Ghostty"
|
|
31
|
+
set w to front window
|
|
32
|
+
set p to position of w
|
|
33
|
+
set s to size of w
|
|
34
|
+
return "" & (item 1 of p) & "," & (item 2 of p) & "," & (item 1 of s) & "," & (item 2 of s)
|
|
35
|
+
end tell
|
|
36
|
+
end tell
|
|
37
|
+
`);
|
|
38
|
+
const parts = result.split(",").map((s) => Number.parseInt(s.trim(), 10));
|
|
39
|
+
if (parts.length < 4) return null;
|
|
40
|
+
return { x: parts[0]!, y: parts[1]!, width: parts[2]!, height: parts[3]! };
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get the screen bounds that the sidebar is on.
|
|
48
|
+
* Returns the full screen dimensions for the monitor containing the sidebar.
|
|
49
|
+
*/
|
|
50
|
+
export async function getScreenBounds(): Promise<WindowBounds | null> {
|
|
51
|
+
try {
|
|
52
|
+
const result = await runOsascript(`
|
|
53
|
+
tell application "Finder"
|
|
54
|
+
set db to bounds of window of desktop
|
|
55
|
+
return "" & (item 1 of db) & "," & (item 2 of db) & "," & (item 3 of db) & "," & (item 4 of db)
|
|
56
|
+
end tell
|
|
57
|
+
`);
|
|
58
|
+
const parts = result.split(",").map((s) => Number.parseInt(s.trim(), 10));
|
|
59
|
+
if (parts.length < 4) return null;
|
|
60
|
+
return {
|
|
61
|
+
x: parts[0]!,
|
|
62
|
+
y: parts[1]!,
|
|
63
|
+
width: parts[2]! - parts[0]!,
|
|
64
|
+
height: parts[3]! - parts[1]!,
|
|
65
|
+
};
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Move and resize a VS Code window to fill the area right of the sidebar.
|
|
73
|
+
* The VS Code window is brought to the current space and positioned
|
|
74
|
+
* adjacent to the sidebar terminal.
|
|
75
|
+
*/
|
|
76
|
+
export async function positionEditorWindow(
|
|
77
|
+
windowTitle: string,
|
|
78
|
+
sidebarBounds: WindowBounds,
|
|
79
|
+
): Promise<boolean> {
|
|
80
|
+
// Calculate editor position: right of sidebar, same height
|
|
81
|
+
const editorX = sidebarBounds.x + sidebarBounds.width + 4; // 4px gap
|
|
82
|
+
const editorY = sidebarBounds.y;
|
|
83
|
+
// Editor width: fill remaining screen width (estimate with a large number)
|
|
84
|
+
// We'll get the actual screen width for precision
|
|
85
|
+
const screen = await getScreenBounds();
|
|
86
|
+
const screenRight = screen ? screen.x + screen.width : 5120; // fallback
|
|
87
|
+
const editorWidth = screenRight - editorX;
|
|
88
|
+
const editorHeight = sidebarBounds.height;
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
// Escape single quotes in title for AppleScript
|
|
92
|
+
const escapedTitle = windowTitle.replace(/'/g, "'\"'\"'");
|
|
93
|
+
|
|
94
|
+
await runOsascript(`
|
|
95
|
+
tell application "System Events"
|
|
96
|
+
tell process "Code"
|
|
97
|
+
set targetWindow to missing value
|
|
98
|
+
repeat with w in every window
|
|
99
|
+
if name of w contains "${escapedTitle}" then
|
|
100
|
+
set targetWindow to w
|
|
101
|
+
exit repeat
|
|
102
|
+
end if
|
|
103
|
+
end repeat
|
|
104
|
+
if targetWindow is not missing value then
|
|
105
|
+
set position of targetWindow to {${editorX}, ${editorY}}
|
|
106
|
+
set size of targetWindow to {${editorWidth}, ${editorHeight}}
|
|
107
|
+
end if
|
|
108
|
+
end tell
|
|
109
|
+
end tell
|
|
110
|
+
`);
|
|
111
|
+
return true;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Check if a VS Code window with the given title exists.
|
|
119
|
+
*/
|
|
120
|
+
export async function editorWindowExists(windowTitle: string): Promise<boolean> {
|
|
121
|
+
const windows = await listEditorWindows();
|
|
122
|
+
return windows.some((w) => w.includes(windowTitle));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Bring a VS Code window to front and position it next to the sidebar.
|
|
127
|
+
* Returns false if the window doesn't exist.
|
|
128
|
+
*/
|
|
129
|
+
export async function focusAndPositionEditor(windowTitle: string): Promise<boolean> {
|
|
130
|
+
// First check if the window actually exists
|
|
131
|
+
if (!(await editorWindowExists(windowTitle))) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const sidebar = await getSidebarBounds();
|
|
136
|
+
if (!sidebar) return false;
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const escapedTitle = windowTitle.replace(/'/g, "'\"'\"'");
|
|
140
|
+
|
|
141
|
+
// Activate VS Code and raise the specific window
|
|
142
|
+
await runOsascript(`
|
|
143
|
+
tell application "System Events"
|
|
144
|
+
tell process "Code"
|
|
145
|
+
set frontmost to true
|
|
146
|
+
repeat with w in every window
|
|
147
|
+
if name of w contains "${escapedTitle}" then
|
|
148
|
+
perform action "AXRaise" of w
|
|
149
|
+
exit repeat
|
|
150
|
+
end if
|
|
151
|
+
end repeat
|
|
152
|
+
end tell
|
|
153
|
+
end tell
|
|
154
|
+
`);
|
|
155
|
+
|
|
156
|
+
// Position it next to sidebar
|
|
157
|
+
await positionEditorWindow(windowTitle, sidebar);
|
|
158
|
+
return true;
|
|
159
|
+
} catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Check if VS Code process is running.
|
|
166
|
+
*/
|
|
167
|
+
async function isEditorRunning(): Promise<boolean> {
|
|
168
|
+
try {
|
|
169
|
+
const proc = Bun.spawn(["pgrep", "-x", "Code"], { stdout: "ignore", stderr: "ignore" });
|
|
170
|
+
await proc.exited;
|
|
171
|
+
return proc.exitCode === 0;
|
|
172
|
+
} catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* List all VS Code window titles.
|
|
179
|
+
*/
|
|
180
|
+
export async function listEditorWindows(): Promise<string[]> {
|
|
181
|
+
if (!(await isEditorRunning())) return [];
|
|
182
|
+
try {
|
|
183
|
+
const result = await runOsascript(`
|
|
184
|
+
tell application "System Events"
|
|
185
|
+
tell process "Code"
|
|
186
|
+
set titles to {}
|
|
187
|
+
repeat with w in every window
|
|
188
|
+
set end of titles to name of w
|
|
189
|
+
end repeat
|
|
190
|
+
set AppleScript's text item delimiters to "|||"
|
|
191
|
+
return titles as text
|
|
192
|
+
end tell
|
|
193
|
+
end tell
|
|
194
|
+
`);
|
|
195
|
+
if (!result) return [];
|
|
196
|
+
return result.split("|||").filter((t) => t.length > 0);
|
|
197
|
+
} catch {
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Get the focused (front) VS Code window title, and whether VS Code is frontmost app.
|
|
204
|
+
*/
|
|
205
|
+
export async function getFocusedEditorWindow(): Promise<{
|
|
206
|
+
frontWindow: string;
|
|
207
|
+
isFrontmost: boolean;
|
|
208
|
+
}> {
|
|
209
|
+
if (!(await isEditorRunning())) return { frontWindow: "", isFrontmost: false };
|
|
210
|
+
try {
|
|
211
|
+
const result = await runOsascript(`
|
|
212
|
+
tell application "System Events"
|
|
213
|
+
tell process "Code"
|
|
214
|
+
set isFront to frontmost
|
|
215
|
+
set wName to name of front window
|
|
216
|
+
return (isFront as text) & "|||" & wName
|
|
217
|
+
end tell
|
|
218
|
+
end tell
|
|
219
|
+
`);
|
|
220
|
+
const [isFrontStr, windowName] = result.split("|||");
|
|
221
|
+
return {
|
|
222
|
+
frontWindow: windowName ?? "",
|
|
223
|
+
isFrontmost: isFrontStr === "true",
|
|
224
|
+
};
|
|
225
|
+
} catch {
|
|
226
|
+
return { frontWindow: "", isFrontmost: false };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Focus the sidebar terminal (bring Ghostty to front).
|
|
232
|
+
*/
|
|
233
|
+
export async function focusSidebar(): Promise<void> {
|
|
234
|
+
try {
|
|
235
|
+
await runOsascript(`
|
|
236
|
+
tell application "Ghostty" to activate
|
|
237
|
+
`);
|
|
238
|
+
} catch {
|
|
239
|
+
// Ghostty not available
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Close a specific VS Code window by title match.
|
|
245
|
+
* Raises the window then sends Cmd+W to close it.
|
|
246
|
+
*/
|
|
247
|
+
export async function closeEditorWindow(windowTitle: string): Promise<void> {
|
|
248
|
+
if (!(await isEditorRunning())) return;
|
|
249
|
+
try {
|
|
250
|
+
const escapedTitle = windowTitle.replace(/'/g, "'\"'\"'");
|
|
251
|
+
await runOsascript(`
|
|
252
|
+
tell application "System Events"
|
|
253
|
+
tell process "Code"
|
|
254
|
+
set frontmost to true
|
|
255
|
+
repeat with w in every window
|
|
256
|
+
if name of w contains "${escapedTitle}" then
|
|
257
|
+
perform action "AXRaise" of w
|
|
258
|
+
delay 0.2
|
|
259
|
+
keystroke "w" using {command down, shift down}
|
|
260
|
+
exit repeat
|
|
261
|
+
end if
|
|
262
|
+
end repeat
|
|
263
|
+
end tell
|
|
264
|
+
end tell
|
|
265
|
+
`);
|
|
266
|
+
} catch {
|
|
267
|
+
// Window not found or already closed
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Close all VS Code windows (without quitting the app).
|
|
273
|
+
*/
|
|
274
|
+
export async function closeAllEditors(): Promise<void> {
|
|
275
|
+
if (!(await isEditorRunning())) return;
|
|
276
|
+
try {
|
|
277
|
+
// Close each window one by one with Cmd+Shift+W (close window, not tab)
|
|
278
|
+
await runOsascript(`
|
|
279
|
+
tell application "System Events"
|
|
280
|
+
tell process "Code"
|
|
281
|
+
set frontmost to true
|
|
282
|
+
repeat while (count of windows) > 0
|
|
283
|
+
keystroke "w" using {command down, shift down}
|
|
284
|
+
delay 0.3
|
|
285
|
+
end repeat
|
|
286
|
+
end tell
|
|
287
|
+
end tell
|
|
288
|
+
`);
|
|
289
|
+
} catch {
|
|
290
|
+
// VS Code not running
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Reposition ALL VS Code windows to fill the area right of the sidebar.
|
|
296
|
+
* Called when the sidebar terminal is resized.
|
|
297
|
+
*/
|
|
298
|
+
export async function repositionAllEditors(): Promise<void> {
|
|
299
|
+
if (!(await isEditorRunning())) return;
|
|
300
|
+
const [sidebar, screen] = await Promise.all([getSidebarBounds(), getScreenBounds()]);
|
|
301
|
+
if (!sidebar) return;
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
const editorX = sidebar.x + sidebar.width + 4;
|
|
305
|
+
const editorY = sidebar.y;
|
|
306
|
+
const screenRight = screen ? screen.x + screen.width : 5120;
|
|
307
|
+
const editorWidth = screenRight - editorX;
|
|
308
|
+
const editorHeight = sidebar.height;
|
|
309
|
+
|
|
310
|
+
await runOsascript(`
|
|
311
|
+
tell application "System Events"
|
|
312
|
+
tell process "Code"
|
|
313
|
+
repeat with w in every window
|
|
314
|
+
set position of w to {${editorX}, ${editorY}}
|
|
315
|
+
set size of w to {${editorWidth}, ${editorHeight}}
|
|
316
|
+
end repeat
|
|
317
|
+
end tell
|
|
318
|
+
end tell
|
|
319
|
+
`);
|
|
320
|
+
} catch {
|
|
321
|
+
// VS Code not running or no windows
|
|
322
|
+
}
|
|
323
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import type { WorktreeEntry } from "../types.ts";
|
|
3
|
+
|
|
4
|
+
export async function createWorktree(repoPath: string, branchName: string): Promise<string> {
|
|
5
|
+
const proc = Bun.spawn(["git", "wt", branchName], {
|
|
6
|
+
cwd: repoPath,
|
|
7
|
+
stdout: "pipe",
|
|
8
|
+
stderr: "pipe",
|
|
9
|
+
});
|
|
10
|
+
const [stdout, stderr] = await Promise.all([
|
|
11
|
+
new Response(proc.stdout).text(),
|
|
12
|
+
new Response(proc.stderr).text(),
|
|
13
|
+
]);
|
|
14
|
+
const code = await proc.exited;
|
|
15
|
+
if (code !== 0) {
|
|
16
|
+
throw new Error(stderr.trim() || `git wt exited with code ${code}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Parse worktree path from output - look for absolute paths
|
|
20
|
+
const output = stdout.trim();
|
|
21
|
+
const lines = output.split("\n");
|
|
22
|
+
for (const line of [...lines].reverse()) {
|
|
23
|
+
const trimmed = line.trim();
|
|
24
|
+
if (trimmed.startsWith("/")) return trimmed;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Fallback: find worktree via git worktree list
|
|
28
|
+
const listProc = Bun.spawn(["git", "-C", repoPath, "worktree", "list", "--porcelain"], {
|
|
29
|
+
stdout: "pipe",
|
|
30
|
+
stderr: "pipe",
|
|
31
|
+
});
|
|
32
|
+
const listOutput = await new Response(listProc.stdout).text();
|
|
33
|
+
await listProc.exited;
|
|
34
|
+
|
|
35
|
+
let currentPath = "";
|
|
36
|
+
for (const line of listOutput.split("\n")) {
|
|
37
|
+
if (line.startsWith("worktree ")) currentPath = line.slice(9);
|
|
38
|
+
else if (line.startsWith("branch ")) {
|
|
39
|
+
const branch = line.slice(7).replace("refs/heads/", "");
|
|
40
|
+
if (branch === branchName) return currentPath;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
throw new Error(`Could not determine worktree path from git wt output:\n${output}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function removeWorktree(worktreePath: string): Promise<void> {
|
|
48
|
+
if (!existsSync(worktreePath)) return;
|
|
49
|
+
|
|
50
|
+
// Find the main repo for this worktree
|
|
51
|
+
const proc = Bun.spawn(["git", "-C", worktreePath, "worktree", "list", "--porcelain"], {
|
|
52
|
+
stdout: "pipe",
|
|
53
|
+
stderr: "pipe",
|
|
54
|
+
});
|
|
55
|
+
const output = await new Response(proc.stdout).text();
|
|
56
|
+
await proc.exited;
|
|
57
|
+
|
|
58
|
+
// First entry in worktree list is the main worktree
|
|
59
|
+
const lines = output.split("\n");
|
|
60
|
+
const mainWorktreeLine = lines.find((l) => l.startsWith("worktree "));
|
|
61
|
+
if (!mainWorktreeLine) {
|
|
62
|
+
throw new Error("Could not determine main worktree");
|
|
63
|
+
}
|
|
64
|
+
const mainPath = mainWorktreeLine.replace("worktree ", "");
|
|
65
|
+
|
|
66
|
+
// Remove the worktree
|
|
67
|
+
const removeProc = Bun.spawn(
|
|
68
|
+
["git", "-C", mainPath, "worktree", "remove", worktreePath, "--force"],
|
|
69
|
+
{
|
|
70
|
+
stdout: "pipe",
|
|
71
|
+
stderr: "pipe",
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
const stderr = await new Response(removeProc.stderr).text();
|
|
75
|
+
await removeProc.exited;
|
|
76
|
+
|
|
77
|
+
if (removeProc.exitCode !== 0) {
|
|
78
|
+
throw new Error(`Failed to remove worktree: ${stderr}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function listWorktrees(repoPath: string): Promise<WorktreeEntry[]> {
|
|
83
|
+
const proc = Bun.spawn(["git", "-C", repoPath, "worktree", "list", "--porcelain"], {
|
|
84
|
+
stdout: "pipe",
|
|
85
|
+
stderr: "pipe",
|
|
86
|
+
});
|
|
87
|
+
const output = await new Response(proc.stdout).text();
|
|
88
|
+
await proc.exited;
|
|
89
|
+
|
|
90
|
+
if (proc.exitCode !== 0) return [];
|
|
91
|
+
|
|
92
|
+
const entries: WorktreeEntry[] = [];
|
|
93
|
+
const blocks = output.split("\n\n").filter((b) => b.trim());
|
|
94
|
+
|
|
95
|
+
for (const block of blocks) {
|
|
96
|
+
const lines = block.split("\n");
|
|
97
|
+
let path = "";
|
|
98
|
+
let branch = "";
|
|
99
|
+
let isBare = false;
|
|
100
|
+
|
|
101
|
+
for (const line of lines) {
|
|
102
|
+
if (line.startsWith("worktree ")) {
|
|
103
|
+
path = line.replace("worktree ", "");
|
|
104
|
+
} else if (line.startsWith("branch ")) {
|
|
105
|
+
// refs/heads/main -> main
|
|
106
|
+
const ref = line.replace("branch ", "");
|
|
107
|
+
const parts = ref.split("/");
|
|
108
|
+
branch = parts.slice(2).join("/");
|
|
109
|
+
} else if (line === "bare") {
|
|
110
|
+
isBare = true;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (path && !isBare) {
|
|
115
|
+
entries.push({ path, branch });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return entries;
|
|
120
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { RepoInfo } from "../types.ts";
|
|
4
|
+
|
|
5
|
+
async function getDefaultBranch(repoPath: string): Promise<string> {
|
|
6
|
+
try {
|
|
7
|
+
const proc = Bun.spawn(["git", "-C", repoPath, "symbolic-ref", "refs/remotes/origin/HEAD"], {
|
|
8
|
+
stdout: "pipe",
|
|
9
|
+
stderr: "pipe",
|
|
10
|
+
});
|
|
11
|
+
const output = await new Response(proc.stdout).text();
|
|
12
|
+
await proc.exited;
|
|
13
|
+
if (proc.exitCode === 0) {
|
|
14
|
+
// refs/remotes/origin/main -> main
|
|
15
|
+
const parts = output.trim().split("/");
|
|
16
|
+
return parts[parts.length - 1] ?? "main";
|
|
17
|
+
}
|
|
18
|
+
} catch {
|
|
19
|
+
// Fall through
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Fallback: check if main or master exists
|
|
23
|
+
try {
|
|
24
|
+
const proc = Bun.spawn(["git", "-C", repoPath, "branch", "--list", "main", "master"], {
|
|
25
|
+
stdout: "pipe",
|
|
26
|
+
stderr: "pipe",
|
|
27
|
+
});
|
|
28
|
+
const output = await new Response(proc.stdout).text();
|
|
29
|
+
await proc.exited;
|
|
30
|
+
const branches = output
|
|
31
|
+
.trim()
|
|
32
|
+
.split("\n")
|
|
33
|
+
.map((b) => b.trim().replace("* ", ""));
|
|
34
|
+
if (branches.includes("main")) return "main";
|
|
35
|
+
if (branches.includes("master")) return "master";
|
|
36
|
+
} catch {
|
|
37
|
+
// Fall through
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return "main";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function scanRepos(workspaceDirs: string[]): Promise<RepoInfo[]> {
|
|
44
|
+
// Collect all repo paths first
|
|
45
|
+
const repoPaths: { name: string; path: string }[] = [];
|
|
46
|
+
|
|
47
|
+
for (const dir of workspaceDirs) {
|
|
48
|
+
if (!existsSync(dir)) continue;
|
|
49
|
+
|
|
50
|
+
let entries: string[];
|
|
51
|
+
try {
|
|
52
|
+
entries = readdirSync(dir);
|
|
53
|
+
} catch {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
const fullPath = join(dir, entry);
|
|
59
|
+
try {
|
|
60
|
+
const stat = statSync(fullPath);
|
|
61
|
+
if (!stat.isDirectory()) continue;
|
|
62
|
+
|
|
63
|
+
const gitDir = join(fullPath, ".git");
|
|
64
|
+
if (!existsSync(gitDir)) continue;
|
|
65
|
+
|
|
66
|
+
repoPaths.push({ name: entry, path: fullPath });
|
|
67
|
+
} catch {
|
|
68
|
+
// Skip entries that can't be processed
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Resolve default branches in parallel
|
|
74
|
+
const repos = await Promise.all(
|
|
75
|
+
repoPaths.map(async ({ name, path }) => {
|
|
76
|
+
const defaultBranch = await getDefaultBranch(path);
|
|
77
|
+
return { name, path, defaultBranch };
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
return repos.sort((a, b) => a.name.localeCompare(b.name));
|
|
82
|
+
}
|