diff options
| author | Adam <[email protected]> | 2026-01-19 15:50:23 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-01-20 17:58:06 -0600 |
| commit | 0470717c7fbb9ff175b70c6d76ffb2330ef40a1a (patch) | |
| tree | 291cff80dcd40315572893e1c639463fda635001 /packages/app | |
| parent | 7f50b279962aa76990e2ca2b7eb9bdfd3beafc38 (diff) | |
| download | opencode-0470717c7fbb9ff175b70c6d76ffb2330ef40a1a.tar.gz opencode-0470717c7fbb9ff175b70c6d76ffb2330ef40a1a.zip | |
feat(app): initial i18n stubbing
Diffstat (limited to 'packages/app')
| -rw-r--r-- | packages/app/package.json | 1 | ||||
| -rw-r--r-- | packages/app/src/app.tsx | 21 | ||||
| -rw-r--r-- | packages/app/src/context/language.tsx | 77 | ||||
| -rw-r--r-- | packages/app/src/i18n/en.ts | 9 | ||||
| -rw-r--r-- | packages/app/src/i18n/zh.ts | 13 | ||||
| -rw-r--r-- | packages/app/src/pages/layout.tsx | 36 |
6 files changed, 148 insertions, 9 deletions
diff --git a/packages/app/package.json b/packages/app/package.json index dd4f8f3c0..ae71034d5 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -42,6 +42,7 @@ "@shikijs/transformers": "3.9.2", "@solid-primitives/active-element": "2.1.3", "@solid-primitives/audio": "1.4.2", + "@solid-primitives/i18n": "2.2.1", "@solid-primitives/event-bus": "1.1.2", "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.3", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 33a5556ef..8f9104bd8 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -21,6 +21,7 @@ import { FileProvider } from "@/context/file" import { NotificationProvider } from "@/context/notification" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { CommandProvider } from "@/context/command" +import { LanguageProvider } from "@/context/language" import { Logo } from "@opencode-ai/ui/logo" import Layout from "@/pages/layout" import DirectoryLayout from "@/pages/directory-layout" @@ -84,15 +85,17 @@ export function AppInterface(props: { defaultUrl?: string }) { <Router root={(props) => ( <SettingsProvider> - <PermissionProvider> - <LayoutProvider> - <NotificationProvider> - <CommandProvider> - <Layout>{props.children}</Layout> - </CommandProvider> - </NotificationProvider> - </LayoutProvider> - </PermissionProvider> + <LanguageProvider> + <PermissionProvider> + <LayoutProvider> + <NotificationProvider> + <CommandProvider> + <Layout>{props.children}</Layout> + </CommandProvider> + </NotificationProvider> + </LayoutProvider> + </PermissionProvider> + </LanguageProvider> </SettingsProvider> )} > diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx new file mode 100644 index 000000000..3178cb6b6 --- /dev/null +++ b/packages/app/src/context/language.tsx @@ -0,0 +1,77 @@ +import * as i18n from "@solid-primitives/i18n" +import { createEffect, createMemo } from "solid-js" +import { createStore } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { Persist, persisted } from "@/utils/persist" +import { dict as en } from "@/i18n/en" +import { dict as zh } from "@/i18n/zh" + +export type Locale = "en" | "zh" + +type RawDictionary = typeof en +type Dictionary = i18n.Flatten<RawDictionary> + +const LOCALES: readonly Locale[] = ["en", "zh"] + +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")) return "zh" + } + + return "en" +} + +export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({ + name: "Language", + init: () => { + const [store, setStore, _, ready] = persisted( + Persist.global("language", ["language.v1"]), + createStore({ + locale: detectLocale() as Locale, + }), + ) + + const locale = createMemo<Locale>(() => (store.locale === "zh" ? "zh" : "en")) + + createEffect(() => { + const current = locale() + if (store.locale === current) return + setStore("locale", current) + }) + + const base = i18n.flatten(en) + const dict = createMemo<Dictionary>(() => { + if (locale() === "en") return base + return { ...base, ...i18n.flatten(zh) } + }) + + const t = i18n.translator(dict, i18n.resolveTemplate) + + const labelKey: Record<Locale, keyof Dictionary> = { + en: "language.en", + zh: "language.zh", + } + + const label = (value: Locale) => t(labelKey[value]) + + createEffect(() => { + if (typeof document !== "object") return + document.documentElement.lang = locale() + }) + + return { + ready, + locale, + locales: LOCALES, + label, + t, + setLocale(next: Locale) { + setStore("locale", next) + }, + } + }, +}) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts new file mode 100644 index 000000000..c51b1a7d7 --- /dev/null +++ b/packages/app/src/i18n/en.ts @@ -0,0 +1,9 @@ +export const dict = { + "command.category.language": "Language", + "command.language.cycle": "Cycle language", + "command.language.set": "Use language: {{language}}", + "language.en": "English", + "language.zh": "Chinese", + "toast.language.title": "Language", + "toast.language.description": "Switched to {{language}}", +} diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts new file mode 100644 index 000000000..3f1360821 --- /dev/null +++ b/packages/app/src/i18n/zh.ts @@ -0,0 +1,13 @@ +import { dict as en } from "./en" + +type Keys = keyof typeof en + +export const dict = { + "command.category.language": "\u8bed\u8a00", + "command.language.cycle": "\u5207\u6362\u8bed\u8a00", + "command.language.set": "\u4f7f\u7528\u8bed\u8a00: {{language}}", + "language.en": "\u82f1\u8bed", + "language.zh": "\u4e2d\u6587", + "toast.language.title": "\u8bed\u8a00", + "toast.language.description": "\u5df2\u5207\u6362\u5230{{language}}", +} satisfies Partial<Record<Keys, string>> diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 9d873e08a..bd6c044a8 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -69,6 +69,7 @@ import { DialogSelectDirectory } from "@/components/dialog-select-directory" import { DialogEditProject } from "@/components/dialog-edit-project" import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" +import { useLanguage, type Locale } from "@/context/language" export default function Layout(props: ParentProps) { const [store, setStore, , ready] = persisted( @@ -109,6 +110,7 @@ export default function Layout(props: ParentProps) { const dialog = useDialog() const command = useCommand() const theme = useTheme() + const language = useLanguage() const initialDir = params.dir const availableThemeEntries = createMemo(() => Object.entries(theme.themes())) const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"] @@ -268,6 +270,24 @@ export default function Layout(props: ParentProps) { }) } + function setLocale(next: Locale) { + if (next === language.locale()) return + language.setLocale(next) + showToast({ + title: language.t("toast.language.title"), + description: language.t("toast.language.description", { language: language.label(next) }), + }) + } + + function cycleLanguage(direction = 1) { + const locales = language.locales + const currentIndex = locales.indexOf(language.locale()) + const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + locales.length) % locales.length + const next = locales[nextIndex] + if (!next) return + setLocale(next) + } + onMount(() => { if (!platform.checkUpdate || !platform.update || !platform.restart) return @@ -906,6 +926,22 @@ export default function Layout(props: ParentProps) { }) } + commands.push({ + id: "language.cycle", + title: language.t("command.language.cycle"), + category: language.t("command.category.language"), + onSelect: () => cycleLanguage(1), + }) + + for (const locale of language.locales) { + commands.push({ + id: `language.set.${locale}`, + title: language.t("command.language.set", { language: language.label(locale) }), + category: language.t("command.category.language"), + onSelect: () => setLocale(locale), + }) + } + return commands }) |
