feat(app): initial i18n stubbing

This commit is contained in:
Adam
2026-01-19 15:50:23 -06:00
parent 7f50b27996
commit 0470717c7f
7 changed files with 151 additions and 9 deletions

View File

@@ -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/event-listener@2.4.3", "", { "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/i18n@2.2.1", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-TnTnE2Ku11MGYZ1JzhJ8pYscwg1fr9MteoYxPwsfxWfh9Jp5K7RRJncJn9BhOHvNLwROjqOHZ46PT7sPHqbcXw=="],
"@solid-primitives/keyed": ["@solid-primitives/keyed@1.5.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-BgoEdqPw48URnI+L5sZIHdF4ua4Las1eWEBBPaoSFs42kkhnHue+rwCBPL2Z9ebOyQ75sUhUfOETdJfmv0D6Kg=="],
"@solid-primitives/map": ["@solid-primitives/map@0.4.13", "", { "dependencies": { "@solid-primitives/trigger": "^1.1.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-B1zyFbsiTQvqPr+cuPCXO72sRuczG9Swncqk5P74NCGw1VE8qa/Ry9GlfI1e/VdeQYHjan+XkbE3rO2GW/qKew=="],

View File

@@ -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",

View File

@@ -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>
)}
>

View File

@@ -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)
},
}
},
})

View File

@@ -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}}",
}

View File

@@ -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>>

View File

@@ -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
})