summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-19 15:50:23 -0600
committerAdam <[email protected]>2026-01-20 17:58:06 -0600
commit0470717c7fbb9ff175b70c6d76ffb2330ef40a1a (patch)
tree291cff80dcd40315572893e1c639463fda635001 /packages/app/src
parent7f50b279962aa76990e2ca2b7eb9bdfd3beafc38 (diff)
downloadopencode-0470717c7fbb9ff175b70c6d76ffb2330ef40a1a.tar.gz
opencode-0470717c7fbb9ff175b70c6d76ffb2330ef40a1a.zip
feat(app): initial i18n stubbing
Diffstat (limited to 'packages/app/src')
-rw-r--r--packages/app/src/app.tsx21
-rw-r--r--packages/app/src/context/language.tsx77
-rw-r--r--packages/app/src/i18n/en.ts9
-rw-r--r--packages/app/src/i18n/zh.ts13
-rw-r--r--packages/app/src/pages/layout.tsx36
5 files changed, 147 insertions, 9 deletions
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
})