feat(i18n): add Simplified Chinese (zh-CN) translation (#8765)

Signed-off-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: antai <antai12232931@anaiguo.com>
Co-authored-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Lifei Zhou <lifei@squareup.com>
This commit is contained in:
Tai An
2026-05-12 18:34:08 -07:00
committed by GitHub
parent 12604a8838
commit 8c36ba86c6
10 changed files with 5011 additions and 56 deletions
+2 -2
View File
@@ -1381,7 +1381,7 @@ export default function ChatInput({
data-testid="chat-input"
autoFocus
id="dynamic-textarea"
placeholder={isRecording ? '' : getNavigationShortcutText()}
placeholder={isRecording ? '' : getNavigationShortcutText(intl)}
value={displayValue}
onChange={handleChange}
onCompositionStart={handleCompositionStart}
@@ -1514,7 +1514,7 @@ export default function ChatInput({
}`}
>
<Send className="w-4 h-4" />
<span className="text-sm">Send</span>
<span className="text-sm">{intl.formatMessage(i18n.send)}</span>
</Button>
</span>
</TooltipTrigger>
@@ -7,6 +7,7 @@ import { DropdownMenu, DropdownMenuTrigger } from '../ui/dropdown-menu';
import { ChatSessionsDropdown, SessionsList } from './navigation';
import { ChatHistorySearch } from '../conversation/ChatHistorySearch';
import type { NavigationRendererProps } from './navigation/types';
import { getNavItemLabel } from '../../hooks/useNavigationItems';
const i18n = defineMessages({
newChat: {
@@ -176,7 +177,7 @@ export const CondensedRenderer: React.FC<NavigationRendererProps> = ({
</div>
<Icon className="w-5 h-5 flex-shrink-0" />
<span className="text-sm font-medium text-left flex-1">
{item.label}
{getNavItemLabel(item, intl)}
</span>
<div className="flex-shrink-0">
{isChatExpanded ? (
@@ -230,7 +231,7 @@ export const CondensedRenderer: React.FC<NavigationRendererProps> = ({
<Icon className="w-5 h-5 flex-shrink-0" />
{!isCondensedIconOnly && (
<span className="text-sm font-medium text-left flex-1">
{item.label}
{getNavItemLabel(item, intl)}
</span>
)}
{!isCondensedIconOnly && item.getTag && (
@@ -320,7 +321,7 @@ export const CondensedRenderer: React.FC<NavigationRendererProps> = ({
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span className="text-sm font-medium text-left hidden min-[1200px]:block">
{item.label}
{getNavItemLabel(item, intl)}
</span>
</motion.button>
</DropdownMenuTrigger>
@@ -350,7 +351,7 @@ export const CondensedRenderer: React.FC<NavigationRendererProps> = ({
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span className="text-sm font-medium text-left hidden min-[1200px]:block">
{item.label}
{getNavItemLabel(item, intl)}
</span>
</motion.button>
)}
@@ -7,6 +7,8 @@ import { DropdownMenu, DropdownMenuTrigger } from '../ui/dropdown-menu';
import { ChatSessionsDropdown } from './navigation';
import { ChatHistorySearch } from '../conversation/ChatHistorySearch';
import type { NavigationRendererProps } from './navigation/types';
import { useIntl } from '../../i18n';
import { getNavItemLabel } from '../../hooks/useNavigationItems';
export const ExpandedRenderer: React.FC<NavigationRendererProps> = ({
isNavExpanded,
@@ -26,6 +28,7 @@ export const ExpandedRenderer: React.FC<NavigationRendererProps> = ({
drag,
navFocusRef,
}) => {
const intl = useIntl();
const [chatDropdownOpen, setChatDropdownOpen] = useState(false);
const [gridColumns, setGridColumns] = useState(2);
const [gridMeasured, setGridMeasured] = useState(false);
@@ -213,7 +216,7 @@ export const ExpandedRenderer: React.FC<NavigationRendererProps> = ({
)}
<div className="mt-auto w-full">
<Icon className="w-6 h-6 mb-2" />
<h2 className="font-light text-left text-xl">{item.label}</h2>
<h2 className="font-light text-left text-xl">{getNavItemLabel(item, intl)}</h2>
</div>
</div>
</motion.div>
@@ -282,7 +285,7 @@ export const ExpandedRenderer: React.FC<NavigationRendererProps> = ({
)}
<div className="mt-auto w-full">
<Icon className="w-6 h-6 mb-2" />
<h2 className="font-light text-left text-xl">{item.label}</h2>
<h2 className="font-light text-left text-xl">{getNavItemLabel(item, intl)}</h2>
</div>
</button>
</motion.div>
@@ -9,6 +9,7 @@ import {
Zap,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { defineMessages, type IntlShape, type MessageDescriptor } from 'react-intl';
export interface NavItem {
id: string;
@@ -31,6 +32,52 @@ export const NAV_ITEMS: NavItem[] = [
{ id: 'settings', path: '/settings', label: 'Settings', icon: Settings },
];
// Translation descriptors for nav labels. Kept here next to NAV_ITEMS so the two
// stay in sync. Reuses the existing `navigationCustomization.item*` ids that are
// also used by the "Customize Navigation" settings screen.
const navItemMessages = defineMessages({
home: {
id: 'navigationCustomization.itemHome',
defaultMessage: 'Home',
},
chat: {
id: 'navigationCustomization.itemChat',
defaultMessage: 'Chat',
},
recipes: {
id: 'navigationCustomization.itemRecipes',
defaultMessage: 'Recipes',
},
skills: {
id: 'navigationCustomization.itemSkills',
defaultMessage: 'Skills',
},
apps: {
id: 'navigationCustomization.itemApps',
defaultMessage: 'Apps',
},
scheduler: {
id: 'navigationCustomization.itemScheduler',
defaultMessage: 'Scheduler',
},
extensions: {
id: 'navigationCustomization.itemExtensions',
defaultMessage: 'Extensions',
},
settings: {
id: 'navigationCustomization.itemSettings',
defaultMessage: 'Settings',
},
});
const NAV_ITEM_MESSAGES: Record<string, MessageDescriptor> = navItemMessages;
/** Format a NavItem's label using the provided intl instance, falling back to `item.label`. */
export function getNavItemLabel(item: NavItem, intl: IntlShape): string {
const descriptor = NAV_ITEM_MESSAGES[item.id];
return descriptor ? intl.formatMessage(descriptor) : item.label;
}
export function getNavItemById(id: string): NavItem | undefined {
return NAV_ITEMS.find((item) => item.id === id);
}
+11 -13
View File
@@ -19,43 +19,43 @@ describe('getLocale', () => {
});
it('returns "en" as the default fallback', () => {
// navigator.language returns something unsupported
vi.stubGlobal('navigator', { language: 'xx-XX' });
// navigator.languages contains only unsupported tags
vi.stubGlobal('navigator', { languages: ['xx-XX'] });
expect(getLocale()).toEqual({ locale: 'en', messageLocale: 'en' });
});
it('preserves regional tag for formatting when base language is supported', () => {
vi.stubGlobal('navigator', { language: 'en-US' });
vi.stubGlobal('navigator', { languages: ['en-US'] });
expect(getLocale()).toEqual({ locale: 'en-US', messageLocale: 'en' });
});
it('returns exact match when navigator.language matches a supported locale', () => {
vi.stubGlobal('navigator', { language: 'en' });
it('returns exact match when navigator.languages contains a supported locale', () => {
vi.stubGlobal('navigator', { languages: ['en'] });
expect(getLocale()).toEqual({ locale: 'en', messageLocale: 'en' });
});
it('respects GOOSE_LOCALE over navigator.language', () => {
it('respects GOOSE_LOCALE over navigator.languages', () => {
mockAppConfig({ GOOSE_LOCALE: 'en' });
vi.stubGlobal('navigator', { language: 'xx-XX' });
vi.stubGlobal('navigator', { languages: ['xx-XX'] });
expect(getLocale()).toEqual({ locale: 'en', messageLocale: 'en' });
});
it('preserves regional tag from GOOSE_LOCALE', () => {
mockAppConfig({ GOOSE_LOCALE: 'en-GB' });
vi.stubGlobal('navigator', { language: 'xx-XX' });
vi.stubGlobal('navigator', { languages: ['xx-XX'] });
expect(getLocale()).toEqual({ locale: 'en-GB', messageLocale: 'en' });
});
it('falls back to base language tag for message catalog', () => {
// "en-GB" should use "en" catalog but keep "en-GB" for formatting
vi.stubGlobal('navigator', { language: 'en-GB' });
vi.stubGlobal('navigator', { languages: ['en-GB'] });
expect(getLocale()).toEqual({ locale: 'en-GB', messageLocale: 'en' });
});
it('falls back to base language when locale tag is invalid BCP 47', () => {
// "en-" is not a valid BCP 47 tag and would cause RangeError in Intl APIs
mockAppConfig({ GOOSE_LOCALE: 'en-' });
vi.stubGlobal('navigator', { language: 'xx-XX' });
vi.stubGlobal('navigator', { languages: ['xx-XX'] });
expect(getLocale()).toEqual({ locale: 'en', messageLocale: 'en' });
});
});
@@ -72,9 +72,7 @@ describe('loadMessages', () => {
const { loadMessages } = await import('./index');
const messages = await loadMessages('xx');
expect(messages).toEqual({});
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('No message catalog found')
);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No message catalog found'));
warnSpy.mockRestore();
});
});
+35 -11
View File
@@ -3,22 +3,37 @@
*
* Locale resolution order:
* 1. GOOSE_LOCALE config value (set via environment variable, passed through appConfig)
* 2. navigator.language (browser/OS locale)
* 2. navigator.languages (full accept-language list from OS/browser)
* 3. "en" (fallback)
*
* For Chinese: any Simplified Chinese tag (zh, zh-CN, zh-Hans, zh-Hans-CN, zh-SG, zh-MY)
* maps to the "zh-CN" catalog. Traditional variants (zh-TW, zh-HK, zh-Hant) are not yet
* translated and fall through to English.
*/
// Re-export react-intl utilities that components use directly
export { defineMessages, useIntl } from 'react-intl';
/** The set of locales that have translation catalogs. */
const SUPPORTED_LOCALES = new Set(['en']);
const SUPPORTED_LOCALES = new Set(['en', 'zh-CN']);
/**
* Map Simplified Chinese aliases (zh, zh-Hans*, zh-SG, zh-MY) to "zh-CN".
* Traditional variants (zh-Hant*, zh-TW, zh-HK, zh-MO) and non-Chinese tags pass through unchanged.
*/
function resolveChineseAlias(tag: string): string {
const lower = tag.toLowerCase();
if (/^zh-(hant|tw|hk|mo)(-|$)/.test(lower)) return tag;
if (lower === 'zh' || lower.startsWith('zh-')) return 'zh-CN';
return tag;
}
/**
* Detect the user's preferred locale.
*
* Returns two values:
* - `locale`: the full BCP 47 tag (e.g. "en-GB") for formatting (dates, numbers).
* - `messageLocale`: the base language that has a translation catalog (e.g. "en").
* - `messageLocale`: the locale key that has a translation catalog (e.g. "en", "zh-CN").
*/
export function getLocale(): { locale: string; messageLocale: string } {
const explicit =
@@ -32,13 +47,22 @@ export function getLocale(): { locale: string; messageLocale: string } {
candidates.push(explicit);
}
if (typeof navigator !== 'undefined' && navigator.language) {
candidates.push(navigator.language);
// Walk navigator.languages (full preference list) so a user whose primary UI
// language isn't supported still gets a supported language from later in their list.
if (typeof navigator !== 'undefined' && Array.isArray(navigator.languages)) {
for (const tag of navigator.languages) {
if (tag) candidates.push(tag);
}
}
for (const tag of candidates) {
for (const rawTag of candidates) {
// Normalize underscores to hyphens so POSIX-style tags like "zh_CN" work.
const normalized = rawTag.replace(/_/g, '-');
const tag = resolveChineseAlias(normalized);
// Exact match first
if (SUPPORTED_LOCALES.has(tag)) return { locale: tag, messageLocale: tag };
// Try base language (e.g. "pt-BR" → "pt") for the catalog, but keep the
// full regional tag for formatting so date/number output respects the region.
const base = tag.split('-')[0];
@@ -48,7 +72,7 @@ export function getLocale(): { locale: string; messageLocale: string } {
// Intl APIs, so fall back to the base language in that case.
let locale = base;
try {
[locale] = Intl.getCanonicalLocales(tag);
[locale] = Intl.getCanonicalLocales(normalized);
} catch {
// tag is not valid BCP 47 — use the base language instead
}
@@ -70,9 +94,7 @@ export const currentMessageLocale = resolvedLocale.messageLocale;
* Load compiled messages for a given locale.
* Returns an empty object for English (react-intl uses defaultMessage as fallback).
*/
export async function loadMessages(
locale: string
): Promise<Record<string, string>> {
export async function loadMessages(locale: string): Promise<Record<string, string>> {
if (locale === 'en') {
// English strings live in source code as defaultMessage — no catalog needed.
return {};
@@ -83,7 +105,9 @@ export async function loadMessages(
const mod = await import(`./compiled/${locale}.json`);
return mod.default ?? mod;
} catch {
console.warn(`[i18n] No message catalog found for locale "${locale}", falling back to English.`);
console.warn(
`[i18n] No message catalog found for locale "${locale}", falling back to English.`
);
return {};
}
}
+6
View File
@@ -149,6 +149,9 @@
"chatInput.failedToReadImage": {
"defaultMessage": "Failed to read image file"
},
"chatInput.navigationShortcut": {
"defaultMessage": "{prefix}↑/{prefix}↓ to navigate messages"
},
"chatInput.processingDroppedFiles": {
"defaultMessage": "Processing dropped files..."
},
@@ -2426,6 +2429,9 @@
"navigationCustomization.itemSettings": {
"defaultMessage": "Settings"
},
"navigationCustomization.itemSkills": {
"defaultMessage": "Skills"
},
"navigationCustomization.resetToDefaults": {
"defaultMessage": "Reset to defaults"
},
File diff suppressed because it is too large Load Diff
+157 -22
View File
@@ -59,6 +59,118 @@ function shouldSetupUpdater(): boolean {
return UPDATES_ENABLED || process.env.ENABLE_DEV_UPDATES === 'true';
}
// =======================================================================
// Native menu localization
// -----------------------------------------------------------------------
// Electron's main process can't use react-intl (which runs in the renderer),
// so the native menu bar is translated here with a small hand-maintained
// dictionary. Only Simplified Chinese is filled in right now; other locales
// fall through to the original English labels. Keep the keys in sync with
// the raw label strings used below.
// =======================================================================
const MENU_TRANSLATIONS_ZH_CN: Record<string, string> = {
// Top-level
File: '文件',
Edit: '编辑',
View: '视图',
Window: '窗口',
Help: '帮助',
// Context menu
'Add to dictionary': '添加到词典',
Cut: '剪切',
Copy: '复制',
Paste: '粘贴',
// Goose-added items
'New Window': '新建窗口',
Settings: '设置',
'Find…': '查找…',
'Find Next': '查找下一个',
'Find Previous': '查找上一个',
'Use Selection for Find': '用所选内容查找',
Find: '查找',
'New Chat': '新建聊天',
'New Chat Window': '新建聊天窗口',
'Open Directory...': '打开目录…',
'Recent Directories': '最近的目录',
'Focus Goose Window': '聚焦 Goose 窗口',
'Quick Launcher': '快速启动器',
'Always on Top': '窗口置顶',
'Toggle Navigation': '切换导航',
'About Goose': '关于 Goose',
// Electron's default role-based labels we want to translate as well.
// (The menu role itself still provides the correct behaviour; only the
// display string is overridden.)
Undo: '撤销',
Redo: '重做',
'Select All': '全选',
Delete: '删除',
Speech: '语音',
Reload: '重新加载',
'Force Reload': '强制重新加载',
'Toggle Developer Tools': '切换开发者工具',
'Actual Size': '实际大小',
'Reset Zoom': '重置缩放',
'Zoom In': '放大',
'Zoom Out': '缩小',
'Toggle Full Screen': '切换全屏',
'Toggle Fullscreen': '切换全屏',
Minimize: '最小化',
Close: '关闭',
'Close Window': '关闭窗口',
Quit: '退出',
Exit: '退出',
'Bring All to Front': '全部置于最前',
'Emoji & Symbols': '表情符号',
'Start Dictation…': '开始听写…',
'Hide Goose': '隐藏 Goose',
'Hide Others': '隐藏其他',
'Show All': '全部显示',
Services: '服务',
};
function detectMenuLocale(): string {
const explicit = process.env.GOOSE_LOCALE;
if (explicit) return explicit;
try {
return app.getSystemLocale() || 'en';
} catch {
return 'en';
}
}
function menuT(label: string): string {
// Normalize underscores to hyphens so POSIX-style tags like "zh_CN" work.
const lower = detectMenuLocale().replace(/_/g, '-').toLowerCase();
const isTraditional = /^zh-(hant|tw|hk|mo)\b/.test(lower);
const isSimplifiedChinese = !isTraditional && (lower === 'zh' || lower.startsWith('zh-'));
if (isSimplifiedChinese) {
return MENU_TRANSLATIONS_ZH_CN[label] ?? label;
}
return label;
}
/**
* Recursively translate `label` on every item in the given menu, including nested submenus.
* Electron's default application menu comes with English labels that are not otherwise
* configurable, so we post-process them here before calling `Menu.setApplicationMenu`.
*/
function translateMenuLabels(items: MenuItem[]): void {
for (const item of items) {
if (item.label) {
const translated = menuT(item.label);
if (translated !== item.label) {
// MenuItem.label is a writable property on the main-process side, even though
// the TS type sometimes claims otherwise. Cast through unknown for safety.
(item as unknown as { label: string }).label = translated;
}
}
if (item.submenu && item.submenu.items) {
translateMenuLabels(item.submenu.items);
}
}
}
// Settings management
const SETTINGS_FILE = path.join(app.getPath('userData'), 'settings.json');
const STARTUP_LOGS_DIR = path.join(app.getPath('userData'), 'logs', 'startup');
@@ -202,6 +314,22 @@ app.on('certificate-error', (event, _webContents, url, _error, certificate, call
}
});
// Fill in GOOSE_LOCALE from the OS region locale once Electron is ready.
// Kept separate from the initial appConfig assignment above because
// app.getSystemLocale() is only available after the app.ready event fires.
app.whenReady().then(() => {
if (!appConfig.GOOSE_LOCALE) {
try {
const sysLocale = app.getSystemLocale();
if (sysLocale) {
appConfig.GOOSE_LOCALE = sysLocale;
}
} catch {
// Locale detection is best-effort; renderer will fall back to navigator.language.
}
}
});
// Main-process net.fetch: pin to the exact cert goosed generated.
app.whenReady().then(() => {
session.defaultSession.setCertificateVerifyProc((request, callback) => {
@@ -580,6 +708,8 @@ let appConfig = {
GOOSE_API_HOST: 'https://localhost',
GOOSE_PATH_ROOT: resolveGoosePathRoot(),
GOOSE_WORKING_DIR: '',
// Start with the env-var override; the OS region locale is filled in after app.ready
// (see updateLocaleFromSystem below) since getSystemLocale() cannot be called earlier.
GOOSE_LOCALE: process.env.GOOSE_LOCALE || undefined,
// If GOOSE_ALLOWLIST_WARNING env var is not set, defaults to false (strict blocking mode)
GOOSE_ALLOWLIST_WARNING: process.env.GOOSE_ALLOWLIST_WARNING === 'true',
@@ -833,7 +963,7 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
if (params.misspelledWord) {
menu.append(
new MenuItem({
label: 'Add to dictionary',
label: menuT('Add to dictionary'),
click: () =>
mainWindow.webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord),
})
@@ -847,14 +977,14 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
if (params.selectionText) {
menu.append(
new MenuItem({
label: 'Cut',
label: menuT('Cut'),
accelerator: 'CmdOrCtrl+X',
role: 'cut',
})
);
menu.append(
new MenuItem({
label: 'Copy',
label: menuT('Copy'),
accelerator: 'CmdOrCtrl+C',
role: 'copy',
})
@@ -865,7 +995,7 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
if (params.isEditable) {
menu.append(
new MenuItem({
label: 'Paste',
label: menuT('Paste'),
accelerator: 'CmdOrCtrl+V',
role: 'paste',
})
@@ -1956,7 +2086,7 @@ async function appMain() {
if (process.platform === 'darwin') {
const dockMenu = Menu.buildFromTemplate([
{
label: 'New Window',
label: menuT('New Window'),
click: () => {
createNewWindow(app);
},
@@ -1976,7 +2106,7 @@ async function appMain() {
appMenu.submenu.insert(
1,
new MenuItem({
label: 'Settings',
label: menuT('Settings'),
accelerator: shortcuts.settings,
click() {
const focusedWindow = BrowserWindow.getFocusedWindow();
@@ -1994,7 +2124,7 @@ async function appMain() {
const findSubmenu = Menu.buildFromTemplate([
{
label: 'Find…',
label: menuT('Find…'),
accelerator: shortcuts.find || undefined,
click() {
const focusedWindow = BrowserWindow.getFocusedWindow();
@@ -2002,7 +2132,7 @@ async function appMain() {
},
},
{
label: 'Find Next',
label: menuT('Find Next'),
accelerator: shortcuts.findNext || undefined,
click() {
const focusedWindow = BrowserWindow.getFocusedWindow();
@@ -2010,7 +2140,7 @@ async function appMain() {
},
},
{
label: 'Find Previous',
label: menuT('Find Previous'),
accelerator: shortcuts.findPrevious || undefined,
click() {
const focusedWindow = BrowserWindow.getFocusedWindow();
@@ -2018,7 +2148,7 @@ async function appMain() {
},
},
{
label: 'Use Selection for Find',
label: menuT('Use Selection for Find'),
accelerator: process.platform === 'darwin' ? 'Command+E' : undefined,
click() {
const focusedWindow = BrowserWindow.getFocusedWindow();
@@ -2031,7 +2161,7 @@ async function appMain() {
editMenu.submenu.insert(
selectAllIndex + 1,
new MenuItem({
label: 'Find',
label: menuT('Find'),
submenu: findSubmenu,
})
);
@@ -2047,7 +2177,7 @@ async function appMain() {
fileMenu.submenu.insert(
menuIndex++,
new MenuItem({
label: 'New Chat',
label: menuT('New Chat'),
accelerator: shortcuts.newChat,
click() {
const focusedWindow = BrowserWindow.getFocusedWindow();
@@ -2061,7 +2191,7 @@ async function appMain() {
fileMenu.submenu.insert(
menuIndex++,
new MenuItem({
label: 'New Chat Window',
label: menuT('New Chat Window'),
accelerator: shortcuts.newChatWindow,
click() {
ipcMain.emit('create-chat-window');
@@ -2074,7 +2204,7 @@ async function appMain() {
fileMenu.submenu.insert(
menuIndex++,
new MenuItem({
label: 'Open Directory...',
label: menuT('Open Directory...'),
accelerator: shortcuts.openDirectory,
click: () => openDirectoryDialog(),
})
@@ -2086,7 +2216,7 @@ async function appMain() {
fileMenu.submenu.insert(
menuIndex++,
new MenuItem({
label: 'Recent Directories',
label: menuT('Recent Directories'),
submenu: recentFilesSubmenu,
})
);
@@ -2097,7 +2227,7 @@ async function appMain() {
if (shortcuts.focusWindow) {
fileMenu.submenu.append(
new MenuItem({
label: 'Focus Goose Window',
label: menuT('Focus Goose Window'),
accelerator: shortcuts.focusWindow,
click() {
focusWindow();
@@ -2109,7 +2239,7 @@ async function appMain() {
if (shortcuts.quickLauncher) {
fileMenu.submenu.append(
new MenuItem({
label: 'Quick Launcher',
label: menuT('Quick Launcher'),
accelerator: shortcuts.quickLauncher,
click() {
createLauncher();
@@ -2124,7 +2254,7 @@ async function appMain() {
if (!windowMenu) {
windowMenu = new MenuItem({
label: 'Window',
label: menuT('Window'),
submenu: Menu.buildFromTemplate([]),
});
@@ -2140,7 +2270,7 @@ async function appMain() {
if (shortcuts.alwaysOnTop) {
windowMenu.submenu.append(
new MenuItem({
label: 'Always on Top',
label: menuT('Always on Top'),
type: 'checkbox',
accelerator: shortcuts.alwaysOnTop,
click(menuItem) {
@@ -2169,7 +2299,7 @@ async function appMain() {
viewMenu.submenu.append(new MenuItem({ type: 'separator' }));
viewMenu.submenu.append(
new MenuItem({
label: 'Toggle Navigation',
label: menuT('Toggle Navigation'),
accelerator: shortcuts.toggleNavigation,
click() {
const focusedWindow = BrowserWindow.getFocusedWindow();
@@ -2189,7 +2319,7 @@ async function appMain() {
// If Help menu doesn't exist, create it and add it to the menu
if (!helpMenu) {
helpMenu = new MenuItem({
label: 'Help',
label: menuT('Help'),
submenu: Menu.buildFromTemplate([]), // Start with an empty submenu
});
// Find a reasonable place to insert the Help menu, usually near the end
@@ -2206,7 +2336,7 @@ async function appMain() {
// Create the About Goose menu item with a submenu
const aboutGooseMenuItem = new MenuItem({
label: 'About Goose',
label: menuT('About Goose'),
submenu: Menu.buildFromTemplate([]), // Start with an empty submenu for About
});
@@ -2225,6 +2355,11 @@ async function appMain() {
}
if (menu) {
// Translate labels (including Electron's default top-level entries
// File/Edit/View/Window/Help and submenu items populated by roles) before
// installing the menu. Called last so the lookups above that match on the
// English labels still succeed.
translateMenuLabels(menu.items);
Menu.setApplicationMenu(menu);
}
+19 -2
View File
@@ -1,9 +1,26 @@
import type { IntlShape } from 'react-intl';
function isMac(): boolean {
return window.electron?.platform === 'darwin';
}
export function getNavigationShortcutText(): string {
return isMac() ? '⌘↑/⌘↓ to navigate messages' : 'Ctrl+↑/Ctrl+↓ to navigate messages';
/**
* Localised message for the "navigate messages with arrow keys" chat input placeholder.
* Returns the legacy English string if no intl instance is supplied, so call sites that
* run before the intl provider is available still get a sensible default.
*/
export function getNavigationShortcutText(intl?: IntlShape): string {
const prefix = isMac() ? '⌘' : 'Ctrl+';
if (intl) {
return intl.formatMessage(
{
id: 'chatInput.navigationShortcut',
defaultMessage: '{prefix}↑/{prefix}↓ to navigate messages',
},
{ prefix }
);
}
return `${prefix}↑/${prefix}↓ to navigate messages`;
}
export function getSearchShortcutText(): string {