Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion STYLE_GUIDE.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
## Style Guide

- Try to keep things in one function unless composable or reusable
- AVOID unnecessary destructuring of variables
- AVOID unnecessary destructuring of variables. instead of doing `const { a, b }
= obj` just reference it as obj.a and obj.b. this preserves context
- AVOID `try`/`catch` where possible
- AVOID `else` statements
- AVOID using `any` type
Expand Down
1 change: 1 addition & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,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",
Expand Down
25 changes: 14 additions & 11 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { NotificationProvider } from "@/context/notification"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { CommandProvider } from "@/context/command"
import { Logo } from "@opencode-ai/ui/logo"
import { I18nProvider } from "@/i18n"
import Layout from "@/pages/layout"
import DirectoryLayout from "@/pages/directory-layout"
import { ErrorPage } from "./pages/error"
Expand Down Expand Up @@ -53,17 +54,19 @@ export function AppBaseProviders(props: ParentProps) {
return (
<MetaProvider>
<Font />
<ThemeProvider>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<DialogProvider>
<MarkedProvider>
<DiffComponentProvider component={Diff}>
<CodeComponentProvider component={Code}>{props.children}</CodeComponentProvider>
</DiffComponentProvider>
</MarkedProvider>
</DialogProvider>
</ErrorBoundary>
</ThemeProvider>
<I18nProvider>
<ThemeProvider>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<DialogProvider>
<MarkedProvider>
<DiffComponentProvider component={Diff}>
<CodeComponentProvider component={Code}>{props.children}</CodeComponentProvider>
</DiffComponentProvider>
</MarkedProvider>
</DialogProvider>
</ErrorBoundary>
</ThemeProvider>
</I18nProvider>
</MetaProvider>
)
}
Expand Down
35 changes: 35 additions & 0 deletions packages/app/src/components/dialog-select-language.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useI18n, supportedLocales } from "@/i18n"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"

const languages = {
en: { name: "English", flag: "🇺🇸" },
"zh-CN": { name: "简体中文", flag: "🇨🇳" },
ja: { name: "日本語", flag: "🇯🇵" },
fr: { name: "Français", flag: "🇫🇷" },
es: { name: "Español", flag: "🇪🇸" },
} as const

export function DialogSelectLanguage() {
const { locale, setLocale } = useI18n()

return (
<Dialog
title="Language / 语言 / 言語"
>
<List
items={supportedLocales}
key={(lang) => lang}
current={supportedLocales.find((l) => l === locale())}
onSelect={(lang) => lang && setLocale(lang)}
>
{(lang) => (
<div class="flex items-center gap-2">
<span class="text-lg">{languages[lang].flag}</span>
<span>{languages[lang].name}</span>
</div>
)}
</List>
</Dialog>
)
}
14 changes: 8 additions & 6 deletions packages/app/src/components/session/session-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useServer } from "@/context/server"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useSync } from "@/context/sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { useI18n } from "@/i18n"
import { getFilename } from "@opencode-ai/util/path"
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
import { iife } from "@opencode-ai/util/iife"
Expand All @@ -31,6 +32,7 @@ export function SessionHeader() {
const server = useServer()
const dialog = useDialog()
const sync = useSync()
const { t } = useI18n()

const projectDirectory = createMemo(() => base64Decode(params.dir ?? ""))

Expand Down Expand Up @@ -93,7 +95,7 @@ export function SessionHeader() {
<Select
options={sessions()}
current={currentSession()}
placeholder="New session"
placeholder={t().session.newSession}
label={(x) => x.title}
value={(x) => x.id}
onSelect={navigateToSession}
Expand All @@ -107,7 +109,7 @@ export function SessionHeader() {
<Select
options={sessions()}
current={parentSession()}
placeholder="Back to parent session"
placeholder={t().session.backToParent}
label={(x) => x.title}
value={(x) => x.id}
onSelect={(session) => {
Expand All @@ -122,7 +124,7 @@ export function SessionHeader() {
/>
<div class="text-text-weaker">/</div>
<div class="flex items-center gap-1.5 min-w-0">
<Tooltip value="Back to parent session">
<Tooltip value={t().session.backToParent}>
<button
type="button"
class="flex items-center justify-center gap-1 p-1 rounded hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors flex-shrink-0"
Expand All @@ -136,7 +138,7 @@ export function SessionHeader() {
</Show>
</div>
<Show when={currentSession() && !parentSession()}>
<TooltipKeybind class="hidden xl:block" title="New session" keybind={command.keybind("session.new")}>
<TooltipKeybind class="hidden xl:block" title={t().session.newSession} keybind={command.keybind("session.new")}>
<IconButton as={A} href={`/${params.dir}/session`} icon="edit-small-2" variant="ghost" />
</TooltipKeybind>
</Show>
Expand Down Expand Up @@ -220,9 +222,9 @@ export function SessionHeader() {
</div>
<Show when={shareEnabled() && currentSession()}>
<Popover
title="Share session"
title={t().session.share}
trigger={
<Tooltip class="shrink-0" value="Share session">
<Tooltip class="shrink-0" value={t().session.share}>
<IconButton icon="share" variant="ghost" class="" />
</Tooltip>
}
Expand Down
14 changes: 8 additions & 6 deletions packages/app/src/components/session/session-new-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useSync } from "@/context/sync"
import { Icon } from "@opencode-ai/ui/icon"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { Select } from "@opencode-ai/ui/select"
import { useI18n } from "@/i18n"

const MAIN_WORKTREE = "main"
const CREATE_WORKTREE = "create"
Expand All @@ -15,6 +16,7 @@ interface NewSessionViewProps {

export function NewSessionView(props: NewSessionViewProps) {
const sync = useSync()
const { t } = useI18n()

const sandboxes = createMemo(() => sync.project?.sandboxes ?? [])
const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE])
Expand All @@ -32,13 +34,13 @@ export function NewSessionView(props: NewSessionViewProps) {

const label = (value: string) => {
if (value === MAIN_WORKTREE) {
if (isWorktree()) return "Main branch"
if (isWorktree()) return t().session.mainBranch
const branch = sync.data.vcs?.branch
if (branch) return `Main branch (${branch})`
return "Main branch"
if (branch) return t().session.mainBranchWithName.replace("{branch}", branch)
return t().session.mainBranch
}

if (value === CREATE_WORKTREE) return "Create new worktree"
if (value === CREATE_WORKTREE) return t().session.createWorktree

return getFilename(value)
}
Expand All @@ -48,7 +50,7 @@ export function NewSessionView(props: NewSessionViewProps) {
class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6"
style={{ "padding-bottom": "calc(var(--prompt-height, 11.25rem) + 64px)" }}
>
<div class="text-20-medium text-text-weaker">New session</div>
<div class="text-20-medium text-text-weaker">{t().session.newSession}</div>
<div class="flex justify-center items-center gap-3">
<Icon name="folder" size="small" />
<div class="text-12-medium text-text-weak">
Expand Down Expand Up @@ -76,7 +78,7 @@ export function NewSessionView(props: NewSessionViewProps) {
<div class="flex justify-center items-center gap-3">
<Icon name="pencil-line" size="small" />
<div class="text-12-medium text-text-weak">
Last modified&nbsp;
{t().session.lastModified}&nbsp;
<span class="text-text-strong">
{DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
</span>
Expand Down
70 changes: 70 additions & 0 deletions packages/app/src/i18n/context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { createContext, useContext, Accessor, Setter, createSignal, createRoot } from "solid-js"
import { locales, type Locale, type Translation, defaultLocale, supportedLocales } from "./locales"

type I18nContextValue = {
locale: Accessor<Locale>
setLocale: Setter<Locale>
t: Accessor<Translation>
locales: typeof supportedLocales
}

const I18nContext = createContext<I18nContextValue>()

export function I18nProvider(props: { children: any }) {
const [locale, setLocale] = createSignal<Locale>(defaultLocale)

// Initialize from localStorage or browser language
const initializeLocale = () => {
const stored = localStorage.getItem("opencode-locale") as Locale | null
if (stored && supportedLocales.includes(stored)) {
setLocale(stored)
return
}

// Detect browser language
const browserLang = navigator.language
if (browserLang.startsWith("zh")) {
setLocale("zh-CN")
} else if (browserLang.startsWith("ja")) {
setLocale("ja")
} else if (browserLang.startsWith("fr")) {
setLocale("fr")
} else if (browserLang.startsWith("es")) {
setLocale("es")
}
}
initializeLocale()

// Persist locale changes
const persistLocale: Setter<Locale> = (...args) => {
const result = setLocale(...args)
const newLocale = locale()
localStorage.setItem("opencode-locale", newLocale)
return result
}

const t = () => locales[locale()]

const value: I18nContextValue = {
locale,
setLocale: persistLocale,
t,
locales: supportedLocales,
}

return <I18nContext.Provider value={value}>{props.children}</I18nContext.Provider>
}

export function useI18n() {
const context = useContext(I18nContext)
if (!context) {
throw new Error("useI18n must be used within I18nProvider")
}
return context
}

// Helper to get nested translation values
export function useT() {
const { t } = useI18n()
return t
}
2 changes: 2 additions & 0 deletions packages/app/src/i18n/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { I18nProvider, useI18n, useT } from "./context"
export { locales, defaultLocale, supportedLocales, type Locale, type Translation } from "./locales"
108 changes: 108 additions & 0 deletions packages/app/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
export default {
common: {
loading: "Loading...",
save: "Save",
cancel: "Cancel",
confirm: "Confirm",
delete: "Delete",
edit: "Edit",
search: "Search",
back: "Back",
next: "Next",
close: "Close",
},
home: {
title: "OpenCode AI",
subtitle: "AI-powered development tool",
start: "Start Coding",
recentProjects: "Recent projects",
noRecentProjects: "No recent projects",
getStarted: "Get started by opening a local project",
openProject: "Open project",
},
session: {
new: "New Session",
newSession: "New session",
mainBranch: "Main branch",
mainBranchWithName: "Main branch ({branch})",
createWorktree: "Create new worktree",
lastModified: "Last modified",
backToParent: "Back to parent session",
share: "Share session",
terminate: "Terminate",
archive: "Archive session",
filesChanged: "{count} file{plural} changed",
},
dialog: {
selectProvider: {
title: "Select Provider",
description: "Choose an AI provider to use",
},
selectModel: {
title: "Select Model",
description: "Choose a model to use",
unpaid: {
title: "Model Payment Required",
description: "This model requires payment to use",
},
},
selectServer: {
title: "Select Server",
description: "Choose a server to connect to",
},
selectDirectory: {
title: "Select Directory",
description: "Choose a directory to work with",
openProject: "Open project",
},
selectFile: {
title: "Select File",
description: "Choose a file to work with",
},
selectMcp: {
title: "Select MCP",
description: "Choose a Model Context Protocol server",
},
connectProvider: {
title: "Connect Provider",
description: "Configure your AI provider credentials",
},
editProject: {
title: "Edit Project",
description: "Edit project settings",
editProject: "Edit project",
closeProject: "Close project",
},
manageModels: {
title: "Manage Models",
description: "Manage available models",
},
},
terminal: {
tabs: {
session: "Session",
context: "Context",
lsp: "LSP",
mcp: "MCP",
},
},
fileTree: {
empty: "No files found",
refresh: "Refresh",
},
sidebar: {
toggle: "Toggle sidebar",
newSession: "New session",
loadMore: "Load more",
gettingStarted: "Getting started",
gettingStartedDesc1: "OpenCode includes free models so you can start immediately.",
gettingStartedDesc2: "Connect any provider to use models, inc. Claude, GPT, Gemini etc.",
connectProvider: "Connect provider",
shareFeedback: "Share feedback",
changeLanguage: "Change language",
},
layout: {
editProject: "Edit project",
closeProject: "Close project",
},
} as const
Loading