Quellcode durchsuchen

Add Windows desktop app menu (#28420)

Luke Parker vor 3 Monaten
Ursprung
Commit
82c5d45601

+ 1 - 0
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"
   },

+ 4 - 0
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(),
           }}
         >
+          <Show when={windows()}>
+            <WindowsAppMenu command={command} platform={platform} />
+          </Show>
           <Show when={mac()}>
             <div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />
             <div class="xl:hidden w-10 shrink-0 flex items-center justify-center">

+ 106 - 0
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<typeof useCommand>; platform: ReturnType<typeof usePlatform> }) {
+  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 (
+    <DropdownMenu gutter={4} modal={false} placement="bottom-start">
+      <DropdownMenu.Trigger
+        as={IconButton}
+        icon="menu"
+        variant="ghost"
+        class="titlebar-icon rounded-md shrink-0"
+        aria-label="OpenCode menu"
+        onPointerDown={rememberFocus}
+        onKeyDown={rememberFocus}
+      />
+      <DropdownMenu.Portal>
+        <DropdownMenu.Content class="desktop-app-menu">
+          <DropdownMenu.Group>
+            <DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel>
+            {DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => (
+              <DesktopMenuSubmenu label={menu.label}>
+                {menu.items?.filter((entry) => desktopMenuVisible(entry, "windows")).map((entry) =>
+                  entry.type === "separator" ? (
+                    <DropdownMenu.Separator />
+                  ) : (
+                    <DesktopMenuItem
+                      label={entry.label ?? ""}
+                      keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
+                      disabled={entry.command ? commandDisabled(entry.command) : false}
+                      onSelect={() => runEntry(entry)}
+                    />
+                  ),
+                )}
+              </DesktopMenuSubmenu>
+            ))}
+          </DropdownMenu.Group>
+        </DropdownMenu.Content>
+      </DropdownMenu.Portal>
+    </DropdownMenu>
+  )
+}
+
+function DesktopMenuSubmenu(props: { label: string; children: JSX.Element }) {
+  return (
+    <DropdownMenu.Sub>
+      <DropdownMenu.SubTrigger>
+        <span data-slot="dropdown-menu-item-label">{props.label}</span>
+        <span data-slot="desktop-app-menu-chevron">
+          <Icon name="chevron-right" size="small" />
+        </span>
+      </DropdownMenu.SubTrigger>
+      <DropdownMenu.Portal>
+        <DropdownMenu.SubContent class="desktop-app-menu">{props.children}</DropdownMenu.SubContent>
+      </DropdownMenu.Portal>
+    </DropdownMenu.Sub>
+  )
+}
+
+function DesktopMenuItem(props: { label: string; keybind?: string; disabled?: boolean; onSelect: () => void }) {
+  return (
+    <DropdownMenu.Item disabled={props.disabled} onSelect={props.onSelect}>
+      <DropdownMenu.ItemLabel>{props.label}</DropdownMenu.ItemLabel>
+      <Show when={props.keybind}>
+        <span data-slot="desktop-app-menu-keybind">{props.keybind}</span>
+      </Show>
+    </DropdownMenu.Item>
+  )
+}

+ 4 - 0
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<number>
 
+  /** Run a desktop-only menu action from the app chrome */
+  runDesktopMenuAction?(action: DesktopMenuAction): Promise<void> | void
+
   /** Check if an editor app exists (desktop only) */
   checkAppExists?(appName: string): Promise<boolean>
 

+ 213 - 0
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<Record<DesktopMenuPlatform, string>>
+  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)
+}

+ 57 - 0
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;

+ 84 - 0
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)
+}

+ 4 - 2
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()

+ 5 - 0
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) {

+ 45 - 126
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<MenuItemConstructorOptions["role"]>
+}

+ 1 - 0
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"),

+ 3 - 1
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<number>
   setZoomFactor: (factor: number) => Promise<void>
   setTitlebar: (theme: TitlebarTheme) => Promise<void>
+  runDesktopMenuAction: (action: DesktopMenuAction) => Promise<void>
   loadingWindowComplete: () => void
   runUpdater: (alertOnFail: boolean) => Promise<void>
   checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>

+ 19 - 1
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<string, AsyncStorage>()
 
@@ -254,6 +270,8 @@ const createPlatform = (): Platform => {
 
     webviewZoom,
 
+    runDesktopMenuAction,
+
     checkAppExists: async (appName: string) => {
       return window.api.checkAppExists(appName)
     },

+ 8 - 4
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 }