Skip to content

Commit 197725a

Browse files
authored
external apps opener (#169)
1 parent 306e1bf commit 197725a

File tree

14 files changed

+535
-10
lines changed

14 files changed

+535
-10
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: 10 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";
@@ -187,6 +191,11 @@ app.whenReady().then(() => {
187191
initializePostHog();
188192
trackAppEvent(ANALYTICS_EVENTS.APP_STARTED);
189193

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

apps/array/src/main/preload.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,4 +489,28 @@ contextBridge.exposeInMainWorld("electronAPI", {
489489
): Promise<string | null> =>
490490
ipcRenderer.invoke("worktree-get-main-repo", mainRepoPath, worktreePath),
491491
},
492+
externalApps: {
493+
getDetectedApps: (): Promise<
494+
Array<{
495+
id: string;
496+
name: string;
497+
type: "editor" | "terminal";
498+
path: string;
499+
command: string;
500+
icon?: string;
501+
}>
502+
> => ipcRenderer.invoke("external-apps:get-detected-apps"),
503+
openInApp: (
504+
appId: string,
505+
path: string,
506+
): Promise<{ success: boolean; error?: string }> =>
507+
ipcRenderer.invoke("external-apps:open-in-app", appId, path),
508+
setLastUsed: (appId: string): Promise<void> =>
509+
ipcRenderer.invoke("external-apps:set-last-used", appId),
510+
getLastUsed: (): Promise<{
511+
lastUsedApp?: string;
512+
}> => ipcRenderer.invoke("external-apps:get-last-used"),
513+
copyPath: (path: string): Promise<void> =>
514+
ipcRenderer.invoke("external-apps:copy-path", path),
515+
},
492516
});
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/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";

apps/array/src/renderer/components/ThemeWrapper.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export function ThemeWrapper({ children }: { children: React.ReactNode }) {
1010
appearance={isDarkMode ? "dark" : "light"}
1111
accentColor="orange"
1212
grayColor="slate"
13-
panelBackground="translucent"
13+
panelBackground="solid"
1414
radius="none"
1515
scaling="100%"
1616
>

apps/array/src/renderer/features/sidebar/components/SidebarMenu.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ function SidebarMenuComponent() {
151151
key={folder.id}
152152
id={folder.id}
153153
label={folder.name}
154-
icon={<FolderIcon size={14} weight="fill" />}
154+
icon={<FolderIcon size={14} weight="regular" />}
155155
isExpanded={!collapsedSections.has(folder.id)}
156156
onToggle={() => toggleSection(folder.id)}
157157
addSpacingBefore={index === 0}

0 commit comments

Comments
 (0)