feat(desktop): i18n for tauri side

This commit is contained in:
adamelmore
2026-01-27 15:00:17 -06:00
parent acf0df1e98
commit 51edf68606
8 changed files with 204 additions and 28 deletions

View File

@@ -186,6 +186,7 @@
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/storage": "catalog:", "@solid-primitives/storage": "catalog:",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-dialog": "~2",

View File

@@ -15,6 +15,7 @@
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/storage": "catalog:", "@solid-primitives/storage": "catalog:",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-dialog": "~2",

View File

@@ -1,13 +1,15 @@
import { invoke } from "@tauri-apps/api/core" import { invoke } from "@tauri-apps/api/core"
import { message } from "@tauri-apps/plugin-dialog" import { message } from "@tauri-apps/plugin-dialog"
import { initI18n, t } from "./i18n"
export async function installCli(): Promise<void> { export async function installCli(): Promise<void> {
await initI18n()
try { try {
const path = await invoke<string>("install_cli") const path = await invoke<string>("install_cli")
await message(`CLI installed to ${path}\n\nRestart your terminal to use the 'opencode' command.`, { await message(t("desktop.cli.installed.message", { path }), { title: t("desktop.cli.installed.title") })
title: "CLI Installed",
})
} catch (e) { } catch (e) {
await message(`Failed to install CLI: ${e}`, { title: "Installation Failed" }) await message(t("desktop.cli.failed.message", { error: String(e) }), { title: t("desktop.cli.failed.title") })
} }
} }

View File

@@ -0,0 +1,31 @@
export const dict = {
"desktop.menu.checkForUpdates": "Check for Updates...",
"desktop.menu.installCli": "Install CLI...",
"desktop.menu.reloadWebview": "Reload Webview",
"desktop.menu.restart": "Restart",
"desktop.dialog.chooseFolder": "Choose a folder",
"desktop.dialog.chooseFile": "Choose a file",
"desktop.dialog.saveFile": "Save file",
"desktop.updater.checkFailed.title": "Update Check Failed",
"desktop.updater.checkFailed.message": "Failed to check for updates",
"desktop.updater.none.title": "No Update Available",
"desktop.updater.none.message": "You are already using the latest version of OpenCode",
"desktop.updater.downloadFailed.title": "Update Failed",
"desktop.updater.downloadFailed.message": "Failed to download update",
"desktop.updater.downloaded.title": "Update Downloaded",
"desktop.updater.downloaded.prompt":
"Version {{version}} of OpenCode has been downloaded, would you like to install it and relaunch?",
"desktop.updater.installFailed.title": "Update Failed",
"desktop.updater.installFailed.message": "Failed to install update",
"desktop.cli.installed.title": "CLI Installed",
"desktop.cli.installed.message": "CLI installed to {{path}}\n\nRestart your terminal to use the 'opencode' command.",
"desktop.cli.failed.title": "Installation Failed",
"desktop.cli.failed.message": "Failed to install CLI: {{error}}",
"desktop.error.serverStartFailed.title": "OpenCode failed to start",
"desktop.error.serverStartFailed.description":
"The local OpenCode server could not be started. Restart the app, or check your network settings (VPN/proxy) and try again.",
} as const

View File

@@ -0,0 +1,134 @@
import * as i18n from "@solid-primitives/i18n"
import { Store } from "@tauri-apps/plugin-store"
import { dict as desktopEn } from "./en"
import { dict as appEn } from "../../../app/src/i18n/en"
import { dict as appZh } from "../../../app/src/i18n/zh"
import { dict as appZht } from "../../../app/src/i18n/zht"
import { dict as appKo } from "../../../app/src/i18n/ko"
import { dict as appDe } from "../../../app/src/i18n/de"
import { dict as appEs } from "../../../app/src/i18n/es"
import { dict as appFr } from "../../../app/src/i18n/fr"
import { dict as appDa } from "../../../app/src/i18n/da"
import { dict as appJa } from "../../../app/src/i18n/ja"
import { dict as appPl } from "../../../app/src/i18n/pl"
import { dict as appRu } from "../../../app/src/i18n/ru"
import { dict as appAr } from "../../../app/src/i18n/ar"
import { dict as appNo } from "../../../app/src/i18n/no"
import { dict as appBr } from "../../../app/src/i18n/br"
export type Locale = "en" | "zh" | "zht" | "ko" | "de" | "es" | "fr" | "da" | "ja" | "pl" | "ru" | "ar" | "no" | "br"
type RawDictionary = typeof appEn & typeof desktopEn
type Dictionary = i18n.Flatten<RawDictionary>
const LOCALES: readonly Locale[] = ["en", "zh", "zht", "ko", "de", "es", "fr", "da", "ja", "pl", "ru", "ar", "no", "br"]
function detectLocale(): Locale {
if (typeof navigator !== "object") return "en"
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
for (const language of languages) {
if (!language) continue
if (language.toLowerCase().startsWith("zh")) {
if (language.toLowerCase().includes("hant")) return "zht"
return "zh"
}
if (language.toLowerCase().startsWith("ko")) return "ko"
if (language.toLowerCase().startsWith("de")) return "de"
if (language.toLowerCase().startsWith("es")) return "es"
if (language.toLowerCase().startsWith("fr")) return "fr"
if (language.toLowerCase().startsWith("da")) return "da"
if (language.toLowerCase().startsWith("ja")) return "ja"
if (language.toLowerCase().startsWith("pl")) return "pl"
if (language.toLowerCase().startsWith("ru")) return "ru"
if (language.toLowerCase().startsWith("ar")) return "ar"
if (
language.toLowerCase().startsWith("no") ||
language.toLowerCase().startsWith("nb") ||
language.toLowerCase().startsWith("nn")
)
return "no"
if (language.toLowerCase().startsWith("pt")) return "br"
}
return "en"
}
function parseLocale(value: unknown): Locale | null {
if (!value) return null
if (typeof value !== "string") return null
if ((LOCALES as readonly string[]).includes(value)) return value as Locale
return null
}
function parseRecord(value: unknown) {
if (!value || typeof value !== "object") return null
if (Array.isArray(value)) return null
return value as Record<string, unknown>
}
function pickLocale(value: unknown): Locale | null {
const direct = parseLocale(value)
if (direct) return direct
const record = parseRecord(value)
if (!record) return null
return parseLocale(record.locale)
}
const base = i18n.flatten({ ...appEn, ...desktopEn })
function build(locale: Locale): Dictionary {
if (locale === "en") return base
if (locale === "zh") return { ...base, ...i18n.flatten(appZh) }
if (locale === "zht") return { ...base, ...i18n.flatten(appZht) }
if (locale === "de") return { ...base, ...i18n.flatten(appDe) }
if (locale === "es") return { ...base, ...i18n.flatten(appEs) }
if (locale === "fr") return { ...base, ...i18n.flatten(appFr) }
if (locale === "da") return { ...base, ...i18n.flatten(appDa) }
if (locale === "ja") return { ...base, ...i18n.flatten(appJa) }
if (locale === "pl") return { ...base, ...i18n.flatten(appPl) }
if (locale === "ru") return { ...base, ...i18n.flatten(appRu) }
if (locale === "ar") return { ...base, ...i18n.flatten(appAr) }
if (locale === "no") return { ...base, ...i18n.flatten(appNo) }
if (locale === "br") return { ...base, ...i18n.flatten(appBr) }
return { ...base, ...i18n.flatten(appKo) }
}
const state = {
locale: detectLocale(),
dict: base as Dictionary,
init: undefined as Promise<Locale> | undefined,
}
state.dict = build(state.locale)
const translate = i18n.translator(() => state.dict, i18n.resolveTemplate)
export function t(key: keyof Dictionary, params?: Record<string, string | number>) {
return translate(key, params)
}
export function initI18n(): Promise<Locale> {
const cached = state.init
if (cached) return cached
const promise = (async () => {
const store = await Store.load("opencode.global.dat").catch(() => null)
if (!store) return state.locale
const raw = await store.get("language").catch(() => null)
const value = typeof raw === "string" ? JSON.parse(raw) : raw
const next = pickLocale(value) ?? state.locale
state.locale = next
state.dict = build(next)
return next
})().catch(() => state.locale)
state.init = promise
return promise
}

View File

@@ -18,16 +18,17 @@ import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup }
import { UPDATER_ENABLED } from "./updater" import { UPDATER_ENABLED } from "./updater"
import { createMenu } from "./menu" import { createMenu } from "./menu"
import { initI18n, t } from "./i18n"
import pkg from "../package.json" import pkg from "../package.json"
import "./styles.css" import "./styles.css"
const root = document.getElementById("root") const root = document.getElementById("root")
if (import.meta.env.DEV && !(root instanceof HTMLElement)) { if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
throw new Error( throw new Error(t("error.dev.rootNotFound"))
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
)
} }
void initI18n()
// Floating UI can call getComputedStyle with non-elements (e.g., null refs, virtual elements). // Floating UI can call getComputedStyle with non-elements (e.g., null refs, virtual elements).
// This happens on all platforms (WebView2 on Windows, WKWebView on macOS), not just Windows. // This happens on all platforms (WebView2 on Windows, WKWebView on macOS), not just Windows.
const originalGetComputedStyle = window.getComputedStyle const originalGetComputedStyle = window.getComputedStyle
@@ -54,7 +55,7 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
const result = await open({ const result = await open({
directory: true, directory: true,
multiple: opts?.multiple ?? false, multiple: opts?.multiple ?? false,
title: opts?.title ?? "Choose a folder", title: opts?.title ?? t("desktop.dialog.chooseFolder"),
}) })
return result return result
}, },
@@ -63,14 +64,14 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
const result = await open({ const result = await open({
directory: false, directory: false,
multiple: opts?.multiple ?? false, multiple: opts?.multiple ?? false,
title: opts?.title ?? "Choose a file", title: opts?.title ?? t("desktop.dialog.chooseFile"),
}) })
return result return result
}, },
async saveFilePickerDialog(opts) { async saveFilePickerDialog(opts) {
const result = await save({ const result = await save({
title: opts?.title ?? "Save file", title: opts?.title ?? t("desktop.dialog.saveFile"),
defaultPath: opts?.defaultPath, defaultPath: opts?.defaultPath,
}) })
return result return result
@@ -380,7 +381,7 @@ function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.
const errorMessage = () => { const errorMessage = () => {
const error = serverData.error const error = serverData.error
if (!error) return "Unknown error" if (!error) return t("error.chain.unknown")
if (typeof error === "string") return error if (typeof error === "string") return error
if (error instanceof Error) return error.message if (error instanceof Error) return error.message
return String(error) return String(error)
@@ -410,16 +411,15 @@ function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.
} }
> >
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base gap-4 px-6"> <div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base gap-4 px-6">
<div class="text-16-semibold">OpenCode failed to start</div> <div class="text-16-semibold">{t("desktop.error.serverStartFailed.title")}</div>
<div class="text-12-regular opacity-70 text-center max-w-xl"> <div class="text-12-regular opacity-70 text-center max-w-xl">
The local OpenCode server could not be started. Restart the app, or check your network settings (VPN/proxy) {t("desktop.error.serverStartFailed.description")}
and try again.
</div> </div>
<div class="w-full max-w-3xl rounded border border-border bg-background-base overflow-auto max-h-64"> <div class="w-full max-w-3xl rounded border border-border bg-background-base overflow-auto max-h-64">
<pre class="p-3 whitespace-pre-wrap break-words text-11-regular">{errorMessage()}</pre> <pre class="p-3 whitespace-pre-wrap break-words text-11-regular">{errorMessage()}</pre>
</div> </div>
<button class="px-3 py-2 rounded bg-primary text-primary-foreground" onClick={() => void restartApp()}> <button class="px-3 py-2 rounded bg-primary text-primary-foreground" onClick={() => void restartApp()}>
Restart App {t("error.page.action.restart")}
</button> </button>
<div data-tauri-decorum-tb class="flex flex-row absolute top-0 right-0 z-10 h-10" /> <div data-tauri-decorum-tb class="flex flex-row absolute top-0 right-0 z-10 h-10" />
</div> </div>

View File

@@ -5,10 +5,13 @@ import { relaunch } from "@tauri-apps/plugin-process"
import { runUpdater, UPDATER_ENABLED } from "./updater" import { runUpdater, UPDATER_ENABLED } from "./updater"
import { installCli } from "./cli" import { installCli } from "./cli"
import { initI18n, t } from "./i18n"
export async function createMenu() { export async function createMenu() {
if (ostype() !== "macos") return if (ostype() !== "macos") return
await initI18n()
const menu = await Menu.new({ const menu = await Menu.new({
items: [ items: [
await Submenu.new({ await Submenu.new({
@@ -20,22 +23,22 @@ export async function createMenu() {
await MenuItem.new({ await MenuItem.new({
enabled: UPDATER_ENABLED, enabled: UPDATER_ENABLED,
action: () => runUpdater({ alertOnFail: true }), action: () => runUpdater({ alertOnFail: true }),
text: "Check For Updates...", text: t("desktop.menu.checkForUpdates"),
}), }),
await MenuItem.new({ await MenuItem.new({
action: () => installCli(), action: () => installCli(),
text: "Install CLI...", text: t("desktop.menu.installCli"),
}), }),
await MenuItem.new({ await MenuItem.new({
action: async () => window.location.reload(), action: async () => window.location.reload(),
text: "Reload Webview", text: t("desktop.menu.reloadWebview"),
}), }),
await MenuItem.new({ await MenuItem.new({
action: async () => { action: async () => {
await invoke("kill_sidecar").catch(() => undefined) await invoke("kill_sidecar").catch(() => undefined)
await relaunch().catch(() => undefined) await relaunch().catch(() => undefined)
}, },
text: "Restart", text: t("desktop.menu.restart"),
}), }),
await PredefinedMenuItem.new({ await PredefinedMenuItem.new({
item: "Separator", item: "Separator",

View File

@@ -4,41 +4,45 @@ import { ask, message } from "@tauri-apps/plugin-dialog"
import { invoke } from "@tauri-apps/api/core" import { invoke } from "@tauri-apps/api/core"
import { type as ostype } from "@tauri-apps/plugin-os" import { type as ostype } from "@tauri-apps/plugin-os"
import { initI18n, t } from "./i18n"
export const UPDATER_ENABLED = window.__OPENCODE__?.updaterEnabled ?? false export const UPDATER_ENABLED = window.__OPENCODE__?.updaterEnabled ?? false
export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) { export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) {
await initI18n()
let update let update
try { try {
update = await check() update = await check()
} catch { } catch {
if (alertOnFail) await message("Failed to check for updates", { title: "Update Check Failed" }) if (alertOnFail)
await message(t("desktop.updater.checkFailed.message"), { title: t("desktop.updater.checkFailed.title") })
return return
} }
if (!update) { if (!update) {
if (alertOnFail) if (alertOnFail) await message(t("desktop.updater.none.message"), { title: t("desktop.updater.none.title") })
await message("You are already using the latest version of OpenCode", { title: "No Update Available" })
return return
} }
try { try {
await update.download() await update.download()
} catch { } catch {
if (alertOnFail) await message("Failed to download update", { title: "Update Failed" }) if (alertOnFail)
await message(t("desktop.updater.downloadFailed.message"), { title: t("desktop.updater.downloadFailed.title") })
return return
} }
const shouldUpdate = await ask( const shouldUpdate = await ask(t("desktop.updater.downloaded.prompt", { version: update.version }), {
`Version ${update.version} of OpenCode has been downloaded, would you like to install it and relaunch?`, title: t("desktop.updater.downloaded.title"),
{ title: "Update Downloaded" }, })
)
if (!shouldUpdate) return if (!shouldUpdate) return
try { try {
if (ostype() === "windows") await invoke("kill_sidecar") if (ostype() === "windows") await invoke("kill_sidecar")
await update.install() await update.install()
} catch { } catch {
await message("Failed to install update", { title: "Update Failed" }) await message(t("desktop.updater.installFailed.message"), { title: t("desktop.updater.installFailed.title") })
return return
} }