Skip to content

Commit 5e1ac70

Browse files
authored
feat: context menus for opening external editor (#170)
1 parent 197725a commit 5e1ac70

37 files changed

+759
-217
lines changed

apps/array/src/main/preload.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { contextBridge, type IpcRendererEvent, ipcRenderer } from "electron";
22
import type { CloudRegion, OAuthTokenResponse } from "../shared/types/oauth";
33
import type {
4+
ExternalAppContextMenuResult,
45
FolderContextMenuResult,
56
SplitContextMenuResult,
67
TabContextMenuResult,
@@ -319,17 +320,36 @@ contextBridge.exposeInMainWorld("electronAPI", {
319320
showTaskContextMenu: (
320321
taskId: string,
321322
taskTitle: string,
323+
worktreePath?: string,
322324
): Promise<TaskContextMenuResult> =>
323-
ipcRenderer.invoke("show-task-context-menu", taskId, taskTitle),
325+
ipcRenderer.invoke(
326+
"show-task-context-menu",
327+
taskId,
328+
taskTitle,
329+
worktreePath,
330+
),
324331
showFolderContextMenu: (
325332
folderId: string,
326333
folderName: string,
334+
folderPath?: string,
327335
): Promise<FolderContextMenuResult> =>
328-
ipcRenderer.invoke("show-folder-context-menu", folderId, folderName),
329-
showTabContextMenu: (canClose: boolean): Promise<TabContextMenuResult> =>
330-
ipcRenderer.invoke("show-tab-context-menu", canClose),
336+
ipcRenderer.invoke(
337+
"show-folder-context-menu",
338+
folderId,
339+
folderName,
340+
folderPath,
341+
),
342+
showTabContextMenu: (
343+
canClose: boolean,
344+
filePath?: string,
345+
): Promise<TabContextMenuResult> =>
346+
ipcRenderer.invoke("show-tab-context-menu", canClose, filePath),
331347
showSplitContextMenu: (): Promise<SplitContextMenuResult> =>
332348
ipcRenderer.invoke("show-split-context-menu"),
349+
showFileContextMenu: (
350+
filePath: string,
351+
): Promise<ExternalAppContextMenuResult> =>
352+
ipcRenderer.invoke("show-file-context-menu", filePath),
333353
folders: {
334354
getFolders: (): Promise<
335355
Array<{

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

Lines changed: 146 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
1-
import { Menu, type MenuItemConstructorOptions } from "electron";
1+
import { Menu, type MenuItemConstructorOptions, nativeImage } from "electron";
22
import { createIpcService } from "../ipc/createIpcService.js";
33
import type {
4+
ExternalAppContextMenuResult,
45
FolderContextMenuResult,
56
SplitContextMenuResult,
67
TabContextMenuResult,
78
TaskContextMenuResult,
89
} from "./contextMenu.types.js";
10+
import { externalAppsStore, getOrRefreshApps } from "./externalApps.js";
911

1012
export type {
13+
ExternalAppContextMenuResult,
1114
FolderContextMenuAction,
1215
FolderContextMenuResult,
1316
SplitContextMenuResult,
@@ -18,12 +21,84 @@ export type {
1821
TaskContextMenuResult,
1922
} from "./contextMenu.types.js";
2023

24+
const ICON_SIZE = 16;
25+
26+
function showContextMenu<T>(
27+
template: MenuItemConstructorOptions[],
28+
defaultResult: T,
29+
): Promise<T> {
30+
return new Promise((resolve) => {
31+
const menu = Menu.buildFromTemplate(template);
32+
menu.popup({
33+
callback: () => resolve(defaultResult),
34+
});
35+
});
36+
}
37+
38+
async function buildExternalAppsMenuItems(
39+
_targetPath: string,
40+
resolve: (result: ExternalAppContextMenuResult) => void,
41+
): Promise<MenuItemConstructorOptions[]> {
42+
const apps = await getOrRefreshApps();
43+
const prefs = externalAppsStore.get("externalAppsPrefs");
44+
const lastUsedAppId = prefs.lastUsedApp;
45+
46+
// Handle no apps detected
47+
if (apps.length === 0) {
48+
return [
49+
{
50+
label: "No external apps detected",
51+
enabled: false,
52+
},
53+
];
54+
}
55+
56+
// Find last used app or default to first
57+
const lastUsedApp = apps.find((app) => app.id === lastUsedAppId) || apps[0];
58+
59+
const menuItems: MenuItemConstructorOptions[] = [
60+
{
61+
label: `Open in ${lastUsedApp.name}`,
62+
click: () =>
63+
resolve({
64+
action: { type: "open-in-app", appId: lastUsedApp.id },
65+
}),
66+
},
67+
{
68+
label: "Open in",
69+
submenu: apps.map((app) => ({
70+
label: app.name,
71+
icon: app.icon
72+
? nativeImage
73+
.createFromDataURL(app.icon)
74+
.resize({ width: ICON_SIZE, height: ICON_SIZE })
75+
: undefined,
76+
click: () =>
77+
resolve({
78+
action: { type: "open-in-app", appId: app.id },
79+
}),
80+
})),
81+
},
82+
{
83+
label: "Copy Path",
84+
accelerator: "CmdOrCtrl+Shift+C",
85+
click: () =>
86+
resolve({
87+
action: { type: "copy-path" },
88+
}),
89+
},
90+
];
91+
92+
return menuItems;
93+
}
94+
2195
export const showTaskContextMenuService = createIpcService({
2296
channel: "show-task-context-menu",
2397
handler: async (
2498
_event,
2599
_taskId: string,
26100
_taskTitle: string,
101+
worktreePath?: string,
27102
): Promise<TaskContextMenuResult> => {
28103
return new Promise((resolve) => {
29104
const template: MenuItemConstructorOptions[] = [
@@ -42,10 +117,20 @@ export const showTaskContextMenuService = createIpcService({
42117
},
43118
];
44119

45-
const menu = Menu.buildFromTemplate(template);
46-
menu.popup({
47-
callback: () => resolve({ action: null }),
48-
});
120+
const setupMenu = async () => {
121+
if (worktreePath) {
122+
template.push({ type: "separator" });
123+
const externalAppsItems = await buildExternalAppsMenuItems(
124+
worktreePath,
125+
resolve,
126+
);
127+
template.push(...externalAppsItems);
128+
}
129+
130+
showContextMenu(template, { action: null }).then(resolve);
131+
};
132+
133+
setupMenu();
49134
});
50135
},
51136
});
@@ -56,6 +141,7 @@ export const showFolderContextMenuService = createIpcService({
56141
_event,
57142
_folderId: string,
58143
_folderName: string,
144+
folderPath?: string,
59145
): Promise<FolderContextMenuResult> => {
60146
return new Promise((resolve) => {
61147
const template: MenuItemConstructorOptions[] = [
@@ -65,17 +151,31 @@ export const showFolderContextMenuService = createIpcService({
65151
},
66152
];
67153

68-
const menu = Menu.buildFromTemplate(template);
69-
menu.popup({
70-
callback: () => resolve({ action: null }),
71-
});
154+
const setupMenu = async () => {
155+
if (folderPath) {
156+
template.push({ type: "separator" });
157+
const externalAppsItems = await buildExternalAppsMenuItems(
158+
folderPath,
159+
resolve,
160+
);
161+
template.push(...externalAppsItems);
162+
}
163+
164+
showContextMenu(template, { action: null }).then(resolve);
165+
};
166+
167+
setupMenu();
72168
});
73169
},
74170
});
75171

76172
export const showTabContextMenuService = createIpcService({
77173
channel: "show-tab-context-menu",
78-
handler: async (_event, canClose: boolean): Promise<TabContextMenuResult> => {
174+
handler: async (
175+
_event,
176+
canClose: boolean,
177+
filePath?: string,
178+
): Promise<TabContextMenuResult> => {
79179
return new Promise((resolve) => {
80180
const template: MenuItemConstructorOptions[] = [
81181
{
@@ -84,7 +184,6 @@ export const showTabContextMenuService = createIpcService({
84184
enabled: canClose,
85185
click: () => resolve({ action: "close" }),
86186
},
87-
{ type: "separator" },
88187
{
89188
label: "Close other tabs",
90189
click: () => resolve({ action: "close-others" }),
@@ -95,10 +194,20 @@ export const showTabContextMenuService = createIpcService({
95194
},
96195
];
97196

98-
const menu = Menu.buildFromTemplate(template);
99-
menu.popup({
100-
callback: () => resolve({ action: null }),
101-
});
197+
const setupMenu = async () => {
198+
if (filePath) {
199+
template.push({ type: "separator" });
200+
const externalAppsItems = await buildExternalAppsMenuItems(
201+
filePath,
202+
resolve,
203+
);
204+
template.push(...externalAppsItems);
205+
}
206+
207+
showContextMenu(template, { action: null }).then(resolve);
208+
};
209+
210+
setupMenu();
102211
});
103212
},
104213
});
@@ -126,10 +235,28 @@ export const showSplitContextMenuService = createIpcService({
126235
},
127236
];
128237

129-
const menu = Menu.buildFromTemplate(template);
130-
menu.popup({
131-
callback: () => resolve({ direction: null }),
132-
});
238+
showContextMenu(template, { direction: null }).then(resolve);
239+
});
240+
},
241+
});
242+
243+
export const showFileContextMenuService = createIpcService({
244+
channel: "show-file-context-menu",
245+
handler: async (
246+
_event,
247+
filePath: string,
248+
): Promise<ExternalAppContextMenuResult> => {
249+
return new Promise((resolve) => {
250+
const setupMenu = async () => {
251+
const externalAppsItems = await buildExternalAppsMenuItems(
252+
filePath,
253+
resolve,
254+
);
255+
256+
showContextMenu(externalAppsItems, { action: null }).then(resolve);
257+
};
258+
259+
setupMenu();
133260
});
134261
},
135262
});

apps/array/src/main/services/contextMenu.types.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,26 @@
1-
export type TaskContextMenuAction = "rename" | "duplicate" | "delete" | null;
1+
// External app actions (discriminated union for type safety)
2+
export type ExternalAppAction =
3+
| { type: "open-in-app"; appId: string }
4+
| { type: "copy-path" }
5+
| null;
6+
7+
export interface ExternalAppContextMenuResult {
8+
action: ExternalAppAction;
9+
}
210

3-
export type FolderContextMenuAction = "remove" | null;
11+
export type TaskContextMenuAction =
12+
| "rename"
13+
| "duplicate"
14+
| "delete"
15+
| ExternalAppAction;
16+
17+
export type FolderContextMenuAction = "remove" | ExternalAppAction;
418

519
export type TabContextMenuAction =
620
| "close"
721
| "close-others"
822
| "close-right"
9-
| null;
23+
| ExternalAppAction;
1024

1125
export type SplitDirection = "left" | "right" | "up" | "down" | null;
1226

@@ -31,6 +45,7 @@ declare global {
3145
showTaskContextMenu: (
3246
taskId: string,
3347
taskTitle: string,
48+
worktreePath?: string,
3449
) => Promise<TaskContextMenuResult>;
3550
}
3651
}

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

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
} from "../../shared/types";
99
import { logger } from "../lib/logger";
1010
import { clearAllStoreData, foldersStore } from "./store";
11+
import { deleteWorktreeIfExists } from "./worktreeUtils";
1112

1213
const log = logger.scope("folders");
1314

@@ -51,6 +52,19 @@ async function removeFolder(folderId: string): Promise<void> {
5152
const folders = foldersStore.get("folders", []);
5253
const associations = foldersStore.get("taskAssociations", []);
5354

55+
// Delete worktrees for all tasks associated with this folder
56+
const associationsToRemove = associations.filter(
57+
(a) => a.folderId === folderId,
58+
);
59+
for (const assoc of associationsToRemove) {
60+
if (assoc.worktree) {
61+
await deleteWorktreeIfExists(
62+
assoc.folderPath,
63+
assoc.worktree.worktreePath,
64+
);
65+
}
66+
}
67+
5468
const filtered = folders.filter((f) => f.id !== folderId);
5569
const filteredAssociations = associations.filter(
5670
(a) => a.folderId !== folderId,
@@ -130,6 +144,13 @@ async function updateTaskWorktree(
130144

131145
async function removeTaskAssociation(taskId: string): Promise<void> {
132146
const associations = foldersStore.get("taskAssociations", []);
147+
148+
// Delete worktree if it exists
149+
const assoc = associations.find((a) => a.taskId === taskId);
150+
if (assoc?.worktree) {
151+
await deleteWorktreeIfExists(assoc.folderPath, assoc.worktree.worktreePath);
152+
}
153+
133154
const filtered = associations.filter((a) => a.taskId !== taskId);
134155
foldersStore.set("taskAssociations", filtered);
135156
}
@@ -139,7 +160,17 @@ async function clearTaskWorktree(taskId: string): Promise<void> {
139160

140161
const existingIndex = associations.findIndex((a) => a.taskId === taskId);
141162
if (existingIndex >= 0) {
142-
const { worktree: _, ...rest } = associations[existingIndex];
163+
const assoc = associations[existingIndex];
164+
165+
// Delete worktree if it exists
166+
if (assoc.worktree) {
167+
await deleteWorktreeIfExists(
168+
assoc.folderPath,
169+
assoc.worktree.worktreePath,
170+
);
171+
}
172+
173+
const { worktree: _, ...rest } = assoc;
143174
associations[existingIndex] = rest;
144175
foldersStore.set("taskAssociations", associations);
145176
}
@@ -220,7 +251,7 @@ export function registerFoldersIpc(): void {
220251
"clear-all-data",
221252
async (_event: IpcMainInvokeEvent): Promise<void> => {
222253
try {
223-
clearAllStoreData();
254+
await clearAllStoreData();
224255
log.info("Cleared all application data");
225256
} catch (error) {
226257
log.error("Failed to clear all data:", error);

0 commit comments

Comments
 (0)