Преглед изворни кода

feat(app): initial i18n stubbing

Adam пре 2 месеци
родитељ
комит
0470717c7f

+ 3 - 0
bun.lock

@@ -33,6 +33,7 @@
         "@solid-primitives/active-element": "2.1.3",
         "@solid-primitives/audio": "1.4.2",
         "@solid-primitives/event-bus": "1.1.2",
+        "@solid-primitives/i18n": "2.2.1",
         "@solid-primitives/media": "2.3.3",
         "@solid-primitives/resize-observer": "2.1.3",
         "@solid-primitives/scroll": "2.1.3",
@@ -1634,6 +1635,8 @@
 
     "@solid-primitives/event-listener": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-h4VqkYFv6Gf+L7SQj+Y6puigL/5DIi7x5q07VZET7AWcS+9/G3WfIE9WheniHWJs51OEkRB43w6lDys5YeFceg=="],
 
+    "@solid-primitives/i18n": ["@solid-primitives/[email protected]", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-TnTnE2Ku11MGYZ1JzhJ8pYscwg1fr9MteoYxPwsfxWfh9Jp5K7RRJncJn9BhOHvNLwROjqOHZ46PT7sPHqbcXw=="],
+
     "@solid-primitives/keyed": ["@solid-primitives/[email protected]", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-BgoEdqPw48URnI+L5sZIHdF4ua4Las1eWEBBPaoSFs42kkhnHue+rwCBPL2Z9ebOyQ75sUhUfOETdJfmv0D6Kg=="],
 
     "@solid-primitives/map": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/trigger": "^1.1.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-B1zyFbsiTQvqPr+cuPCXO72sRuczG9Swncqk5P74NCGw1VE8qa/Ry9GlfI1e/VdeQYHjan+XkbE3rO2GW/qKew=="],

+ 1 - 0
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",

+ 12 - 9
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>
               )}
             >

+ 77 - 0
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)
+      },
+    }
+  },
+})

+ 9 - 0
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}}",
+}

+ 13 - 0
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>>

+ 36 - 0
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
   })