Skip to content

Commit f47fe64

Browse files
quickbeardQuickbeardclaude
authored
feat(app): translate the desktop menu (#322)
* feat(app): translate the desktop menu The app menu was the last English-only surface in a translated UI: its labels were literals in DESKTOP_MENU, which is shared by the macOS native menu bar (built in the desktop main process) and the Windows/Linux titlebar menu (built in the renderer). Labels are now dictionary keys, typed against the English dict so a missing key fails typecheck. windows-app-menu.tsx resolves them through useLanguage().t. The macOS bar resolves them in main/menu-i18n.ts, which dynamic-imports the selected locale's dict and reads the locale from the same codev.global.dat record the renderer's i18n reads — so Windows and Linux, which never build a native menu, load no dictionary at all. createMenu is async as a result, and ipc.ts's store-set handler calls refreshMenuLanguage() so a language change rebuilds the bar rather than leaving it a launch behind. 45 menu.* keys added to en/es/vi, following .codev/glossary/vi.md — the Vietnamese keeps session, terminal, webview and developer tools in English. A parity test asserts every labelKey resolves in all three locales, and pickMenuLocale is unit-tested against the record shape the app actually persists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: point the changelog entry at the real PR number --------- Co-authored-by: Quickbeard <quickbeard@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d0585f6 commit f47fe64

15 files changed

Lines changed: 369 additions & 67 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@ file is the human-readable summary of what changed and why.
1919
session, prompt, token, commit, branch, and agent stay in English (the
2020
glossary lives in `.codev/glossary/vi.md`).
2121
([#307](https://github.com/quickbeard/codev-code/pull/307))
22+
- **The app menu is translated too** — File, Edit, View, Go, Window and Help
23+
and everything inside them stayed English no matter which language the rest
24+
of the app was in: the menu was built from fixed English text rather than
25+
from the dictionaries. It now follows your language like every other
26+
surface, on the macOS menu bar and the Windows/Linux titlebar menu alike,
27+
and switching language updates it without a restart. Developer terms stay in
28+
English where Vietnamese developers expect them — session, terminal,
29+
webview, developer tools.
30+
([#322](https://github.com/quickbeard/codev-code/pull/322))
2231

2332
### Privacy
2433

‎DIVERGENCES.md‎

Lines changed: 4 additions & 3 deletions
Large diffs are not rendered by default.

‎packages/app/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"exports": {
77
".": "./src/index.ts",
88
"./desktop-menu": "./src/desktop-menu.ts",
9+
"./i18n/*": "./src/i18n/*.ts",
910
"./updater": "./src/updater.ts",
1011
"./wsl/types": "./src/wsl/types.ts",
1112
"./sandbox/types": "./src/sandbox/types.ts",

‎packages/app/src/components/windows-app-menu.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
66
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
77

88
import { useCommand } from "@/context/command"
9+
import { useLanguage } from "@/context/language"
910
import {
1011
DESKTOP_MENU,
1112
desktopMenuItemDisabled,
@@ -20,6 +21,7 @@ export function WindowsAppMenu(props: {
2021
platform: ReturnType<typeof usePlatform>
2122
variant?: "legacy" | "v2"
2223
}) {
24+
const language = useLanguage()
2325
let lastFocused: HTMLElement | undefined
2426

2527
const rememberFocus = () => {
@@ -85,15 +87,15 @@ export function WindowsAppMenu(props: {
8587
<DropdownMenu.Group>
8688
<DropdownMenu.GroupLabel class="desktop-app-menu-heading">CoDev</DropdownMenu.GroupLabel>
8789
{DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => (
88-
<DesktopMenuSubmenu label={menu.label}>
90+
<DesktopMenuSubmenu label={language.t(menu.labelKey)}>
8991
{menu.items
9092
?.filter((entry) => desktopMenuVisible(entry, "windows"))
9193
.map((entry) =>
9294
entry.type === "separator" ? (
9395
<DropdownMenu.Separator />
9496
) : (
9597
<DesktopMenuItem
96-
label={entry.label ?? ""}
98+
label={entry.labelKey ? language.t(entry.labelKey) : ""}
9799
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
98100
disabled={desktopMenuItemDisabled(entry, {
99101
updaterStatus: props.platform.updater?.state().status,

‎packages/app/src/desktop-menu.test.ts‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,31 @@ import { DESKTOP_MENU, desktopMenuItemDisabled, desktopMenuVisible, type Desktop
44
describe("desktop menu", () => {
55
test("exports logs through the desktop command registry", () => {
66
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
7-
(item) => item.type === "item" && item.label === "Export Logs...",
7+
(item) => item.type === "item" && item.labelKey === "menu.exportLogs",
88
)
99

1010
expect(items).toHaveLength(2)
1111
expect(items.every((item) => item.type === "item" && item.command === "logs.export" && !item.action)).toBe(true)
1212
})
1313

14+
// The menu is the one surface whose labels are resolved twice — by the
15+
// renderer for the Windows/Linux titlebar menu and by the desktop main
16+
// process for the macOS menu bar — and neither can fall back to English text
17+
// that is no longer in the table. A key missing from a locale renders that
18+
// item blank there.
19+
test("translates every menu label in every locale", async () => {
20+
const keys = DESKTOP_MENU.flatMap((menu) => [
21+
menu.labelKey,
22+
...(menu.items ?? []).flatMap((item) => (item.type === "item" && item.labelKey ? [item.labelKey] : [])),
23+
])
24+
25+
expect(keys.length).toBeGreaterThan(0)
26+
for (const locale of ["en", "es", "vi"]) {
27+
const { dict } = (await import(`./i18n/${locale}`)) as { dict: Record<string, string> }
28+
expect({ locale, missing: keys.filter((key) => !dict[key]) }).toEqual({ locale, missing: [] })
29+
}
30+
})
31+
1432
test("offers an update check on every platform exactly once", () => {
1533
for (const platform of ["macos", "windows"] as const) {
1634
const items = DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, platform)).flatMap((menu) =>
@@ -45,7 +63,7 @@ describe("desktop menu", () => {
4563
}
4664

4765
test("defers to the command registry for command entries", () => {
48-
const entry = { type: "item", label: "Export Logs...", command: "logs.export" } as const
66+
const entry = { type: "item", labelKey: "menu.exportLogs", command: "logs.export" } as const
4967

5068
expect(desktopMenuItemDisabled(entry, { commandDisabled: (id) => id === "logs.export" })).toBe(true)
5169
expect(desktopMenuItemDisabled(entry, { commandDisabled: never })).toBe(false)
@@ -54,7 +72,7 @@ describe("desktop menu", () => {
5472
test("leaves link and role entries alone", () => {
5573
expect(
5674
desktopMenuItemDisabled(
57-
{ type: "item", label: "Report a Bug", href: "https://example.test" },
75+
{ type: "item", labelKey: "menu.reportBug", href: "https://example.test" },
5876
{
5977
commandDisabled: never,
6078
},

‎packages/app/src/desktop-menu.ts‎

Lines changed: 77 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { DOCS_URL } from "@codev/brand"
2+
import type { dict } from "./i18n/en"
23
import type { UpdaterState } from "./updater"
34

5+
// Menu labels are dictionary keys, not text: this table is read by the macOS
6+
// native menu in the desktop main process and by the Windows/Linux titlebar
7+
// menu in the renderer, and both resolve it against the app dictionaries. A
8+
// literal here is a label that never translates.
9+
export type DesktopMenuLabelKey = keyof typeof dict
10+
411
export type DesktopMenuPlatform = "macos" | "windows"
512

613
export type DesktopMenuAction =
@@ -48,7 +55,7 @@ export type DesktopMenuRole =
4855

4956
export type DesktopMenuItem = {
5057
type: "item"
51-
label?: string
58+
labelKey?: DesktopMenuLabelKey
5259
command?: string
5360
action?: DesktopMenuAction
5461
role?: DesktopMenuRole
@@ -67,7 +74,7 @@ export type DesktopMenuEntry = DesktopMenuItem | DesktopMenuSeparator
6774

6875
export type DesktopMenu = {
6976
id: string
70-
label: string
77+
labelKey: DesktopMenuLabelKey
7178
role?: DesktopMenuRole
7279
items?: DesktopMenuEntry[]
7380
platforms?: DesktopMenuPlatform[]
@@ -76,17 +83,17 @@ export type DesktopMenu = {
7683
export const DESKTOP_MENU: DesktopMenu[] = [
7784
{
7885
id: "app",
79-
label: "CoDev",
86+
labelKey: "menu.app",
8087
platforms: ["macos"],
8188
items: [
8289
{ type: "item", role: "about" },
83-
{ type: "item", label: "Check for Updates...", action: "app.checkForUpdates", enabled: "updater" },
84-
{ type: "item", label: "Settings", command: "settings.open", accelerator: { macos: "Cmd+," } },
85-
{ type: "item", label: "Log in/Configure", command: "gateway.apiKey.new" },
86-
{ type: "item", label: "Upload Session Logs", action: "upload.sessions" },
87-
{ type: "item", label: "Reload Webview", action: "view.reload" },
88-
{ type: "item", label: "Restart", action: "app.relaunch" },
89-
{ type: "item", label: "Export Logs...", command: "logs.export" },
90+
{ type: "item", labelKey: "menu.checkForUpdates", action: "app.checkForUpdates", enabled: "updater" },
91+
{ type: "item", labelKey: "menu.settings", command: "settings.open", accelerator: { macos: "Cmd+," } },
92+
{ type: "item", labelKey: "menu.login", command: "gateway.apiKey.new" },
93+
{ type: "item", labelKey: "menu.uploadSessionLogs", action: "upload.sessions" },
94+
{ type: "item", labelKey: "menu.reloadWebview", action: "view.reload" },
95+
{ type: "item", labelKey: "menu.restart", action: "app.relaunch" },
96+
{ type: "item", labelKey: "menu.exportLogs", command: "logs.export" },
9097
{ type: "separator" },
9198
{ type: "item", role: "hide" },
9299
{ type: "item", role: "hideOthers" },
@@ -97,18 +104,18 @@ export const DESKTOP_MENU: DesktopMenu[] = [
97104
},
98105
{
99106
id: "file",
100-
label: "File",
107+
labelKey: "menu.file",
101108
items: [
102109
{
103110
type: "item",
104-
label: "New Session",
111+
labelKey: "menu.newSession",
105112
command: "session.new",
106113
accelerator: { macos: "Shift+Cmd+S" },
107114
},
108-
{ type: "item", label: "Open Project...", command: "project.open", accelerator: { macos: "Cmd+O" } },
115+
{ type: "item", labelKey: "menu.openProject", command: "project.open", accelerator: { macos: "Cmd+O" } },
109116
{
110117
type: "item",
111-
label: "Settings",
118+
labelKey: "menu.settings",
112119
command: "settings.open",
113120
accelerator: { windows: "Ctrl+," },
114121
platforms: ["windows"],
@@ -117,41 +124,41 @@ export const DESKTOP_MENU: DesktopMenu[] = [
117124
// Windows/Linux menu does not render — same split Settings itself uses.
118125
{
119126
type: "item",
120-
label: "Log in/Configure",
127+
labelKey: "menu.login",
121128
command: "gateway.apiKey.new",
122129
platforms: ["windows"],
123130
},
124131
// macOS carries this in the app menu below its sign-in sibling.
125132
{
126133
type: "item",
127-
label: "Upload Session Logs",
134+
labelKey: "menu.uploadSessionLogs",
128135
action: "upload.sessions",
129136
platforms: ["windows"],
130137
},
131138
{
132139
type: "item",
133-
label: "New Window",
140+
labelKey: "menu.newWindow",
134141
action: "window.new",
135142
accelerator: { macos: "Cmd+Shift+N", windows: "Ctrl+Shift+N" },
136143
},
137144
{ type: "separator" },
138-
{ type: "item", label: "Close Window", action: "window.close", role: "close" },
145+
{ type: "item", labelKey: "menu.closeWindow", action: "window.close", role: "close" },
139146
],
140147
},
141148
{
142149
id: "edit",
143-
label: "Edit",
150+
labelKey: "menu.edit",
144151
items: [
145-
{ type: "item", label: "Undo", action: "edit.undo", role: "undo", accelerator: { windows: "Ctrl+Z" } },
146-
{ type: "item", label: "Redo", action: "edit.redo", role: "redo", accelerator: { windows: "Ctrl+Y" } },
152+
{ type: "item", labelKey: "menu.undo", action: "edit.undo", role: "undo", accelerator: { windows: "Ctrl+Z" } },
153+
{ type: "item", labelKey: "menu.redo", action: "edit.redo", role: "redo", accelerator: { windows: "Ctrl+Y" } },
147154
{ type: "separator" },
148-
{ type: "item", label: "Cut", action: "edit.cut", role: "cut", accelerator: { windows: "Ctrl+X" } },
149-
{ type: "item", label: "Copy", action: "edit.copy", role: "copy", accelerator: { windows: "Ctrl+C" } },
150-
{ type: "item", label: "Paste", action: "edit.paste", role: "paste", accelerator: { windows: "Ctrl+V" } },
151-
{ type: "item", label: "Delete", action: "edit.delete" },
155+
{ type: "item", labelKey: "menu.cut", action: "edit.cut", role: "cut", accelerator: { windows: "Ctrl+X" } },
156+
{ type: "item", labelKey: "menu.copy", action: "edit.copy", role: "copy", accelerator: { windows: "Ctrl+C" } },
157+
{ type: "item", labelKey: "menu.paste", action: "edit.paste", role: "paste", accelerator: { windows: "Ctrl+V" } },
158+
{ type: "item", labelKey: "menu.delete", action: "edit.delete" },
152159
{
153160
type: "item",
154-
label: "Select All",
161+
labelKey: "menu.selectAll",
155162
action: "edit.selectAll",
156163
role: "selectAll",
157164
accelerator: { windows: "Ctrl+A" },
@@ -160,78 +167,95 @@ export const DESKTOP_MENU: DesktopMenu[] = [
160167
},
161168
{
162169
id: "view",
163-
label: "View",
170+
labelKey: "menu.view",
164171
items: [
165-
{ type: "item", label: "Toggle Sidebar", command: "sidebar.toggle" },
166-
{ type: "item", label: "Toggle Terminal", command: "terminal.toggle", accelerator: { macos: "Ctrl+`" } },
167-
{ type: "item", label: "Toggle File Tree", command: "fileTree.toggle" },
172+
{ type: "item", labelKey: "menu.toggleSidebar", command: "sidebar.toggle" },
173+
{ type: "item", labelKey: "menu.toggleTerminal", command: "terminal.toggle", accelerator: { macos: "Ctrl+`" } },
174+
{ type: "item", labelKey: "menu.toggleFileTree", command: "fileTree.toggle" },
168175
{ type: "separator" },
169-
{ type: "item", label: "Reload", action: "view.reload", role: "reload" },
170-
{ type: "item", label: "Toggle Developer Tools", action: "view.toggleDevTools", role: "toggleDevTools" },
176+
{ type: "item", labelKey: "menu.reload", action: "view.reload", role: "reload" },
177+
{ type: "item", labelKey: "menu.toggleDevTools", action: "view.toggleDevTools", role: "toggleDevTools" },
171178
{ type: "separator" },
172179
{
173180
type: "item",
174-
label: "Actual Size",
181+
labelKey: "menu.actualSize",
175182
action: "view.resetZoom",
176183
role: "resetZoom",
177184
accelerator: { windows: "Ctrl+0" },
178185
},
179-
{ type: "item", label: "Zoom In", action: "view.zoomIn", role: "zoomIn", accelerator: { windows: "Ctrl++" } },
180-
{ type: "item", label: "Zoom Out", action: "view.zoomOut", role: "zoomOut", accelerator: { windows: "Ctrl+-" } },
186+
{
187+
type: "item",
188+
labelKey: "menu.zoomIn",
189+
action: "view.zoomIn",
190+
role: "zoomIn",
191+
accelerator: { windows: "Ctrl++" },
192+
},
193+
{
194+
type: "item",
195+
labelKey: "menu.zoomOut",
196+
action: "view.zoomOut",
197+
role: "zoomOut",
198+
accelerator: { windows: "Ctrl+-" },
199+
},
181200
{ type: "separator" },
182-
{ type: "item", label: "Toggle Full Screen", action: "view.toggleFullscreen", role: "togglefullscreen" },
201+
{ type: "item", labelKey: "menu.toggleFullScreen", action: "view.toggleFullscreen", role: "togglefullscreen" },
183202
],
184203
},
185204
{
186205
id: "go",
187-
label: "Go",
206+
labelKey: "menu.go",
188207
items: [
189-
{ type: "item", label: "Back", command: "common.goBack", accelerator: { macos: "Cmd+[" } },
190-
{ type: "item", label: "Forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } },
208+
{ type: "item", labelKey: "menu.back", command: "common.goBack", accelerator: { macos: "Cmd+[" } },
209+
{ type: "item", labelKey: "menu.forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } },
191210
{ type: "separator" },
192-
{ type: "item", label: "Previous Session", command: "session.previous", accelerator: { macos: "Option+Up" } },
193-
{ type: "item", label: "Next Session", command: "session.next", accelerator: { macos: "Option+Down" } },
211+
{
212+
type: "item",
213+
labelKey: "menu.previousSession",
214+
command: "session.previous",
215+
accelerator: { macos: "Option+Up" },
216+
},
217+
{ type: "item", labelKey: "menu.nextSession", command: "session.next", accelerator: { macos: "Option+Down" } },
194218
{ type: "separator" },
195219
{
196220
type: "item",
197-
label: "Previous Project",
221+
labelKey: "menu.previousProject",
198222
command: "project.previous",
199223
accelerator: { macos: "Cmd+Option+Up" },
200224
},
201225
{
202226
type: "item",
203-
label: "Next Project",
227+
labelKey: "menu.nextProject",
204228
command: "project.next",
205229
accelerator: { macos: "Cmd+Option+Down" },
206230
},
207231
],
208232
},
209233
{
210234
id: "window",
211-
label: "Window",
235+
labelKey: "menu.window",
212236
role: "windowMenu",
213237
items: [
214-
{ type: "item", label: "Minimize", action: "window.minimize" },
215-
{ type: "item", label: "Maximize", action: "window.toggleMaximize" },
238+
{ type: "item", labelKey: "menu.minimize", action: "window.minimize" },
239+
{ type: "item", labelKey: "menu.maximize", action: "window.toggleMaximize" },
216240
{ type: "separator" },
217-
{ type: "item", label: "Close Window", action: "window.close" },
241+
{ type: "item", labelKey: "menu.closeWindow", action: "window.close" },
218242
],
219243
},
220244
{
221245
id: "help",
222-
label: "Help",
246+
labelKey: "menu.help",
223247
items: [
224-
{ type: "item", label: "CoDev Documentation", href: DOCS_URL },
225-
{ type: "item", label: "Export Logs...", command: "logs.export" },
248+
{ type: "item", labelKey: "menu.documentation", href: DOCS_URL },
249+
{ type: "item", labelKey: "menu.exportLogs", command: "logs.export" },
226250
{ type: "separator" },
227251
{
228252
type: "item",
229-
label: "Share Feedback",
253+
labelKey: "menu.shareFeedback",
230254
href: "https://github.com/quickbeard/codev-code/issues/new?template=feature-request.yml",
231255
},
232256
{
233257
type: "item",
234-
label: "Report a Bug",
258+
labelKey: "menu.reportBug",
235259
href: "https://github.com/quickbeard/codev-code/issues/new?template=bug-report.yml",
236260
},
237261
// macOS has this in the app menu, which the Windows/Linux menu does not
@@ -240,7 +264,7 @@ export const DESKTOP_MENU: DesktopMenu[] = [
240264
{ type: "separator", platforms: ["windows"] },
241265
{
242266
type: "item",
243-
label: "Check for Updates...",
267+
labelKey: "menu.checkForUpdates",
244268
action: "app.checkForUpdates",
245269
enabled: "updater",
246270
platforms: ["windows"],

0 commit comments

Comments
 (0)