Skip to content

Commit c17af1d

Browse files
committed
Merge branch 'main' into task-run-scope
2 parents eb610fe + 197725a commit c17af1d

37 files changed

+748
-470
lines changed

apps/array/forge.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ const config: ForgeConfig = {
198198
copyNativeDependency("node-addon-api", buildPath);
199199
copyNativeDependency("@parcel/watcher", buildPath);
200200
copyNativeDependency("@parcel/watcher-darwin-arm64", buildPath);
201+
copyNativeDependency("file-icon", buildPath);
201202
},
202203
},
203204
publishers: [

apps/array/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@
121121
"date-fns": "^3.3.1",
122122
"electron-log": "^5.4.3",
123123
"electron-store": "^11.0.0",
124+
"file-icon": "^6.0.0",
124125
"idb-keyval": "^6.2.2",
125126
"node-addon-api": "^8.5.0",
126127
"node-pty": "1.1.0-beta39",

apps/array/src/main/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ import { registerFoldersIpc } from "./services/folders.js";
1919
import { registerFsIpc } from "./services/fs.js";
2020
import { registerGitIpc } from "./services/git.js";
2121
import "./services/index.js";
22+
import {
23+
getOrRefreshApps,
24+
registerExternalAppsIpc,
25+
} from "./services/externalApps.js";
2226
import { registerOAuthHandlers } from "./services/oauth.js";
2327
import { registerOsIpc } from "./services/os.js";
2428
import { registerPosthogIpc } from "./services/posthog.js";
@@ -87,6 +91,7 @@ function createWindow(): void {
8791
contextIsolation: true,
8892
preload: path.join(__dirname, "preload.js"),
8993
enableBlinkFeatures: "GetDisplayMedia",
94+
partition: "persist:main",
9095
},
9196
});
9297

@@ -186,6 +191,11 @@ app.whenReady().then(() => {
186191
initializePostHog();
187192
trackAppEvent(ANALYTICS_EVENTS.APP_STARTED);
188193

194+
// Preload external app icons in background
195+
getOrRefreshApps().catch(() => {
196+
// Silently fail, will retry on first use
197+
});
198+
189199
// Dev mode: Watch agent package and restart via mprocs
190200
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
191201
setupAgentHotReload();
@@ -222,3 +232,4 @@ registerFileWatcherIpc(() => mainWindow);
222232
registerFoldersIpc();
223233
registerWorktreeIpc();
224234
registerShellIpc();
235+
registerExternalAppsIpc();

apps/array/src/main/preload.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
439439
ipcRenderer.invoke("remove-task-association", taskId),
440440
clearTaskWorktree: (taskId: string): Promise<void> =>
441441
ipcRenderer.invoke("clear-task-worktree", taskId),
442+
cleanupOrphanedWorktrees: (
443+
mainRepoPath: string,
444+
): Promise<{
445+
deleted: string[];
446+
errors: Array<{ path: string; error: string }>;
447+
}> => ipcRenderer.invoke("cleanup-orphaned-worktrees", mainRepoPath),
442448
},
443449
// Worktree API
444450
worktree: {
@@ -485,4 +491,28 @@ contextBridge.exposeInMainWorld("electronAPI", {
485491
): Promise<string | null> =>
486492
ipcRenderer.invoke("worktree-get-main-repo", mainRepoPath, worktreePath),
487493
},
494+
externalApps: {
495+
getDetectedApps: (): Promise<
496+
Array<{
497+
id: string;
498+
name: string;
499+
type: "editor" | "terminal";
500+
path: string;
501+
command: string;
502+
icon?: string;
503+
}>
504+
> => ipcRenderer.invoke("external-apps:get-detected-apps"),
505+
openInApp: (
506+
appId: string,
507+
path: string,
508+
): Promise<{ success: boolean; error?: string }> =>
509+
ipcRenderer.invoke("external-apps:open-in-app", appId, path),
510+
setLastUsed: (appId: string): Promise<void> =>
511+
ipcRenderer.invoke("external-apps:set-last-used", appId),
512+
getLastUsed: (): Promise<{
513+
lastUsedApp?: string;
514+
}> => ipcRenderer.invoke("external-apps:get-last-used"),
515+
copyPath: (path: string): Promise<void> =>
516+
ipcRenderer.invoke("external-apps:copy-path", path),
517+
},
488518
});
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import { exec } from "node:child_process";
2+
import fs from "node:fs/promises";
3+
import { promisify } from "node:util";
4+
import { app, ipcMain } from "electron";
5+
import Store from "electron-store";
6+
import type {
7+
DetectedApplication,
8+
ExternalAppsPreferences,
9+
ExternalAppType,
10+
} from "../../shared/types";
11+
12+
const execAsync = promisify(exec);
13+
14+
// Dynamic import for file-icon ESM module
15+
let fileIcon: typeof import("file-icon") | null = null;
16+
async function getFileIcon() {
17+
if (!fileIcon) {
18+
fileIcon = await import("file-icon");
19+
}
20+
return fileIcon;
21+
}
22+
23+
// Cache detected apps in memory (cache the promise to prevent concurrent detections)
24+
let cachedApps: DetectedApplication[] | null = null;
25+
let detectionPromise: Promise<DetectedApplication[]> | null = null;
26+
27+
const APP_PATHS: Record<string, string> = {
28+
vscode: "/Applications/Visual Studio Code.app",
29+
cursor: "/Applications/Cursor.app",
30+
sublime: "/Applications/Sublime Text.app",
31+
webstorm: "/Applications/WebStorm.app",
32+
intellij: "/Applications/IntelliJ IDEA.app",
33+
zed: "/Applications/Zed.app",
34+
pycharm: "/Applications/PyCharm.app",
35+
iterm: "/Applications/iTerm.app",
36+
warp: "/Applications/Warp.app",
37+
terminal: "/System/Applications/Utilities/Terminal.app",
38+
alacritty: "/Applications/Alacritty.app",
39+
kitty: "/Applications/kitty.app",
40+
ghostty: "/Applications/Ghostty.app",
41+
finder: "/System/Library/CoreServices/Finder.app",
42+
};
43+
44+
const DISPLAY_NAMES: Record<string, string> = {
45+
vscode: "VS Code",
46+
cursor: "Cursor",
47+
sublime: "Sublime Text",
48+
webstorm: "WebStorm",
49+
intellij: "IntelliJ IDEA",
50+
zed: "Zed",
51+
pycharm: "PyCharm",
52+
iterm: "iTerm",
53+
warp: "Warp",
54+
terminal: "Terminal",
55+
alacritty: "Alacritty",
56+
kitty: "Kitty",
57+
ghostty: "Ghostty",
58+
finder: "Finder",
59+
};
60+
61+
function getStorePath(): string {
62+
const userDataPath = app.getPath("userData");
63+
if (userDataPath.includes("@posthog")) {
64+
const path = require("node:path");
65+
return path.join(path.dirname(userDataPath), "Array");
66+
}
67+
return userDataPath;
68+
}
69+
70+
interface ExternalAppsSchema {
71+
externalAppsPrefs: ExternalAppsPreferences;
72+
}
73+
74+
export const externalAppsStore = new Store<ExternalAppsSchema>({
75+
name: "external-apps",
76+
cwd: getStorePath(),
77+
defaults: {
78+
externalAppsPrefs: {},
79+
},
80+
});
81+
82+
async function extractIcon(appPath: string): Promise<string | undefined> {
83+
try {
84+
const fileIconModule = await getFileIcon();
85+
const uint8Array = await fileIconModule.fileIconToBuffer(appPath, {
86+
size: 64,
87+
});
88+
const buffer = Buffer.from(uint8Array);
89+
const base64 = buffer.toString("base64");
90+
return `data:image/png;base64,${base64}`;
91+
} catch (_error) {
92+
return undefined;
93+
}
94+
}
95+
96+
function generateCommand(appPath: string): string {
97+
return `open -a "${appPath}"`;
98+
}
99+
100+
function getDisplayName(id: string): string {
101+
return DISPLAY_NAMES[id] || id;
102+
}
103+
104+
async function checkApplication(
105+
id: string,
106+
appPath: string,
107+
type: ExternalAppType,
108+
): Promise<DetectedApplication | null> {
109+
try {
110+
await fs.access(appPath);
111+
112+
const icon = await extractIcon(appPath);
113+
const command = generateCommand(appPath);
114+
const name = getDisplayName(id);
115+
116+
return {
117+
id,
118+
name,
119+
type,
120+
path: appPath,
121+
command,
122+
icon,
123+
};
124+
} catch {
125+
return null;
126+
}
127+
}
128+
129+
async function detectExternalApps(): Promise<DetectedApplication[]> {
130+
const apps: DetectedApplication[] = [];
131+
132+
for (const [id, appPath] of Object.entries(APP_PATHS)) {
133+
const detected = await checkApplication(id, appPath, "editor");
134+
if (detected) {
135+
apps.push(detected);
136+
}
137+
}
138+
139+
return apps;
140+
}
141+
142+
export async function getOrRefreshApps(): Promise<DetectedApplication[]> {
143+
if (cachedApps) {
144+
return cachedApps;
145+
}
146+
147+
if (detectionPromise) {
148+
return detectionPromise;
149+
}
150+
151+
detectionPromise = detectExternalApps().then((apps) => {
152+
cachedApps = apps;
153+
detectionPromise = null;
154+
return apps;
155+
});
156+
157+
return detectionPromise;
158+
}
159+
160+
export function registerExternalAppsIpc(): void {
161+
ipcMain.handle(
162+
"external-apps:get-detected-apps",
163+
async (): Promise<DetectedApplication[]> => {
164+
return await getOrRefreshApps();
165+
},
166+
);
167+
168+
ipcMain.handle(
169+
"external-apps:open-in-app",
170+
async (
171+
_event,
172+
appId: string,
173+
targetPath: string,
174+
): Promise<{ success: boolean; error?: string }> => {
175+
try {
176+
const apps = await getOrRefreshApps();
177+
const appToOpen = apps.find((a) => a.id === appId);
178+
179+
if (!appToOpen) {
180+
return { success: false, error: "Application not found" };
181+
}
182+
183+
let command: string;
184+
if (appToOpen.command.includes("open -a")) {
185+
command = `${appToOpen.command} "${targetPath}"`;
186+
} else {
187+
command = `${appToOpen.command} "${targetPath}"`;
188+
}
189+
190+
await execAsync(command);
191+
return { success: true };
192+
} catch (error) {
193+
return {
194+
success: false,
195+
error: error instanceof Error ? error.message : "Unknown error",
196+
};
197+
}
198+
},
199+
);
200+
201+
ipcMain.handle(
202+
"external-apps:set-last-used",
203+
async (_event, appId: string): Promise<void> => {
204+
const prefs = externalAppsStore.get("externalAppsPrefs");
205+
externalAppsStore.set("externalAppsPrefs", {
206+
...prefs,
207+
lastUsedApp: appId,
208+
});
209+
},
210+
);
211+
212+
ipcMain.handle(
213+
"external-apps:get-last-used",
214+
async (): Promise<{
215+
lastUsedApp?: string;
216+
}> => {
217+
const prefs = externalAppsStore.get("externalAppsPrefs");
218+
return {
219+
lastUsedApp: prefs.lastUsedApp,
220+
};
221+
},
222+
);
223+
224+
ipcMain.handle(
225+
"external-apps:copy-path",
226+
async (_event, targetPath: string): Promise<void> => {
227+
const { clipboard } = await import("electron");
228+
clipboard.writeText(targetPath);
229+
},
230+
);
231+
}

apps/array/src/main/services/folders.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import path from "node:path";
2+
import { WorktreeManager } from "@posthog/agent";
23
import { type IpcMainInvokeEvent, ipcMain } from "electron";
34
import type {
45
RegisteredFolder,
@@ -144,6 +145,26 @@ async function clearTaskWorktree(taskId: string): Promise<void> {
144145
}
145146
}
146147

148+
async function cleanupOrphanedWorktreesForFolder(
149+
mainRepoPath: string,
150+
): Promise<{
151+
deleted: string[];
152+
errors: Array<{ path: string; error: string }>;
153+
}> {
154+
const manager = new WorktreeManager({ mainRepoPath });
155+
156+
const associations = foldersStore.get("taskAssociations", []);
157+
const associatedWorktreePaths: string[] = [];
158+
159+
for (const assoc of associations) {
160+
if (assoc.worktree?.worktreePath) {
161+
associatedWorktreePaths.push(assoc.worktree.worktreePath);
162+
}
163+
}
164+
165+
return await manager.cleanupOrphanedWorktrees(associatedWorktreePaths);
166+
}
167+
147168
export function registerFoldersIpc(): void {
148169
ipcMain.handle(
149170
"get-folders",
@@ -292,4 +313,28 @@ export function registerFoldersIpc(): void {
292313
}
293314
},
294315
);
316+
317+
ipcMain.handle(
318+
"cleanup-orphaned-worktrees",
319+
async (
320+
_event: IpcMainInvokeEvent,
321+
mainRepoPath: string,
322+
): Promise<{
323+
deleted: string[];
324+
errors: Array<{ path: string; error: string }>;
325+
}> => {
326+
try {
327+
return await cleanupOrphanedWorktreesForFolder(mainRepoPath);
328+
} catch (error) {
329+
log.error(
330+
`Failed to cleanup orphaned worktrees for ${mainRepoPath}:`,
331+
error,
332+
);
333+
return {
334+
deleted: [],
335+
errors: [{ path: mainRepoPath, error: String(error) }],
336+
};
337+
}
338+
},
339+
);
295340
}

apps/array/src/main/services/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import "./agent.js";
77
import "./contextMenu.js";
88
import "./dev-reload.js";
9+
import "./externalApps.js";
910
import "./fileWatcher.js";
1011
import "./folders.js";
1112
import "./fs.js";

0 commit comments

Comments
 (0)