Skip to content

Commit a33cfac

Browse files
Apply PR #28420: Add Windows desktop app menu
2 parents 1e80919 + 5eb2b7a commit a33cfac

14 files changed

Lines changed: 562 additions & 139 deletions

File tree

packages/app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"type": "module",
66
"exports": {
77
".": "./src/index.ts",
8+
"./desktop-menu": "./src/desktop-menu.ts",
89
"./vite": "./vite.js",
910
"./index.css": "./src/index.css"
1011
},

packages/app/src/components/titlebar.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { usePlatform } from "@/context/platform"
1212
import { useCommand } from "@/context/command"
1313
import { useLanguage } from "@/context/language"
1414
import { useSettings } from "@/context/settings"
15+
import { WindowsAppMenu } from "./windows-app-menu"
1516
import { applyPath, backPath, forwardPath } from "./titlebar-history"
1617

1718
type TauriDesktopWindow = {
@@ -191,6 +192,9 @@ export function Titlebar() {
191192
"pl-2": !mac(),
192193
}}
193194
>
195+
<Show when={windows()}>
196+
<WindowsAppMenu command={command} platform={platform} />
197+
</Show>
194198
<Show when={mac()}>
195199
<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />
196200
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { Show, type JSX } from "solid-js"
2+
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
3+
import { Icon } from "@opencode-ai/ui/icon"
4+
import { IconButton } from "@opencode-ai/ui/icon-button"
5+
6+
import { useCommand } from "@/context/command"
7+
import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
8+
import { usePlatform } from "@/context/platform"
9+
10+
export function WindowsAppMenu(props: { command: ReturnType<typeof useCommand>; platform: ReturnType<typeof usePlatform> }) {
11+
let lastFocused: HTMLElement | undefined
12+
13+
const rememberFocus = () => {
14+
const active = document.activeElement
15+
lastFocused = active instanceof HTMLElement ? active : undefined
16+
}
17+
const commandDisabled = (id: string) => {
18+
const option = props.command.options.find((option) => option.id === id)
19+
if (!option) return true
20+
return option.disabled ?? false
21+
}
22+
const runCommand = (id: string) => {
23+
if (commandDisabled(id)) return
24+
props.command.trigger(id)
25+
}
26+
const runAction = (action: DesktopMenuAction) => {
27+
if (action.startsWith("edit.") && lastFocused?.isConnected) lastFocused.focus({ preventScroll: true })
28+
void props.platform.runDesktopMenuAction?.(action)
29+
}
30+
const runEntry = (entry: DesktopMenuEntry) => {
31+
if (entry.type === "separator") return
32+
if (entry.command) {
33+
runCommand(entry.command)
34+
return
35+
}
36+
if (entry.action) {
37+
runAction(entry.action)
38+
return
39+
}
40+
if (entry.href) props.platform.openLink(entry.href)
41+
}
42+
43+
return (
44+
<DropdownMenu gutter={4} modal={false} placement="bottom-start">
45+
<DropdownMenu.Trigger
46+
as={IconButton}
47+
icon="menu"
48+
variant="ghost"
49+
class="titlebar-icon rounded-md shrink-0"
50+
aria-label="OpenCode menu"
51+
onPointerDown={rememberFocus}
52+
onKeyDown={rememberFocus}
53+
/>
54+
<DropdownMenu.Portal>
55+
<DropdownMenu.Content class="desktop-app-menu">
56+
<DropdownMenu.Group>
57+
<DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel>
58+
{DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => (
59+
<DesktopMenuSubmenu label={menu.label}>
60+
{menu.items?.filter((entry) => desktopMenuVisible(entry, "windows")).map((entry) =>
61+
entry.type === "separator" ? (
62+
<DropdownMenu.Separator />
63+
) : (
64+
<DesktopMenuItem
65+
label={entry.label ?? ""}
66+
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
67+
disabled={entry.command ? commandDisabled(entry.command) : false}
68+
onSelect={() => runEntry(entry)}
69+
/>
70+
),
71+
)}
72+
</DesktopMenuSubmenu>
73+
))}
74+
</DropdownMenu.Group>
75+
</DropdownMenu.Content>
76+
</DropdownMenu.Portal>
77+
</DropdownMenu>
78+
)
79+
}
80+
81+
function DesktopMenuSubmenu(props: { label: string; children: JSX.Element }) {
82+
return (
83+
<DropdownMenu.Sub>
84+
<DropdownMenu.SubTrigger>
85+
<span data-slot="dropdown-menu-item-label">{props.label}</span>
86+
<span data-slot="desktop-app-menu-chevron">
87+
<Icon name="chevron-right" size="small" />
88+
</span>
89+
</DropdownMenu.SubTrigger>
90+
<DropdownMenu.Portal>
91+
<DropdownMenu.SubContent class="desktop-app-menu">{props.children}</DropdownMenu.SubContent>
92+
</DropdownMenu.Portal>
93+
</DropdownMenu.Sub>
94+
)
95+
}
96+
97+
function DesktopMenuItem(props: { label: string; keybind?: string; disabled?: boolean; onSelect: () => void }) {
98+
return (
99+
<DropdownMenu.Item disabled={props.disabled} onSelect={props.onSelect}>
100+
<DropdownMenu.ItemLabel>{props.label}</DropdownMenu.ItemLabel>
101+
<Show when={props.keybind}>
102+
<span data-slot="desktop-app-menu-keybind">{props.keybind}</span>
103+
</Show>
104+
</DropdownMenu.Item>
105+
)
106+
}

packages/app/src/context/platform.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createSimpleContext } from "@opencode-ai/ui/context"
22
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
33
import type { Accessor } from "solid-js"
4+
import type { DesktopMenuAction } from "../desktop-menu"
45
import { ServerConnection } from "./server"
56

67
type PickerPaths = string | string[] | null
@@ -92,6 +93,9 @@ export type Platform = {
9293
/** Webview zoom level (desktop only) */
9394
webviewZoom?: Accessor<number>
9495

96+
/** Run a desktop-only menu action from the app chrome */
97+
runDesktopMenuAction?(action: DesktopMenuAction): Promise<void> | void
98+
9599
/** Check if an editor app exists (desktop only) */
96100
checkAppExists?(appName: string): Promise<boolean>
97101

packages/app/src/desktop-menu.ts

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
export type DesktopMenuPlatform = "macos" | "windows"
2+
3+
export type DesktopMenuAction =
4+
| "app.checkForUpdates"
5+
| "app.relaunch"
6+
| "app.exportLogs"
7+
| "edit.undo"
8+
| "edit.redo"
9+
| "edit.cut"
10+
| "edit.copy"
11+
| "edit.paste"
12+
| "edit.delete"
13+
| "edit.selectAll"
14+
| "view.reload"
15+
| "view.toggleDevTools"
16+
| "view.resetZoom"
17+
| "view.zoomIn"
18+
| "view.zoomOut"
19+
| "view.toggleFullscreen"
20+
| "window.new"
21+
| "window.close"
22+
| "window.minimize"
23+
| "window.toggleMaximize"
24+
25+
export type DesktopMenuRole =
26+
| "about"
27+
| "close"
28+
| "copy"
29+
| "cut"
30+
| "hide"
31+
| "hideOthers"
32+
| "paste"
33+
| "quit"
34+
| "redo"
35+
| "reload"
36+
| "resetZoom"
37+
| "selectAll"
38+
| "toggleDevTools"
39+
| "togglefullscreen"
40+
| "undo"
41+
| "unhide"
42+
| "windowMenu"
43+
| "zoomIn"
44+
| "zoomOut"
45+
46+
export type DesktopMenuItem = {
47+
type: "item"
48+
label?: string
49+
command?: string
50+
action?: DesktopMenuAction
51+
role?: DesktopMenuRole
52+
href?: string
53+
accelerator?: Partial<Record<DesktopMenuPlatform, string>>
54+
enabled?: "updater"
55+
platforms?: DesktopMenuPlatform[]
56+
}
57+
58+
export type DesktopMenuSeparator = {
59+
type: "separator"
60+
platforms?: DesktopMenuPlatform[]
61+
}
62+
63+
export type DesktopMenuEntry = DesktopMenuItem | DesktopMenuSeparator
64+
65+
export type DesktopMenu = {
66+
id: string
67+
label: string
68+
role?: DesktopMenuRole
69+
items?: DesktopMenuEntry[]
70+
platforms?: DesktopMenuPlatform[]
71+
}
72+
73+
export const DESKTOP_MENU: DesktopMenu[] = [
74+
{
75+
id: "app",
76+
label: "OpenCode",
77+
platforms: ["macos"],
78+
items: [
79+
{ type: "item", role: "about" },
80+
{ type: "item", label: "Check for Updates...", action: "app.checkForUpdates", enabled: "updater" },
81+
{ type: "item", label: "Settings", command: "settings.open", accelerator: { macos: "Cmd+," } },
82+
{ type: "item", label: "Reload Webview", action: "view.reload" },
83+
{ type: "item", label: "Restart", action: "app.relaunch" },
84+
{ type: "item", label: "Export Logs...", action: "app.exportLogs" },
85+
{ type: "separator" },
86+
{ type: "item", role: "hide" },
87+
{ type: "item", role: "hideOthers" },
88+
{ type: "item", role: "unhide" },
89+
{ type: "separator" },
90+
{ type: "item", role: "quit" },
91+
],
92+
},
93+
{
94+
id: "file",
95+
label: "File",
96+
items: [
97+
{
98+
type: "item",
99+
label: "New Session",
100+
command: "session.new",
101+
accelerator: { macos: "Shift+Cmd+S" },
102+
},
103+
{ type: "item", label: "Open Project...", command: "project.open", accelerator: { macos: "Cmd+O" } },
104+
{
105+
type: "item",
106+
label: "Settings",
107+
command: "settings.open",
108+
accelerator: { windows: "Ctrl+," },
109+
platforms: ["windows"],
110+
},
111+
{
112+
type: "item",
113+
label: "New Window",
114+
action: "window.new",
115+
accelerator: { macos: "Cmd+Shift+N", windows: "Ctrl+Shift+N" },
116+
},
117+
{ type: "separator" },
118+
{ type: "item", label: "Close Window", action: "window.close", role: "close" },
119+
],
120+
},
121+
{
122+
id: "edit",
123+
label: "Edit",
124+
items: [
125+
{ type: "item", label: "Undo", action: "edit.undo", role: "undo", accelerator: { windows: "Ctrl+Z" } },
126+
{ type: "item", label: "Redo", action: "edit.redo", role: "redo", accelerator: { windows: "Ctrl+Y" } },
127+
{ type: "separator" },
128+
{ type: "item", label: "Cut", action: "edit.cut", role: "cut", accelerator: { windows: "Ctrl+X" } },
129+
{ type: "item", label: "Copy", action: "edit.copy", role: "copy", accelerator: { windows: "Ctrl+C" } },
130+
{ type: "item", label: "Paste", action: "edit.paste", role: "paste", accelerator: { windows: "Ctrl+V" } },
131+
{ type: "item", label: "Delete", action: "edit.delete" },
132+
{
133+
type: "item",
134+
label: "Select All",
135+
action: "edit.selectAll",
136+
role: "selectAll",
137+
accelerator: { windows: "Ctrl+A" },
138+
},
139+
],
140+
},
141+
{
142+
id: "view",
143+
label: "View",
144+
items: [
145+
{ type: "item", label: "Toggle Sidebar", command: "sidebar.toggle", accelerator: { macos: "Cmd+B" } },
146+
{ type: "item", label: "Toggle Terminal", command: "terminal.toggle", accelerator: { macos: "Ctrl+`" } },
147+
{ type: "item", label: "Toggle File Tree", command: "fileTree.toggle" },
148+
{ type: "separator" },
149+
{ type: "item", label: "Reload", action: "view.reload", role: "reload" },
150+
{ type: "item", label: "Toggle Developer Tools", action: "view.toggleDevTools", role: "toggleDevTools" },
151+
{ type: "separator" },
152+
{
153+
type: "item",
154+
label: "Actual Size",
155+
action: "view.resetZoom",
156+
role: "resetZoom",
157+
accelerator: { windows: "Ctrl+0" },
158+
},
159+
{ type: "item", label: "Zoom In", action: "view.zoomIn", role: "zoomIn", accelerator: { windows: "Ctrl++" } },
160+
{ type: "item", label: "Zoom Out", action: "view.zoomOut", role: "zoomOut", accelerator: { windows: "Ctrl+-" } },
161+
{ type: "separator" },
162+
{ type: "item", label: "Toggle Full Screen", action: "view.toggleFullscreen", role: "togglefullscreen" },
163+
],
164+
},
165+
{
166+
id: "go",
167+
label: "Go",
168+
items: [
169+
{ type: "item", label: "Back", command: "common.goBack", accelerator: { macos: "Cmd+[" } },
170+
{ type: "item", label: "Forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } },
171+
{ type: "separator" },
172+
{ type: "item", label: "Previous Session", command: "session.previous", accelerator: { macos: "Option+Up" } },
173+
{ type: "item", label: "Next Session", command: "session.next", accelerator: { macos: "Option+Down" } },
174+
{ type: "separator" },
175+
{
176+
type: "item",
177+
label: "Previous Project",
178+
command: "project.previous",
179+
accelerator: { macos: "Cmd+Option+Up" },
180+
},
181+
{
182+
type: "item",
183+
label: "Next Project",
184+
command: "project.next",
185+
accelerator: { macos: "Cmd+Option+Down" },
186+
},
187+
],
188+
},
189+
{
190+
id: "window",
191+
label: "Window",
192+
role: "windowMenu",
193+
items: [
194+
{ type: "item", label: "Minimize", action: "window.minimize" },
195+
{ type: "item", label: "Maximize", action: "window.toggleMaximize" },
196+
{ type: "separator" },
197+
{ type: "item", label: "Close Window", action: "window.close" },
198+
],
199+
},
200+
{
201+
id: "help",
202+
label: "Help",
203+
items: [
204+
{ type: "item", label: "OpenCode Documentation", href: "https://opencode.ai/docs" },
205+
{ type: "item", label: "Support Forum", href: "https://discord.com/invite/opencode" },
206+
{ type: "item", label: "Export Logs...", action: "app.exportLogs" },
207+
{ type: "separator" },
208+
{ type: "item", label: "Share Feedback", href: "https://github.com/anomalyco/opencode/issues/new?template=feature_request.yml" },
209+
{ type: "item", label: "Report a Bug", href: "https://github.com/anomalyco/opencode/issues/new?template=bug_report.yml" },
210+
],
211+
},
212+
]
213+
214+
export function desktopMenuVisible(item: { platforms?: DesktopMenuPlatform[] }, platform: DesktopMenuPlatform) {
215+
return !item.platforms || item.platforms.includes(platform)
216+
}

0 commit comments

Comments
 (0)