diff --git a/bun.lock b/bun.lock index 76eb6ef34..f515b8cab 100644 --- a/bun.lock +++ b/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/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=="], 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 }) { ( - - - - - {props.children} - - - - + + + + + + {props.children} + + + + + )} > 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 + +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(() => (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(() => { + if (locale() === "en") return base + return { ...base, ...i18n.flatten(zh) } + }) + + const t = i18n.translator(dict, i18n.resolveTemplate) + + const labelKey: Record = { + 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> 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 })