From 82c5d456015571c35416354164f183b3a0131290 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 20 May 2026 14:28:15 +1000 Subject: [PATCH] Add Windows desktop app menu (#28420) --- packages/app/package.json | 1 + packages/app/src/components/titlebar.tsx | 4 + .../app/src/components/windows-app-menu.tsx | 106 +++++++++ packages/app/src/context/platform.tsx | 4 + packages/app/src/desktop-menu.ts | 213 ++++++++++++++++++ packages/app/src/index.css | 57 +++++ .../desktop/src/main/desktop-menu-actions.ts | 84 +++++++ packages/desktop/src/main/index.ts | 6 +- packages/desktop/src/main/ipc.ts | 5 + packages/desktop/src/main/menu.ts | 171 ++++---------- packages/desktop/src/preload/index.ts | 1 + packages/desktop/src/preload/types.ts | 4 +- packages/desktop/src/renderer/index.tsx | 20 +- packages/desktop/src/renderer/webview-zoom.ts | 12 +- 14 files changed, 554 insertions(+), 134 deletions(-) create mode 100644 packages/app/src/components/windows-app-menu.tsx create mode 100644 packages/app/src/desktop-menu.ts create mode 100644 packages/desktop/src/main/desktop-menu-actions.ts diff --git a/packages/app/package.json b/packages/app/package.json index 571c207313..e24428e5ff 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -5,6 +5,7 @@ "type": "module", "exports": { ".": "./src/index.ts", + "./desktop-menu": "./src/desktop-menu.ts", "./vite": "./vite.js", "./index.css": "./src/index.css" }, diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 2917b7adb8..ebeca3b9ed 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -12,6 +12,7 @@ import { usePlatform } from "@/context/platform" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" +import { WindowsAppMenu } from "./windows-app-menu" import { applyPath, backPath, forwardPath } from "./titlebar-history" type TauriDesktopWindow = { @@ -191,6 +192,9 @@ export function Titlebar() { "pl-2": !mac(), }} > + + +
diff --git a/packages/app/src/components/windows-app-menu.tsx b/packages/app/src/components/windows-app-menu.tsx new file mode 100644 index 0000000000..27754074ff --- /dev/null +++ b/packages/app/src/components/windows-app-menu.tsx @@ -0,0 +1,106 @@ +import { Show, type JSX } from "solid-js" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" + +import { useCommand } from "@/context/command" +import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu" +import { usePlatform } from "@/context/platform" + +export function WindowsAppMenu(props: { command: ReturnType; platform: ReturnType }) { + let lastFocused: HTMLElement | undefined + + const rememberFocus = () => { + const active = document.activeElement + lastFocused = active instanceof HTMLElement ? active : undefined + } + const commandDisabled = (id: string) => { + const option = props.command.options.find((option) => option.id === id) + if (!option) return true + return option.disabled ?? false + } + const runCommand = (id: string) => { + if (commandDisabled(id)) return + props.command.trigger(id) + } + const runAction = (action: DesktopMenuAction) => { + if (action.startsWith("edit.") && lastFocused?.isConnected) lastFocused.focus({ preventScroll: true }) + void props.platform.runDesktopMenuAction?.(action) + } + const runEntry = (entry: DesktopMenuEntry) => { + if (entry.type === "separator") return + if (entry.command) { + runCommand(entry.command) + return + } + if (entry.action) { + runAction(entry.action) + return + } + if (entry.href) props.platform.openLink(entry.href) + } + + return ( + + + + + + OpenCode + {DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => ( + + {menu.items?.filter((entry) => desktopMenuVisible(entry, "windows")).map((entry) => + entry.type === "separator" ? ( + + ) : ( + runEntry(entry)} + /> + ), + )} + + ))} + + + + + ) +} + +function DesktopMenuSubmenu(props: { label: string; children: JSX.Element }) { + return ( + + + {props.label} + + + + + + {props.children} + + + ) +} + +function DesktopMenuItem(props: { label: string; keybind?: string; disabled?: boolean; onSelect: () => void }) { + return ( + + {props.label} + + {props.keybind} + + + ) +} diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index fd89bf51ba..1ded3a7f1a 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -1,6 +1,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage" import type { Accessor } from "solid-js" +import type { DesktopMenuAction } from "../desktop-menu" import { ServerConnection } from "./server" type PickerPaths = string | string[] | null @@ -82,6 +83,9 @@ export type Platform = { /** Webview zoom level (desktop only) */ webviewZoom?: Accessor + /** Run a desktop-only menu action from the app chrome */ + runDesktopMenuAction?(action: DesktopMenuAction): Promise | void + /** Check if an editor app exists (desktop only) */ checkAppExists?(appName: string): Promise diff --git a/packages/app/src/desktop-menu.ts b/packages/app/src/desktop-menu.ts new file mode 100644 index 0000000000..076045e385 --- /dev/null +++ b/packages/app/src/desktop-menu.ts @@ -0,0 +1,213 @@ +export type DesktopMenuPlatform = "macos" | "windows" + +export type DesktopMenuAction = + | "app.checkForUpdates" + | "app.relaunch" + | "edit.undo" + | "edit.redo" + | "edit.cut" + | "edit.copy" + | "edit.paste" + | "edit.delete" + | "edit.selectAll" + | "view.reload" + | "view.toggleDevTools" + | "view.resetZoom" + | "view.zoomIn" + | "view.zoomOut" + | "view.toggleFullscreen" + | "window.new" + | "window.close" + | "window.minimize" + | "window.toggleMaximize" + +export type DesktopMenuRole = + | "about" + | "close" + | "copy" + | "cut" + | "hide" + | "hideOthers" + | "paste" + | "quit" + | "redo" + | "reload" + | "resetZoom" + | "selectAll" + | "toggleDevTools" + | "togglefullscreen" + | "undo" + | "unhide" + | "windowMenu" + | "zoomIn" + | "zoomOut" + +export type DesktopMenuItem = { + type: "item" + label?: string + command?: string + action?: DesktopMenuAction + role?: DesktopMenuRole + href?: string + accelerator?: Partial> + enabled?: "updater" + platforms?: DesktopMenuPlatform[] +} + +export type DesktopMenuSeparator = { + type: "separator" + platforms?: DesktopMenuPlatform[] +} + +export type DesktopMenuEntry = DesktopMenuItem | DesktopMenuSeparator + +export type DesktopMenu = { + id: string + label: string + role?: DesktopMenuRole + items?: DesktopMenuEntry[] + platforms?: DesktopMenuPlatform[] +} + +export const DESKTOP_MENU: DesktopMenu[] = [ + { + id: "app", + label: "OpenCode", + platforms: ["macos"], + items: [ + { type: "item", role: "about" }, + { type: "item", label: "Check for Updates...", action: "app.checkForUpdates", enabled: "updater" }, + { type: "item", label: "Settings", command: "settings.open", accelerator: { macos: "Cmd+," } }, + { type: "item", label: "Reload Webview", action: "view.reload" }, + { type: "item", label: "Restart", action: "app.relaunch" }, + { type: "separator" }, + { type: "item", role: "hide" }, + { type: "item", role: "hideOthers" }, + { type: "item", role: "unhide" }, + { type: "separator" }, + { type: "item", role: "quit" }, + ], + }, + { + id: "file", + label: "File", + items: [ + { + type: "item", + label: "New Session", + command: "session.new", + accelerator: { macos: "Shift+Cmd+S" }, + }, + { type: "item", label: "Open Project...", command: "project.open", accelerator: { macos: "Cmd+O" } }, + { + type: "item", + label: "Settings", + command: "settings.open", + accelerator: { windows: "Ctrl+," }, + platforms: ["windows"], + }, + { + type: "item", + label: "New Window", + action: "window.new", + accelerator: { macos: "Cmd+Shift+N", windows: "Ctrl+Shift+N" }, + }, + { type: "separator" }, + { type: "item", label: "Close Window", action: "window.close", role: "close" }, + ], + }, + { + id: "edit", + label: "Edit", + items: [ + { type: "item", label: "Undo", action: "edit.undo", role: "undo", accelerator: { windows: "Ctrl+Z" } }, + { type: "item", label: "Redo", action: "edit.redo", role: "redo", accelerator: { windows: "Ctrl+Y" } }, + { type: "separator" }, + { type: "item", label: "Cut", action: "edit.cut", role: "cut", accelerator: { windows: "Ctrl+X" } }, + { type: "item", label: "Copy", action: "edit.copy", role: "copy", accelerator: { windows: "Ctrl+C" } }, + { type: "item", label: "Paste", action: "edit.paste", role: "paste", accelerator: { windows: "Ctrl+V" } }, + { type: "item", label: "Delete", action: "edit.delete" }, + { + type: "item", + label: "Select All", + action: "edit.selectAll", + role: "selectAll", + accelerator: { windows: "Ctrl+A" }, + }, + ], + }, + { + id: "view", + label: "View", + items: [ + { type: "item", label: "Toggle Sidebar", command: "sidebar.toggle", accelerator: { macos: "Cmd+B" } }, + { type: "item", label: "Toggle Terminal", command: "terminal.toggle", accelerator: { macos: "Ctrl+`" } }, + { type: "item", label: "Toggle File Tree", command: "fileTree.toggle" }, + { type: "separator" }, + { type: "item", label: "Reload", action: "view.reload", role: "reload" }, + { type: "item", label: "Toggle Developer Tools", action: "view.toggleDevTools", role: "toggleDevTools" }, + { type: "separator" }, + { + type: "item", + label: "Actual Size", + action: "view.resetZoom", + role: "resetZoom", + accelerator: { windows: "Ctrl+0" }, + }, + { type: "item", label: "Zoom In", action: "view.zoomIn", role: "zoomIn", accelerator: { windows: "Ctrl++" } }, + { type: "item", label: "Zoom Out", action: "view.zoomOut", role: "zoomOut", accelerator: { windows: "Ctrl+-" } }, + { type: "separator" }, + { type: "item", label: "Toggle Full Screen", action: "view.toggleFullscreen", role: "togglefullscreen" }, + ], + }, + { + id: "go", + label: "Go", + items: [ + { type: "item", label: "Back", command: "common.goBack", accelerator: { macos: "Cmd+[" } }, + { type: "item", label: "Forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } }, + { type: "separator" }, + { type: "item", label: "Previous Session", command: "session.previous", accelerator: { macos: "Option+Up" } }, + { type: "item", label: "Next Session", command: "session.next", accelerator: { macos: "Option+Down" } }, + { type: "separator" }, + { + type: "item", + label: "Previous Project", + command: "project.previous", + accelerator: { macos: "Cmd+Option+Up" }, + }, + { + type: "item", + label: "Next Project", + command: "project.next", + accelerator: { macos: "Cmd+Option+Down" }, + }, + ], + }, + { + id: "window", + label: "Window", + role: "windowMenu", + items: [ + { type: "item", label: "Minimize", action: "window.minimize" }, + { type: "item", label: "Maximize", action: "window.toggleMaximize" }, + { type: "separator" }, + { type: "item", label: "Close Window", action: "window.close" }, + ], + }, + { + id: "help", + label: "Help", + items: [ + { type: "item", label: "OpenCode Documentation", href: "https://opencode.ai/docs" }, + { type: "item", label: "Support Forum", href: "https://discord.com/invite/opencode" }, + { type: "separator" }, + { type: "item", label: "Share Feedback", href: "https://github.com/anomalyco/opencode/issues/new?template=feature_request.yml" }, + { type: "item", label: "Report a Bug", href: "https://github.com/anomalyco/opencode/issues/new?template=bug_report.yml" }, + ], + }, +] + +export function desktopMenuVisible(item: { platforms?: DesktopMenuPlatform[] }, platform: DesktopMenuPlatform) { + return !item.platforms || item.platforms.includes(platform) +} diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 8db576dd83..79641cae5d 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -53,6 +53,63 @@ container-name: getting-started; } + [data-component="dropdown-menu-content"].desktop-app-menu, + [data-component="dropdown-menu-sub-content"].desktop-app-menu { + min-width: 160px; + padding: 2px; + } + + [data-component="dropdown-menu-content"].desktop-app-menu { + width: 160px; + } + + [data-component="dropdown-menu-sub-content"].desktop-app-menu { + width: max-content; + min-width: 240px; + max-width: min(320px, calc(100vw - 24px)); + } + + [data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-group-label"] { + display: flex; + align-items: center; + height: 28px; + padding: 0 12px; + font-size: var(--font-size-x-small); + font-weight: var(--font-weight-medium); + line-height: 1; + color: var(--text-weak); + } + + [data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-item"], + [data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-sub-trigger"], + [data-component="dropdown-menu-sub-content"].desktop-app-menu [data-slot="dropdown-menu-item"], + [data-component="dropdown-menu-sub-content"].desktop-app-menu [data-slot="dropdown-menu-sub-trigger"] { + min-height: 28px; + padding: 0 12px; + gap: 8px; + font-weight: var(--font-weight-regular); + line-height: 1; + } + + [data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-item-label"], + [data-component="dropdown-menu-sub-content"].desktop-app-menu [data-slot="dropdown-menu-item-label"] { + white-space: nowrap; + } + + [data-slot="desktop-app-menu-keybind"] { + margin-left: auto; + color: var(--text-weak); + font-size: var(--font-size-x-small); + font-weight: var(--font-weight-regular); + white-space: nowrap; + } + + [data-slot="desktop-app-menu-chevron"] { + display: flex; + margin-left: auto; + color: var(--icon-base); + } + [data-component="getting-started-actions"] { display: flex; flex-direction: column; diff --git a/packages/desktop/src/main/desktop-menu-actions.ts b/packages/desktop/src/main/desktop-menu-actions.ts new file mode 100644 index 0000000000..aa15e05e27 --- /dev/null +++ b/packages/desktop/src/main/desktop-menu-actions.ts @@ -0,0 +1,84 @@ +import { BrowserWindow } from "electron" +import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu" +import { createMainWindow, updateTitlebar } from "./windows" + +export type DesktopMenuActionHandlers = Partial<{ + checkForUpdates: () => void + relaunch: () => void +}> + +export function runDesktopMenuAction( + win: BrowserWindow | null, + action: DesktopMenuAction, + handlers: DesktopMenuActionHandlers = {}, +) { + switch (action) { + case "app.checkForUpdates": + handlers.checkForUpdates?.() + return + case "app.relaunch": + handlers.relaunch?.() + return + case "window.new": + createMainWindow() + return + case "window.close": + win?.close() + return + case "window.minimize": + win?.minimize() + return + case "window.toggleMaximize": + if (win?.isMaximized()) { + win.unmaximize() + return + } + win?.maximize() + return + case "view.reload": + win?.reload() + return + case "view.toggleDevTools": + win?.webContents.toggleDevTools() + return + case "view.resetZoom": + setZoom(win, 1) + return + case "view.zoomIn": + setZoom(win, (win?.webContents.getZoomFactor() ?? 1) + 0.2) + return + case "view.zoomOut": + setZoom(win, (win?.webContents.getZoomFactor() ?? 1) - 0.2) + return + case "view.toggleFullscreen": + win?.setFullScreen(!win.isFullScreen()) + return + case "edit.undo": + win?.webContents.undo() + return + case "edit.redo": + win?.webContents.redo() + return + case "edit.cut": + win?.webContents.cut() + return + case "edit.copy": + win?.webContents.copy() + return + case "edit.paste": + win?.webContents.paste() + return + case "edit.delete": + win?.webContents.delete() + return + case "edit.selectAll": + win?.webContents.selectAll() + return + } +} + +function setZoom(win: BrowserWindow | null, value: number) { + if (!win) return + win.webContents.setZoomFactor(Math.min(Math.max(value, 0.2), 10)) + updateTitlebar(win) +} diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 23f2d7027a..a14c6a383c 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -345,11 +345,13 @@ const main = Effect.gen(function* () { mainWindow = createMainWindow() if (mainWindow) { createMenu({ - trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id), + trigger: (id) => { + const win = BrowserWindow.getFocusedWindow() ?? mainWindow + if (win) sendMenuCommand(win, id) + }, checkForUpdates: () => { void checkForUpdates(true, killSidecar) }, - reload: () => mainWindow?.reload(), relaunch: () => { void killSidecar().finally(() => { app.relaunch() diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index dbcd4239dc..40c54c0856 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -1,6 +1,7 @@ import { execFile } from "node:child_process" import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } from "electron" import type { IpcMainEvent, IpcMainInvokeEvent } from "electron" +import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu" import type { InitStep, @@ -10,6 +11,7 @@ import type { WindowConfig, WslConfig, } from "../preload/types" +import { runDesktopMenuAction } from "./desktop-menu-actions" import { getStore } from "./store" import { setTitlebar, updateTitlebar } from "./windows" @@ -198,6 +200,9 @@ export function registerIpcHandlers(deps: Deps) { if (!win) return setTitlebar(win, theme) }) + ipcMain.handle("run-desktop-menu-action", (event: IpcMainInvokeEvent, action: DesktopMenuAction) => { + runDesktopMenuAction(BrowserWindow.fromWebContents(event.sender), action) + }) } export function sendSqliteMigrationProgress(win: BrowserWindow, progress: SqliteMigrationProgress) { diff --git a/packages/desktop/src/main/menu.ts b/packages/desktop/src/main/menu.ts index 2d5a900f39..d8746d2ac6 100644 --- a/packages/desktop/src/main/menu.ts +++ b/packages/desktop/src/main/menu.ts @@ -1,141 +1,60 @@ -import { Menu, shell } from "electron" +import { BrowserWindow, Menu, shell } from "electron" +import type { MenuItemConstructorOptions } from "electron" +import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuEntry, type DesktopMenuRole } from "@opencode-ai/app/desktop-menu" import { UPDATER_ENABLED } from "./constants" -import { createMainWindow } from "./windows" +import { runDesktopMenuAction } from "./desktop-menu-actions" type Deps = { trigger: (id: string) => void checkForUpdates: () => void - reload: () => void relaunch: () => void } export function createMenu(deps: Deps) { if (process.platform !== "darwin") return - const template: Electron.MenuItemConstructorOptions[] = [ - { - label: "OpenCode", - submenu: [ - { role: "about" }, - { - label: "Check for Updates...", - enabled: UPDATER_ENABLED, - click: () => deps.checkForUpdates(), - }, - { - label: "Settings", - accelerator: "Cmd+,", - click: () => deps.trigger("settings.open"), - }, - { - label: "Reload Webview", - click: () => deps.reload(), - }, - { - label: "Restart", - click: () => deps.relaunch(), - }, - { type: "separator" }, - { role: "hide" }, - { role: "hideOthers" }, - { role: "unhide" }, - { type: "separator" }, - { role: "quit" }, - ], - }, - { - label: "File", - submenu: [ - { label: "New Session", accelerator: "Shift+Cmd+S", click: () => deps.trigger("session.new") }, - { label: "Open Project...", accelerator: "Cmd+O", click: () => deps.trigger("project.open") }, - { - label: "New Window", - accelerator: "Cmd+Shift+N", - click: () => createMainWindow(), - }, - { type: "separator" }, - { role: "close" }, - ], - }, - { - label: "Edit", - submenu: [ - { role: "undo" }, - { role: "redo" }, - { type: "separator" }, - { role: "cut" }, - { role: "copy" }, - { role: "paste" }, - { role: "selectAll" }, - ], - }, - { - label: "View", - submenu: [ - { label: "Toggle Sidebar", accelerator: "Cmd+B", click: () => deps.trigger("sidebar.toggle") }, - { label: "Toggle Terminal", accelerator: "Ctrl+`", click: () => deps.trigger("terminal.toggle") }, - { label: "Toggle File Tree", click: () => deps.trigger("fileTree.toggle") }, - { type: "separator" }, - { role: "reload" }, - { role: "toggleDevTools" }, - { type: "separator" }, - { role: "resetZoom" }, - { role: "zoomIn" }, - { role: "zoomOut" }, - { type: "separator" }, - { role: "togglefullscreen" }, - ], - }, - { - label: "Go", - submenu: [ - { label: "Back", accelerator: "Cmd+[", click: () => deps.trigger("common.goBack") }, - { label: "Forward", accelerator: "Cmd+]", click: () => deps.trigger("common.goForward") }, - { type: "separator" }, - { - label: "Previous Session", - accelerator: "Option+Up", - click: () => deps.trigger("session.previous"), - }, - { - label: "Next Session", - accelerator: "Option+Down", - click: () => deps.trigger("session.next"), - }, - { type: "separator" }, - { - label: "Previous Project", - accelerator: "Cmd+Option+Up", - click: () => deps.trigger("project.previous"), - }, - { - label: "Next Project", - accelerator: "Cmd+Option+Down", - click: () => deps.trigger("project.next"), - }, - ], - }, - { role: "windowMenu" }, - { - label: "Help", - submenu: [ - { label: "OpenCode Documentation", click: () => shell.openExternal("https://opencode.ai/docs") }, - { label: "Support Forum", click: () => shell.openExternal("https://discord.com/invite/opencode") }, - { type: "separator" }, - { type: "separator" }, - { - label: "Share Feedback", - click: () => - shell.openExternal("https://github.com/anomalyco/opencode/issues/new?template=feature_request.yml"), - }, - { - label: "Report a Bug", - click: () => shell.openExternal("https://github.com/anomalyco/opencode/issues/new?template=bug_report.yml"), - }, - ], - }, - ] + const template = DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "macos")).map((menu) => { + if (menu.role) return { role: nativeRole(menu.role) } + return { + label: menu.label, + submenu: menu.items?.filter((entry) => desktopMenuVisible(entry, "macos")).map((entry) => nativeItem(entry, deps)), + } + }) Menu.setApplicationMenu(Menu.buildFromTemplate(template)) } + +function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOptions { + if (entry.type === "separator") return { type: "separator" } + if (entry.role) return { role: nativeRole(entry.role) } + + const item: MenuItemConstructorOptions = { + label: entry.label, + accelerator: entry.accelerator?.macos, + enabled: entry.enabled === "updater" ? UPDATER_ENABLED : undefined, + } + + if (entry.command) { + const command = entry.command + item.click = () => deps.trigger(command) + } + if (entry.action) { + const action = entry.action + item.click = () => + runDesktopMenuAction(BrowserWindow.getFocusedWindow(), action, { + checkForUpdates: deps.checkForUpdates, + relaunch: deps.relaunch, + }) + } + if (entry.href) { + const href = entry.href + item.click = () => shell.openExternal(href) + } + + return item +} + +function nativeRole(role: DesktopMenuRole) { + return role as NonNullable +} diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 6261419ca5..4adbfb62aa 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -61,6 +61,7 @@ const api: ElectronAPI = { getZoomFactor: () => ipcRenderer.invoke("get-zoom-factor"), setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor), setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme), + runDesktopMenuAction: (action) => ipcRenderer.invoke("run-desktop-menu-action", action), loadingWindowComplete: () => ipcRenderer.send("loading-window-complete"), runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail), checkUpdate: () => ipcRenderer.invoke("check-update"), diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index 6e22954d18..ce931a1d9e 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -1,3 +1,5 @@ +import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu" + export type InitStep = { phase: "server_waiting" } | { phase: "sqlite_waiting" } | { phase: "done" } export type ServerReadyData = { @@ -14,7 +16,6 @@ export type LinuxDisplayBackend = "wayland" | "auto" export type TitlebarTheme = { mode: "light" | "dark" } - export type WindowConfig = { updaterEnabled: boolean } @@ -71,6 +72,7 @@ export type ElectronAPI = { getZoomFactor: () => Promise setZoomFactor: (factor: number) => Promise setTitlebar: (theme: TitlebarTheme) => Promise + runDesktopMenuAction: (action: DesktopMenuAction) => Promise loadingWindowComplete: () => void runUpdater: (alertOnFail: boolean) => Promise checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }> diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index f9114c7550..16cc41842f 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -21,7 +21,7 @@ import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js import { render } from "solid-js/web" import pkg from "../../package.json" import { initI18n, t } from "./i18n" -import { webviewZoom } from "./webview-zoom" +import { resetZoom, webviewZoom, zoomIn, zoomOut } from "./webview-zoom" import "./styles.css" import { useTheme } from "@opencode-ai/ui/theme" @@ -100,6 +100,22 @@ const createPlatform = (): Platform => { return window.api.wslPath(result, "linux").catch(() => result) as any } + const runDesktopMenuAction: Platform["runDesktopMenuAction"] = (action) => { + switch (action) { + case "view.resetZoom": + resetZoom() + return + case "view.zoomIn": + zoomIn() + return + case "view.zoomOut": + zoomOut() + return + } + + return window.api.runDesktopMenuAction(action) + } + const storage = (() => { const cache = new Map() @@ -254,6 +270,8 @@ const createPlatform = (): Platform => { webviewZoom, + runDesktopMenuAction, + checkAppExists: async (appName: string) => { return window.api.checkAppExists(appName) }, diff --git a/packages/desktop/src/renderer/webview-zoom.ts b/packages/desktop/src/renderer/webview-zoom.ts index cb4b5a4481..843be46378 100644 --- a/packages/desktop/src/renderer/webview-zoom.ts +++ b/packages/desktop/src/renderer/webview-zoom.ts @@ -33,23 +33,27 @@ const applyZoom = (next: number) => { }) } +const resetZoom = () => applyZoom(1) +const zoomIn = () => applyZoom(clamp(requestedZoom + 0.2)) +const zoomOut = () => applyZoom(clamp(requestedZoom - 0.2)) + window.addEventListener("keydown", (event) => { if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return if (event.key === "-") { event.preventDefault() - applyZoom(clamp(requestedZoom - 0.2)) + zoomOut() return } if (event.key === "=" || event.key === "+") { event.preventDefault() - applyZoom(clamp(requestedZoom + 0.2)) + zoomIn() return } if (event.key === "0") { event.preventDefault() - applyZoom(1) + resetZoom() } }) -export { webviewZoom } +export { webviewZoom, resetZoom, zoomIn, zoomOut }