Skip to content

Commit 4e49162

Browse files
authored
refactor: move context-menu service to trpc (#271)
1 parent 8152aee commit 4e49162

File tree

20 files changed

+505
-574
lines changed

20 files changed

+505
-574
lines changed

apps/array/src/main/di/container.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import "reflect-metadata";
22
import { Container } from "inversify";
3+
import { ContextMenuService } from "../services/context-menu/service.js";
34
import { GitService } from "../services/git/service.js";
45
import { MAIN_TOKENS } from "./tokens.js";
56

@@ -11,6 +12,9 @@ export const container = new Container({
1112
});
1213

1314
// Bind services
15+
container
16+
.bind<ContextMenuService>(MAIN_TOKENS.ContextMenuService)
17+
.to(ContextMenuService);
1418
container.bind<GitService>(MAIN_TOKENS.GitService).to(GitService);
1519

1620
export function get<T>(token: symbol): T {

apps/array/src/main/di/tokens.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@
66
*/
77
export const MAIN_TOKENS = Object.freeze({
88
// Services
9+
ContextMenuService: Symbol.for("Main.ContextMenuService"),
910
GitService: Symbol.for("Main.GitService"),
1011
});

apps/array/src/main/preload.ts

Lines changed: 0 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,6 @@ import type {
1111
WorktreeInfo,
1212
} from "../shared/types";
1313
import type { CloudRegion, OAuthTokenResponse } from "../shared/types/oauth";
14-
import type {
15-
ExternalAppContextMenuResult,
16-
FolderContextMenuResult,
17-
SplitContextMenuResult,
18-
TabContextMenuResult,
19-
TaskContextMenuResult,
20-
} from "./services/contextMenu.types.js";
2114
import "electron-log/preload";
2215

2316
process.once("loaded", () => {
@@ -262,41 +255,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
262255
): (() => void) => createIpcListener(`shell:data:${sessionId}`, listener),
263256
onShellExit: (sessionId: string, listener: () => void): (() => void) =>
264257
createVoidIpcListener(`shell:exit:${sessionId}`, listener),
265-
// Context Menu API
266-
showTaskContextMenu: (
267-
taskId: string,
268-
taskTitle: string,
269-
worktreePath?: string,
270-
): Promise<TaskContextMenuResult> =>
271-
ipcRenderer.invoke(
272-
"show-task-context-menu",
273-
taskId,
274-
taskTitle,
275-
worktreePath,
276-
),
277-
showFolderContextMenu: (
278-
folderId: string,
279-
folderName: string,
280-
folderPath?: string,
281-
): Promise<FolderContextMenuResult> =>
282-
ipcRenderer.invoke(
283-
"show-folder-context-menu",
284-
folderId,
285-
folderName,
286-
folderPath,
287-
),
288-
showTabContextMenu: (
289-
canClose: boolean,
290-
filePath?: string,
291-
): Promise<TabContextMenuResult> =>
292-
ipcRenderer.invoke("show-tab-context-menu", canClose, filePath),
293-
showSplitContextMenu: (): Promise<SplitContextMenuResult> =>
294-
ipcRenderer.invoke("show-split-context-menu"),
295-
showFileContextMenu: (
296-
filePath: string,
297-
options?: { showCollapseAll?: boolean },
298-
): Promise<ExternalAppContextMenuResult> =>
299-
ipcRenderer.invoke("show-file-context-menu", filePath, options),
300258
folders: {
301259
getFolders: (): Promise<RegisteredFolder[]> =>
302260
ipcRenderer.invoke("get-folders"),
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
import {
2+
dialog,
3+
Menu,
4+
type MenuItemConstructorOptions,
5+
nativeImage,
6+
} from "electron";
7+
import { injectable } from "inversify";
8+
import type { DetectedApplication } from "../../../shared/types.js";
9+
import { getMainWindow } from "../../trpc/context.js";
10+
import { externalAppsStore, getOrRefreshApps } from "../externalApps.js";
11+
import type {
12+
ActionItemDef,
13+
ConfirmOptions,
14+
FileAction,
15+
FileContextMenuInput,
16+
FileContextMenuResult,
17+
FolderAction,
18+
FolderContextMenuInput,
19+
FolderContextMenuResult,
20+
MenuItemDef,
21+
SeparatorDef,
22+
SplitContextMenuResult,
23+
SplitDirection,
24+
TabAction,
25+
TabContextMenuInput,
26+
TabContextMenuResult,
27+
TaskAction,
28+
TaskContextMenuInput,
29+
TaskContextMenuResult,
30+
} from "./types.js";
31+
32+
@injectable()
33+
export class ContextMenuService {
34+
private readonly ICON_SIZE = 16;
35+
36+
async showTaskContextMenu(
37+
input: TaskContextMenuInput,
38+
): Promise<TaskContextMenuResult> {
39+
const { taskTitle, worktreePath } = input;
40+
const apps = await getOrRefreshApps();
41+
42+
return this.showMenu<TaskAction>([
43+
this.item("Rename", { type: "rename" }),
44+
this.item("Duplicate", { type: "duplicate" }),
45+
this.separator(),
46+
this.item(
47+
"Delete",
48+
{ type: "delete" },
49+
{
50+
confirm: {
51+
title: "Delete Task",
52+
message: `Delete "${taskTitle}"?`,
53+
detail: worktreePath
54+
? "This will permanently delete the task and its associated worktree."
55+
: "This will permanently delete the task.",
56+
confirmLabel: "Delete",
57+
},
58+
},
59+
),
60+
...(worktreePath
61+
? [this.separator(), ...this.externalAppItems<TaskAction>(apps)]
62+
: []),
63+
]);
64+
}
65+
66+
async showFolderContextMenu(
67+
input: FolderContextMenuInput,
68+
): Promise<FolderContextMenuResult> {
69+
const { folderName, folderPath } = input;
70+
const apps = await getOrRefreshApps();
71+
72+
return this.showMenu<FolderAction>([
73+
this.item(
74+
"Remove folder",
75+
{ type: "remove" },
76+
{
77+
confirm: {
78+
title: "Remove Folder",
79+
message: `Remove "${folderName}" from Array?`,
80+
detail:
81+
"This will clean up any worktrees but keep your folder and tasks intact.",
82+
confirmLabel: "Remove",
83+
},
84+
},
85+
),
86+
...(folderPath
87+
? [this.separator(), ...this.externalAppItems<FolderAction>(apps)]
88+
: []),
89+
]);
90+
}
91+
92+
async showTabContextMenu(
93+
input: TabContextMenuInput,
94+
): Promise<TabContextMenuResult> {
95+
const { canClose, filePath } = input;
96+
const apps = await getOrRefreshApps();
97+
98+
return this.showMenu<TabAction>([
99+
this.item(
100+
"Close tab",
101+
{ type: "close" },
102+
{
103+
accelerator: "CmdOrCtrl+W",
104+
enabled: canClose,
105+
},
106+
),
107+
this.item("Close other tabs", { type: "close-others" }),
108+
this.item("Close tabs to the right", { type: "close-right" }),
109+
...(filePath
110+
? [this.separator(), ...this.externalAppItems<TabAction>(apps)]
111+
: []),
112+
]);
113+
}
114+
115+
async showSplitContextMenu(): Promise<SplitContextMenuResult> {
116+
const result = await this.showMenu<SplitDirection>([
117+
this.item("Split right", "right"),
118+
this.item("Split left", "left"),
119+
this.item("Split down", "down"),
120+
this.item("Split up", "up"),
121+
]);
122+
return { direction: result.action };
123+
}
124+
125+
async showFileContextMenu(
126+
input: FileContextMenuInput,
127+
): Promise<FileContextMenuResult> {
128+
const apps = await getOrRefreshApps();
129+
130+
return this.showMenu<FileAction>([
131+
...(input.showCollapseAll
132+
? [
133+
this.item<FileAction>("Collapse All", { type: "collapse-all" }),
134+
this.separator(),
135+
]
136+
: []),
137+
...this.externalAppItems<FileAction>(apps),
138+
]);
139+
}
140+
141+
private externalAppItems<T>(apps: DetectedApplication[]): MenuItemDef<T>[] {
142+
if (apps.length === 0) {
143+
return [this.disabled("No external apps detected")];
144+
}
145+
146+
const lastUsedAppId =
147+
externalAppsStore.get("externalAppsPrefs")?.lastUsedApp;
148+
const lastUsedApp = apps.find((app) => app.id === lastUsedAppId) || apps[0];
149+
const openIn = (appId: string): T =>
150+
({ type: "external-app", action: { type: "open-in-app", appId } }) as T;
151+
const copyPath: T = {
152+
type: "external-app",
153+
action: { type: "copy-path" },
154+
} as T;
155+
156+
return [
157+
this.item(`Open in ${lastUsedApp.name}`, openIn(lastUsedApp.id)),
158+
{
159+
type: "submenu",
160+
label: "Open in",
161+
items: apps.map((app) => ({
162+
label: app.name,
163+
icon: app.icon
164+
? nativeImage
165+
.createFromDataURL(app.icon)
166+
.resize({ width: this.ICON_SIZE, height: this.ICON_SIZE })
167+
: undefined,
168+
action: openIn(app.id),
169+
})),
170+
},
171+
this.item("Copy Path", copyPath, { accelerator: "CmdOrCtrl+Shift+C" }),
172+
];
173+
}
174+
175+
private item<T>(
176+
label: string,
177+
action: T,
178+
options?: Partial<Omit<ActionItemDef<T>, "type" | "label" | "action">>,
179+
): ActionItemDef<T> {
180+
return { type: "item", label, action, ...options };
181+
}
182+
183+
private separator(): SeparatorDef {
184+
return { type: "separator" };
185+
}
186+
187+
private disabled(label: string): MenuItemDef<never> {
188+
return { type: "disabled", label };
189+
}
190+
191+
private showMenu<T>(items: MenuItemDef<T>[]): Promise<{ action: T | null }> {
192+
return new Promise((resolve) => {
193+
let pendingConfirm = false;
194+
195+
const toMenuItem = (def: MenuItemDef<T>): MenuItemConstructorOptions => {
196+
switch (def.type) {
197+
case "separator":
198+
return { type: "separator" };
199+
200+
case "disabled":
201+
return { label: def.label, enabled: false };
202+
203+
case "submenu":
204+
return {
205+
label: def.label,
206+
submenu: def.items.map((sub) => ({
207+
label: sub.label,
208+
icon: sub.icon,
209+
click: () => resolve({ action: sub.action }),
210+
})),
211+
};
212+
213+
case "item": {
214+
const confirmOptions = def.confirm;
215+
const onClick = confirmOptions
216+
? async () => {
217+
pendingConfirm = true;
218+
const confirmed = await this.confirm(confirmOptions);
219+
resolve({ action: confirmed ? def.action : null });
220+
}
221+
: () => resolve({ action: def.action });
222+
223+
return {
224+
label: def.label,
225+
accelerator: def.accelerator,
226+
enabled: def.enabled,
227+
icon: def.icon,
228+
click: onClick,
229+
};
230+
}
231+
}
232+
};
233+
234+
Menu.buildFromTemplate(items.map(toMenuItem)).popup({
235+
callback: () => {
236+
if (!pendingConfirm) resolve({ action: null });
237+
},
238+
});
239+
});
240+
}
241+
242+
private async confirm(options: ConfirmOptions): Promise<boolean> {
243+
const win = getMainWindow();
244+
const result = await dialog.showMessageBox({
245+
...(win ? { parent: win } : {}),
246+
type: "question",
247+
title: options.title,
248+
message: options.message,
249+
detail: options.detail,
250+
buttons: ["Cancel", options.confirmLabel],
251+
defaultId: 1,
252+
cancelId: 0,
253+
});
254+
return result.response === 1;
255+
}
256+
}

0 commit comments

Comments
 (0)