Add Windows desktop app menu (#28420)
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
|
"./desktop-menu": "./src/desktop-menu.ts",
|
||||||
"./vite": "./vite.js",
|
"./vite": "./vite.js",
|
||||||
"./index.css": "./src/index.css"
|
"./index.css": "./src/index.css"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { usePlatform } from "@/context/platform"
|
|||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
|
import { WindowsAppMenu } from "./windows-app-menu"
|
||||||
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
||||||
|
|
||||||
type TauriDesktopWindow = {
|
type TauriDesktopWindow = {
|
||||||
@@ -191,6 +192,9 @@ export function Titlebar() {
|
|||||||
"pl-2": !mac(),
|
"pl-2": !mac(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Show when={windows()}>
|
||||||
|
<WindowsAppMenu command={command} platform={platform} />
|
||||||
|
</Show>
|
||||||
<Show when={mac()}>
|
<Show when={mac()}>
|
||||||
<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />
|
<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />
|
||||||
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
|
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
|
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
|
||||||
import type { Accessor } from "solid-js"
|
import type { Accessor } from "solid-js"
|
||||||
|
import type { DesktopMenuAction } from "../desktop-menu"
|
||||||
import { ServerConnection } from "./server"
|
import { ServerConnection } from "./server"
|
||||||
|
|
||||||
type PickerPaths = string | string[] | null
|
type PickerPaths = string | string[] | null
|
||||||
@@ -82,6 +83,9 @@ export type Platform = {
|
|||||||
/** Webview zoom level (desktop only) */
|
/** Webview zoom level (desktop only) */
|
||||||
webviewZoom?: Accessor<number>
|
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) */
|
/** Check if an editor app exists (desktop only) */
|
||||||
checkAppExists?(appName: string): Promise<boolean>
|
checkAppExists?(appName: string): Promise<boolean>
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -53,6 +53,63 @@
|
|||||||
container-name: getting-started;
|
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"] {
|
[data-component="getting-started-actions"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -345,11 +345,13 @@ const main = Effect.gen(function* () {
|
|||||||
mainWindow = createMainWindow()
|
mainWindow = createMainWindow()
|
||||||
if (mainWindow) {
|
if (mainWindow) {
|
||||||
createMenu({
|
createMenu({
|
||||||
trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id),
|
trigger: (id) => {
|
||||||
|
const win = BrowserWindow.getFocusedWindow() ?? mainWindow
|
||||||
|
if (win) sendMenuCommand(win, id)
|
||||||
|
},
|
||||||
checkForUpdates: () => {
|
checkForUpdates: () => {
|
||||||
void checkForUpdates(true, killSidecar)
|
void checkForUpdates(true, killSidecar)
|
||||||
},
|
},
|
||||||
reload: () => mainWindow?.reload(),
|
|
||||||
relaunch: () => {
|
relaunch: () => {
|
||||||
void killSidecar().finally(() => {
|
void killSidecar().finally(() => {
|
||||||
app.relaunch()
|
app.relaunch()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { execFile } from "node:child_process"
|
import { execFile } from "node:child_process"
|
||||||
import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } from "electron"
|
import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } from "electron"
|
||||||
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
|
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
|
||||||
|
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
InitStep,
|
InitStep,
|
||||||
@@ -10,6 +11,7 @@ import type {
|
|||||||
WindowConfig,
|
WindowConfig,
|
||||||
WslConfig,
|
WslConfig,
|
||||||
} from "../preload/types"
|
} from "../preload/types"
|
||||||
|
import { runDesktopMenuAction } from "./desktop-menu-actions"
|
||||||
import { getStore } from "./store"
|
import { getStore } from "./store"
|
||||||
import { setTitlebar, updateTitlebar } from "./windows"
|
import { setTitlebar, updateTitlebar } from "./windows"
|
||||||
|
|
||||||
@@ -198,6 +200,9 @@ export function registerIpcHandlers(deps: Deps) {
|
|||||||
if (!win) return
|
if (!win) return
|
||||||
setTitlebar(win, theme)
|
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) {
|
export function sendSqliteMigrationProgress(win: BrowserWindow, progress: SqliteMigrationProgress) {
|
||||||
|
|||||||
@@ -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 { UPDATER_ENABLED } from "./constants"
|
||||||
import { createMainWindow } from "./windows"
|
import { runDesktopMenuAction } from "./desktop-menu-actions"
|
||||||
|
|
||||||
type Deps = {
|
type Deps = {
|
||||||
trigger: (id: string) => void
|
trigger: (id: string) => void
|
||||||
checkForUpdates: () => void
|
checkForUpdates: () => void
|
||||||
reload: () => void
|
|
||||||
relaunch: () => void
|
relaunch: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMenu(deps: Deps) {
|
export function createMenu(deps: Deps) {
|
||||||
if (process.platform !== "darwin") return
|
if (process.platform !== "darwin") return
|
||||||
|
|
||||||
const template: Electron.MenuItemConstructorOptions[] = [
|
const template = DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "macos")).map((menu) => {
|
||||||
{
|
if (menu.role) return { role: nativeRole(menu.role) }
|
||||||
label: "OpenCode",
|
return {
|
||||||
submenu: [
|
label: menu.label,
|
||||||
{ role: "about" },
|
submenu: menu.items?.filter((entry) => desktopMenuVisible(entry, "macos")).map((entry) => nativeItem(entry, deps)),
|
||||||
{
|
}
|
||||||
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"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
|
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"]>
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ const api: ElectronAPI = {
|
|||||||
getZoomFactor: () => ipcRenderer.invoke("get-zoom-factor"),
|
getZoomFactor: () => ipcRenderer.invoke("get-zoom-factor"),
|
||||||
setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor),
|
setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor),
|
||||||
setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme),
|
setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme),
|
||||||
|
runDesktopMenuAction: (action) => ipcRenderer.invoke("run-desktop-menu-action", action),
|
||||||
loadingWindowComplete: () => ipcRenderer.send("loading-window-complete"),
|
loadingWindowComplete: () => ipcRenderer.send("loading-window-complete"),
|
||||||
runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail),
|
runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail),
|
||||||
checkUpdate: () => ipcRenderer.invoke("check-update"),
|
checkUpdate: () => ipcRenderer.invoke("check-update"),
|
||||||
|
|||||||
@@ -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 InitStep = { phase: "server_waiting" } | { phase: "sqlite_waiting" } | { phase: "done" }
|
||||||
|
|
||||||
export type ServerReadyData = {
|
export type ServerReadyData = {
|
||||||
@@ -14,7 +16,6 @@ export type LinuxDisplayBackend = "wayland" | "auto"
|
|||||||
export type TitlebarTheme = {
|
export type TitlebarTheme = {
|
||||||
mode: "light" | "dark"
|
mode: "light" | "dark"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WindowConfig = {
|
export type WindowConfig = {
|
||||||
updaterEnabled: boolean
|
updaterEnabled: boolean
|
||||||
}
|
}
|
||||||
@@ -71,6 +72,7 @@ export type ElectronAPI = {
|
|||||||
getZoomFactor: () => Promise<number>
|
getZoomFactor: () => Promise<number>
|
||||||
setZoomFactor: (factor: number) => Promise<void>
|
setZoomFactor: (factor: number) => Promise<void>
|
||||||
setTitlebar: (theme: TitlebarTheme) => Promise<void>
|
setTitlebar: (theme: TitlebarTheme) => Promise<void>
|
||||||
|
runDesktopMenuAction: (action: DesktopMenuAction) => Promise<void>
|
||||||
loadingWindowComplete: () => void
|
loadingWindowComplete: () => void
|
||||||
runUpdater: (alertOnFail: boolean) => Promise<void>
|
runUpdater: (alertOnFail: boolean) => Promise<void>
|
||||||
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
|
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js
|
|||||||
import { render } from "solid-js/web"
|
import { render } from "solid-js/web"
|
||||||
import pkg from "../../package.json"
|
import pkg from "../../package.json"
|
||||||
import { initI18n, t } from "./i18n"
|
import { initI18n, t } from "./i18n"
|
||||||
import { webviewZoom } from "./webview-zoom"
|
import { resetZoom, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
|
||||||
import "./styles.css"
|
import "./styles.css"
|
||||||
import { useTheme } from "@opencode-ai/ui/theme"
|
import { useTheme } from "@opencode-ai/ui/theme"
|
||||||
|
|
||||||
@@ -100,6 +100,22 @@ const createPlatform = (): Platform => {
|
|||||||
return window.api.wslPath(result, "linux").catch(() => result) as any
|
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 storage = (() => {
|
||||||
const cache = new Map<string, AsyncStorage>()
|
const cache = new Map<string, AsyncStorage>()
|
||||||
|
|
||||||
@@ -254,6 +270,8 @@ const createPlatform = (): Platform => {
|
|||||||
|
|
||||||
webviewZoom,
|
webviewZoom,
|
||||||
|
|
||||||
|
runDesktopMenuAction,
|
||||||
|
|
||||||
checkAppExists: async (appName: string) => {
|
checkAppExists: async (appName: string) => {
|
||||||
return window.api.checkAppExists(appName)
|
return window.api.checkAppExists(appName)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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) => {
|
window.addEventListener("keydown", (event) => {
|
||||||
if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return
|
if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return
|
||||||
|
|
||||||
if (event.key === "-") {
|
if (event.key === "-") {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
applyZoom(clamp(requestedZoom - 0.2))
|
zoomOut()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.key === "=" || event.key === "+") {
|
if (event.key === "=" || event.key === "+") {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
applyZoom(clamp(requestedZoom + 0.2))
|
zoomIn()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.key === "0") {
|
if (event.key === "0") {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
applyZoom(1)
|
resetZoom()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export { webviewZoom }
|
export { webviewZoom, resetZoom, zoomIn, zoomOut }
|
||||||
|
|||||||
Reference in New Issue
Block a user