Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/array/forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ const config: ForgeConfig = {
copyNativeDependency("node-addon-api", buildPath);
copyNativeDependency("@parcel/watcher", buildPath);
copyNativeDependency("@parcel/watcher-darwin-arm64", buildPath);
copyNativeDependency("file-icon", buildPath);
},
},
publishers: [
Expand Down
1 change: 1 addition & 0 deletions apps/array/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@
"date-fns": "^3.3.1",
"electron-log": "^5.4.3",
"electron-store": "^11.0.0",
"file-icon": "^6.0.0",
"idb-keyval": "^6.2.2",
"node-addon-api": "^8.5.0",
"node-pty": "1.1.0-beta39",
Expand Down
10 changes: 10 additions & 0 deletions apps/array/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import { registerFoldersIpc } from "./services/folders.js";
import { registerFsIpc } from "./services/fs.js";
import { registerGitIpc } from "./services/git.js";
import "./services/index.js";
import {
getOrRefreshApps,
registerExternalAppsIpc,
} from "./services/externalApps.js";
import { registerOAuthHandlers } from "./services/oauth.js";
import { registerOsIpc } from "./services/os.js";
import { registerPosthogIpc } from "./services/posthog.js";
Expand Down Expand Up @@ -187,6 +191,11 @@ app.whenReady().then(() => {
initializePostHog();
trackAppEvent(ANALYTICS_EVENTS.APP_STARTED);

// Preload external app icons in background
getOrRefreshApps().catch(() => {
// Silently fail, will retry on first use
});

// Dev mode: Watch agent package and restart via mprocs
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
setupAgentHotReload();
Expand Down Expand Up @@ -223,3 +232,4 @@ registerFileWatcherIpc(() => mainWindow);
registerFoldersIpc();
registerWorktreeIpc();
registerShellIpc();
registerExternalAppsIpc();
24 changes: 24 additions & 0 deletions apps/array/src/main/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,4 +489,28 @@ contextBridge.exposeInMainWorld("electronAPI", {
): Promise<string | null> =>
ipcRenderer.invoke("worktree-get-main-repo", mainRepoPath, worktreePath),
},
externalApps: {
getDetectedApps: (): Promise<
Array<{
id: string;
name: string;
type: "editor" | "terminal";
path: string;
command: string;
icon?: string;
}>
> => ipcRenderer.invoke("external-apps:get-detected-apps"),
openInApp: (
appId: string,
path: string,
): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke("external-apps:open-in-app", appId, path),
setLastUsed: (appId: string): Promise<void> =>
ipcRenderer.invoke("external-apps:set-last-used", appId),
getLastUsed: (): Promise<{
lastUsedApp?: string;
}> => ipcRenderer.invoke("external-apps:get-last-used"),
copyPath: (path: string): Promise<void> =>
ipcRenderer.invoke("external-apps:copy-path", path),
},
});
231 changes: 231 additions & 0 deletions apps/array/src/main/services/externalApps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import { exec } from "node:child_process";
import fs from "node:fs/promises";
import { promisify } from "node:util";
import { app, ipcMain } from "electron";
import Store from "electron-store";
import type {
DetectedApplication,
ExternalAppsPreferences,
ExternalAppType,
} from "../../shared/types";

const execAsync = promisify(exec);

// Dynamic import for file-icon ESM module
let fileIcon: typeof import("file-icon") | null = null;
async function getFileIcon() {
if (!fileIcon) {
fileIcon = await import("file-icon");
}
return fileIcon;
}

// Cache detected apps in memory (cache the promise to prevent concurrent detections)
let cachedApps: DetectedApplication[] | null = null;
let detectionPromise: Promise<DetectedApplication[]> | null = null;

const APP_PATHS: Record<string, string> = {
vscode: "/Applications/Visual Studio Code.app",
cursor: "/Applications/Cursor.app",
sublime: "/Applications/Sublime Text.app",
webstorm: "/Applications/WebStorm.app",
intellij: "/Applications/IntelliJ IDEA.app",
zed: "/Applications/Zed.app",
pycharm: "/Applications/PyCharm.app",
iterm: "/Applications/iTerm.app",
warp: "/Applications/Warp.app",
terminal: "/System/Applications/Utilities/Terminal.app",
alacritty: "/Applications/Alacritty.app",
kitty: "/Applications/kitty.app",
ghostty: "/Applications/Ghostty.app",
finder: "/System/Library/CoreServices/Finder.app",
};

const DISPLAY_NAMES: Record<string, string> = {
vscode: "VS Code",
cursor: "Cursor",
sublime: "Sublime Text",
webstorm: "WebStorm",
intellij: "IntelliJ IDEA",
zed: "Zed",
pycharm: "PyCharm",
iterm: "iTerm",
warp: "Warp",
terminal: "Terminal",
alacritty: "Alacritty",
kitty: "Kitty",
ghostty: "Ghostty",
finder: "Finder",
};

function getStorePath(): string {
const userDataPath = app.getPath("userData");
if (userDataPath.includes("@posthog")) {
const path = require("node:path");
return path.join(path.dirname(userDataPath), "Array");
}
return userDataPath;
}

interface ExternalAppsSchema {
externalAppsPrefs: ExternalAppsPreferences;
}

export const externalAppsStore = new Store<ExternalAppsSchema>({
name: "external-apps",
cwd: getStorePath(),
defaults: {
externalAppsPrefs: {},
},
});

async function extractIcon(appPath: string): Promise<string | undefined> {
try {
const fileIconModule = await getFileIcon();
const uint8Array = await fileIconModule.fileIconToBuffer(appPath, {
size: 64,
});
const buffer = Buffer.from(uint8Array);
const base64 = buffer.toString("base64");
return `data:image/png;base64,${base64}`;
} catch (_error) {
return undefined;
}
}

function generateCommand(appPath: string): string {
return `open -a "${appPath}"`;
}

function getDisplayName(id: string): string {
return DISPLAY_NAMES[id] || id;
}

async function checkApplication(
id: string,
appPath: string,
type: ExternalAppType,
): Promise<DetectedApplication | null> {
try {
await fs.access(appPath);

const icon = await extractIcon(appPath);
const command = generateCommand(appPath);
const name = getDisplayName(id);

return {
id,
name,
type,
path: appPath,
command,
icon,
};
} catch {
return null;
}
}

async function detectExternalApps(): Promise<DetectedApplication[]> {
const apps: DetectedApplication[] = [];

for (const [id, appPath] of Object.entries(APP_PATHS)) {
const detected = await checkApplication(id, appPath, "editor");
if (detected) {
apps.push(detected);
}
}

return apps;
}

export async function getOrRefreshApps(): Promise<DetectedApplication[]> {
if (cachedApps) {
return cachedApps;
}

if (detectionPromise) {
return detectionPromise;
}

detectionPromise = detectExternalApps().then((apps) => {
cachedApps = apps;
detectionPromise = null;
return apps;
});

return detectionPromise;
}

export function registerExternalAppsIpc(): void {
ipcMain.handle(
"external-apps:get-detected-apps",
async (): Promise<DetectedApplication[]> => {
return await getOrRefreshApps();
},
);

ipcMain.handle(
"external-apps:open-in-app",
async (
_event,
appId: string,
targetPath: string,
): Promise<{ success: boolean; error?: string }> => {
try {
const apps = await getOrRefreshApps();
const appToOpen = apps.find((a) => a.id === appId);

if (!appToOpen) {
return { success: false, error: "Application not found" };
}

let command: string;
if (appToOpen.command.includes("open -a")) {
command = `${appToOpen.command} "${targetPath}"`;
} else {
command = `${appToOpen.command} "${targetPath}"`;
}

await execAsync(command);
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
},
);

ipcMain.handle(
"external-apps:set-last-used",
async (_event, appId: string): Promise<void> => {
const prefs = externalAppsStore.get("externalAppsPrefs");
externalAppsStore.set("externalAppsPrefs", {
...prefs,
lastUsedApp: appId,
});
},
);

ipcMain.handle(
"external-apps:get-last-used",
async (): Promise<{
lastUsedApp?: string;
}> => {
const prefs = externalAppsStore.get("externalAppsPrefs");
return {
lastUsedApp: prefs.lastUsedApp,
};
},
);

ipcMain.handle(
"external-apps:copy-path",
async (_event, targetPath: string): Promise<void> => {
const { clipboard } = await import("electron");
clipboard.writeText(targetPath);
},
);
}
1 change: 1 addition & 0 deletions apps/array/src/main/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import "./agent.js";
import "./contextMenu.js";
import "./dev-reload.js";
import "./externalApps.js";
import "./fileWatcher.js";
import "./folders.js";
import "./fs.js";
Expand Down
2 changes: 1 addition & 1 deletion apps/array/src/renderer/components/ThemeWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function ThemeWrapper({ children }: { children: React.ReactNode }) {
appearance={isDarkMode ? "dark" : "light"}
accentColor="orange"
grayColor="slate"
panelBackground="translucent"
panelBackground="solid"
radius="none"
scaling="100%"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ function SidebarMenuComponent() {
key={folder.id}
id={folder.id}
label={folder.name}
icon={<FolderIcon size={14} weight="fill" />}
icon={<FolderIcon size={14} weight="regular" />}
isExpanded={!collapsedSections.has(folder.id)}
onToggle={() => toggleSection(folder.id)}
addSpacingBefore={index === 0}
Expand Down
Loading