feat(electrobun): i18n system — @formatjs/intl + Svelte 5 runes + 3 locales
- i18n.svelte.ts: store with $state locale + createIntl(), t() function, formatDate/Number/RelativeTime, getDir() for RTL, async setLocale() - i18n.types.ts: TranslationKey union (codegen from en.json) - locales/en.json: 200+ strings in ICU MessageFormat - locales/pl.json: full Polish translation - locales/ar.json: partial Arabic (validates 6-form plural + RTL) - scripts/i18n-types.ts: codegen script for type-safe keys - 6 components wired: StatusBar, AgentPane, CommandPalette, SettingsDrawer, SplashScreen, ChatInput - Language selector in AppearanceSettings - App.svelte: document.dir reactive for RTL - CONTRIBUTING_I18N.md: guide for adding languages Note: currently Electrobun-only. Will extract to @agor/i18n shared package for both Tauri and Electrobun.
This commit is contained in:
parent
eee65070a8
commit
aae86a4001
16 changed files with 947 additions and 64 deletions
|
|
@ -2,6 +2,7 @@
|
|||
import { tick } from 'svelte';
|
||||
import ChatInput from './ChatInput.svelte';
|
||||
import type { AgentMessage, AgentStatus } from './agent-store.svelte.ts';
|
||||
import { t } from './i18n.svelte.ts';
|
||||
|
||||
interface Props {
|
||||
messages: AgentMessage[];
|
||||
|
|
@ -68,10 +69,10 @@
|
|||
}
|
||||
|
||||
function statusLabel(s: AgentStatus) {
|
||||
if (s === 'running') return 'Running';
|
||||
if (s === 'error') return 'Error';
|
||||
if (s === 'done') return 'Done';
|
||||
return 'Idle';
|
||||
if (s === 'running') return t('agent.status.running');
|
||||
if (s === 'error') return t('agent.status.error');
|
||||
if (s === 'done') return t('agent.status.done');
|
||||
return t('agent.status.idle');
|
||||
}
|
||||
|
||||
function onResizeMouseDown(e: MouseEvent) {
|
||||
|
|
@ -106,7 +107,7 @@
|
|||
<span class="strip-sep" aria-hidden="true"></span>
|
||||
<span class="strip-cost">{fmtCost(costUsd)}</span>
|
||||
{#if status === 'running' && onStop}
|
||||
<button class="strip-stop-btn" onclick={onStop} title="Stop agent" aria-label="Stop agent">
|
||||
<button class="strip-stop-btn" onclick={onStop} title={t('agent.prompt.stop')} aria-label={t('agent.prompt.stop')}>
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<rect x="3" y="3" width="10" height="10" rx="1" />
|
||||
</svg>
|
||||
|
|
@ -206,6 +207,7 @@
|
|||
{model}
|
||||
{provider}
|
||||
{contextPct}
|
||||
placeholder={t('agent.prompt.placeholder')}
|
||||
onSend={handleSend}
|
||||
onInput={(v) => (promptText = v)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
import { trackProject } from './health-store.svelte.ts';
|
||||
import { setAgentToastFn } from './agent-store.svelte.ts';
|
||||
import { appRpc } from './rpc.ts';
|
||||
import { initI18n, getDir, getLocale, t } from './i18n.svelte.ts';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────
|
||||
type AgentStatus = 'running' | 'idle' | 'stalled';
|
||||
|
|
@ -285,6 +286,12 @@
|
|||
});
|
||||
}
|
||||
|
||||
// ── i18n: keep <html> lang and dir in sync ─────────────────────
|
||||
$effect(() => {
|
||||
document.documentElement.lang = getLocale();
|
||||
document.documentElement.dir = getDir();
|
||||
});
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────
|
||||
onMount(() => {
|
||||
// Wire agent toast callback
|
||||
|
|
@ -296,6 +303,7 @@
|
|||
// Fix #8: Load groups FIRST, then apply saved active_group.
|
||||
// Other init tasks run in parallel.
|
||||
const initTasks = [
|
||||
initI18n().catch(console.error),
|
||||
themeStore.initTheme(appRpc).catch(console.error),
|
||||
fontStore.initFonts(appRpc).catch(console.error),
|
||||
keybindingStore.init(appRpc).catch(console.error),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
provider?: string;
|
||||
contextPct?: number;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
onSend?: () => void;
|
||||
onInput?: (v: string) => void;
|
||||
}
|
||||
|
|
@ -17,6 +18,7 @@
|
|||
provider = 'claude',
|
||||
contextPct = 78,
|
||||
disabled = false,
|
||||
placeholder = 'Ask Claude anything...',
|
||||
onSend,
|
||||
onInput,
|
||||
}: Props = $props();
|
||||
|
|
@ -94,7 +96,7 @@
|
|||
<textarea
|
||||
bind:this={textareaEl}
|
||||
class="chat-textarea"
|
||||
placeholder="Ask Claude anything..."
|
||||
{placeholder}
|
||||
rows="1"
|
||||
{value}
|
||||
oninput={handleInput}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { t } from './i18n.svelte.ts';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
|
|
@ -21,27 +22,46 @@
|
|||
window.dispatchEvent(new CustomEvent('palette-command', { detail: name }));
|
||||
}
|
||||
|
||||
const COMMANDS: Command[] = [
|
||||
{ id: 'new-terminal', label: 'New Terminal Tab', shortcut: 'Ctrl+`', action: () => dispatch('new-terminal') },
|
||||
{ id: 'settings', label: 'Open Settings', shortcut: 'Ctrl+,', action: () => dispatch('settings') },
|
||||
{ id: 'search', label: 'Search Messages', shortcut: 'Ctrl+Shift+F', action: () => dispatch('search') },
|
||||
{ id: 'new-project', label: 'Add Project', description: 'Open a project directory', action: () => dispatch('new-project') },
|
||||
{ id: 'clear-agent', label: 'Clear Agent Context', description: 'Reset agent session', action: () => dispatch('clear-agent') },
|
||||
{ id: 'copy-cost', label: 'Copy Session Cost', action: () => dispatch('copy-cost') },
|
||||
{ id: 'docs', label: 'Open Documentation', shortcut: 'F1', action: () => dispatch('docs') },
|
||||
{ id: 'theme', label: 'Change Theme', description: 'Switch between 17 themes', action: () => dispatch('theme') },
|
||||
{ id: 'split-h', label: 'Split Horizontally', shortcut: 'Ctrl+\\', action: () => dispatch('split-h') },
|
||||
{ id: 'split-v', label: 'Split Vertically', shortcut: 'Ctrl+Shift+\\', action: () => dispatch('split-v') },
|
||||
{ id: 'focus-next', label: 'Focus Next Project', shortcut: 'Ctrl+Tab', action: () => dispatch('focus-next') },
|
||||
{ id: 'focus-prev', label: 'Focus Previous Project', shortcut: 'Ctrl+Shift+Tab', action: () => dispatch('focus-prev') },
|
||||
{ id: 'close-tab', label: 'Close Tab', shortcut: 'Ctrl+W', action: () => dispatch('close-tab') },
|
||||
{ id: 'toggle-terminal', label: 'Toggle Terminal', shortcut: 'Ctrl+J', action: () => dispatch('toggle-terminal') },
|
||||
{ id: 'reload-plugins', label: 'Reload Plugins', action: () => dispatch('reload-plugins') },
|
||||
{ id: 'toggle-sidebar', label: 'Toggle Sidebar', shortcut: 'Ctrl+B', action: () => dispatch('toggle-sidebar') },
|
||||
{ id: 'zoom-in', label: 'Zoom In', shortcut: 'Ctrl+=', action: () => dispatch('zoom-in') },
|
||||
{ id: 'zoom-out', label: 'Zoom Out', shortcut: 'Ctrl+-', action: () => dispatch('zoom-out') },
|
||||
// Command definitions — labels resolved reactively via t()
|
||||
interface CommandDef {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
descKey?: string;
|
||||
shortcut?: string;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
const COMMAND_DEFS: CommandDef[] = [
|
||||
{ id: 'new-terminal', labelKey: 'palette.newTerminal', shortcut: 'Ctrl+`', action: () => dispatch('new-terminal') },
|
||||
{ id: 'settings', labelKey: 'palette.openSettings', shortcut: 'Ctrl+,', action: () => dispatch('settings') },
|
||||
{ id: 'search', labelKey: 'palette.searchMessages', shortcut: 'Ctrl+Shift+F', action: () => dispatch('search') },
|
||||
{ id: 'new-project', labelKey: 'palette.addProject', descKey: 'palette.addProjectDesc', action: () => dispatch('new-project') },
|
||||
{ id: 'clear-agent', labelKey: 'palette.clearAgent', descKey: 'palette.clearAgentDesc', action: () => dispatch('clear-agent') },
|
||||
{ id: 'copy-cost', labelKey: 'palette.copyCost', action: () => dispatch('copy-cost') },
|
||||
{ id: 'docs', labelKey: 'palette.openDocs', shortcut: 'F1', action: () => dispatch('docs') },
|
||||
{ id: 'theme', labelKey: 'palette.changeTheme', descKey: 'palette.changeThemeDesc', action: () => dispatch('theme') },
|
||||
{ id: 'split-h', labelKey: 'palette.splitH', shortcut: 'Ctrl+\\', action: () => dispatch('split-h') },
|
||||
{ id: 'split-v', labelKey: 'palette.splitV', shortcut: 'Ctrl+Shift+\\', action: () => dispatch('split-v') },
|
||||
{ id: 'focus-next', labelKey: 'palette.focusNext', shortcut: 'Ctrl+Tab', action: () => dispatch('focus-next') },
|
||||
{ id: 'focus-prev', labelKey: 'palette.focusPrev', shortcut: 'Ctrl+Shift+Tab', action: () => dispatch('focus-prev') },
|
||||
{ id: 'close-tab', labelKey: 'palette.closeTab', shortcut: 'Ctrl+W', action: () => dispatch('close-tab') },
|
||||
{ id: 'toggle-terminal', labelKey: 'palette.toggleTerminal', shortcut: 'Ctrl+J', action: () => dispatch('toggle-terminal') },
|
||||
{ id: 'reload-plugins', labelKey: 'palette.reloadPlugins', action: () => dispatch('reload-plugins') },
|
||||
{ id: 'toggle-sidebar', labelKey: 'palette.toggleSidebar', shortcut: 'Ctrl+B', action: () => dispatch('toggle-sidebar') },
|
||||
{ id: 'zoom-in', labelKey: 'palette.zoomIn', shortcut: 'Ctrl+=', action: () => dispatch('zoom-in') },
|
||||
{ id: 'zoom-out', labelKey: 'palette.zoomOut', shortcut: 'Ctrl+-', action: () => dispatch('zoom-out') },
|
||||
];
|
||||
|
||||
let COMMANDS = $derived<Command[]>(
|
||||
COMMAND_DEFS.map(d => ({
|
||||
id: d.id,
|
||||
label: t(d.labelKey as any),
|
||||
description: d.descKey ? t(d.descKey as any) : undefined,
|
||||
shortcut: d.shortcut,
|
||||
action: d.action,
|
||||
}))
|
||||
);
|
||||
|
||||
let query = $state('');
|
||||
let selectedIdx = $state(0);
|
||||
let inputEl = $state<HTMLInputElement | undefined>(undefined);
|
||||
|
|
@ -110,7 +130,7 @@
|
|||
class="palette-input"
|
||||
type="text"
|
||||
role="combobox"
|
||||
placeholder="Type a command..."
|
||||
placeholder={t('palette.placeholder')}
|
||||
bind:this={inputEl}
|
||||
bind:value={query}
|
||||
onkeydown={handleKeydown}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
import KeyboardSettings from './settings/KeyboardSettings.svelte';
|
||||
import RemoteMachinesSettings from './settings/RemoteMachinesSettings.svelte';
|
||||
import DiagnosticsTab from './settings/DiagnosticsTab.svelte';
|
||||
import { t } from './i18n.svelte.ts';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
|
|
@ -25,19 +26,23 @@
|
|||
icon: string;
|
||||
}
|
||||
|
||||
const CATEGORIES: Category[] = [
|
||||
{ id: 'appearance', label: 'Appearance', icon: '🎨' },
|
||||
{ id: 'agents', label: 'Agents', icon: '🤖' },
|
||||
{ id: 'security', label: 'Security', icon: '🔒' },
|
||||
{ id: 'projects', label: 'Projects', icon: '📁' },
|
||||
{ id: 'orchestration', label: 'Orchestration', icon: '⚙' },
|
||||
{ id: 'machines', label: 'Machines', icon: '🖥' },
|
||||
{ id: 'keyboard', label: 'Keyboard', icon: '⌨' },
|
||||
{ id: 'advanced', label: 'Advanced', icon: '🔧' },
|
||||
{ id: 'marketplace', label: 'Marketplace', icon: '🛒' },
|
||||
{ id: 'diagnostics', label: 'Diagnostics', icon: '📊' },
|
||||
const CATEGORY_DEFS: Array<{ id: CategoryId; icon: string; key: string }> = [
|
||||
{ id: 'appearance', icon: '🎨', key: 'settings.appearance' },
|
||||
{ id: 'agents', icon: '🤖', key: 'settings.agents' },
|
||||
{ id: 'security', icon: '🔒', key: 'settings.security' },
|
||||
{ id: 'projects', icon: '📁', key: 'settings.projects' },
|
||||
{ id: 'orchestration', icon: '⚙', key: 'settings.orchestration' },
|
||||
{ id: 'machines', icon: '🖥', key: 'settings.machines' },
|
||||
{ id: 'keyboard', icon: '⌨', key: 'settings.keyboard' },
|
||||
{ id: 'advanced', icon: '🔧', key: 'settings.advanced' },
|
||||
{ id: 'marketplace', icon: '🛒', key: 'settings.marketplace' },
|
||||
{ id: 'diagnostics', icon: '📊', key: 'settings.diagnostics' },
|
||||
];
|
||||
|
||||
let CATEGORIES = $derived<Category[]>(
|
||||
CATEGORY_DEFS.map(d => ({ id: d.id, label: t(d.key as any), icon: d.icon }))
|
||||
);
|
||||
|
||||
let activeCategory = $state<CategoryId>('appearance');
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
|
|
@ -66,8 +71,8 @@
|
|||
|
||||
<!-- Header -->
|
||||
<header class="drawer-header">
|
||||
<h2 class="drawer-title">Settings</h2>
|
||||
<button class="drawer-close" onclick={onClose} aria-label="Close settings">×</button>
|
||||
<h2 class="drawer-title">{t('settings.title')}</h2>
|
||||
<button class="drawer-close" onclick={onClose} aria-label={t('settings.close')}>×</button>
|
||||
</header>
|
||||
|
||||
<!-- Body: sidebar + content -->
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
* Auto-dismisses when the `ready` prop becomes true.
|
||||
* Fade-out transition: 300ms opacity.
|
||||
*/
|
||||
import { t } from './i18n.svelte.ts';
|
||||
|
||||
interface Props {
|
||||
/** Set to true when app initialization is complete. */
|
||||
|
|
@ -41,7 +42,7 @@
|
|||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
</div>
|
||||
<div class="loading-label">Loading...</div>
|
||||
<div class="loading-label">{t('splash.loading')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
getAttentionQueue,
|
||||
type ProjectHealth,
|
||||
} from './health-store.svelte.ts';
|
||||
import { t } from './i18n.svelte.ts';
|
||||
|
||||
interface Props {
|
||||
projectCount: number;
|
||||
|
|
@ -60,21 +61,21 @@
|
|||
<span class="status-segment">
|
||||
<span class="dot green pulse-dot" aria-hidden="true"></span>
|
||||
<span class="val">{health.running}</span>
|
||||
<span>running</span>
|
||||
<span>{t('statusbar.running')}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if health.idle > 0}
|
||||
<span class="status-segment">
|
||||
<span class="dot gray" aria-hidden="true"></span>
|
||||
<span class="val">{health.idle}</span>
|
||||
<span>idle</span>
|
||||
<span>{t('statusbar.idle')}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if health.stalled > 0}
|
||||
<span class="status-segment stalled">
|
||||
<span class="dot orange" aria-hidden="true"></span>
|
||||
<span class="val">{health.stalled}</span>
|
||||
<span>stalled</span>
|
||||
<span>{t('statusbar.stalled')}</span>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
|
|
@ -84,11 +85,11 @@
|
|||
class="status-segment attn-btn"
|
||||
class:attn-open={showAttention}
|
||||
onclick={() => showAttention = !showAttention}
|
||||
title="Needs attention"
|
||||
title={t('statusbar.needsAttention')}
|
||||
>
|
||||
<span class="dot orange pulse-dot" aria-hidden="true"></span>
|
||||
<span class="val">{attentionQueue.length}</span>
|
||||
<span>attention</span>
|
||||
<span>{t('statusbar.attention')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
|
|
@ -96,32 +97,32 @@
|
|||
|
||||
<!-- Right: aggregates -->
|
||||
{#if health.totalBurnRatePerHour > 0}
|
||||
<span class="status-segment burn" title="Burn rate">
|
||||
<span class="status-segment burn" title={t('statusbar.burnRate')}>
|
||||
<span class="val">{formatRate(health.totalBurnRatePerHour)}</span>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<span class="status-segment" title="Active group">
|
||||
<span class="status-segment" title={t('statusbar.activeGroup')}>
|
||||
<span class="val">{groupName}</span>
|
||||
</span>
|
||||
<span class="status-segment" title="Projects">
|
||||
<span class="status-segment" title={t('statusbar.projects')}>
|
||||
<span class="val">{projectCount}</span>
|
||||
<span>projects</span>
|
||||
<span>{t('statusbar.projects')}</span>
|
||||
</span>
|
||||
<span class="status-segment" title="Session duration">
|
||||
<span>session</span>
|
||||
<span class="status-segment" title={t('statusbar.session')}>
|
||||
<span>{t('statusbar.session')}</span>
|
||||
<span class="val">{sessionDuration}</span>
|
||||
</span>
|
||||
<span class="status-segment" title="Total tokens">
|
||||
<span>tokens</span>
|
||||
<span class="status-segment" title={t('statusbar.tokens')}>
|
||||
<span>{t('statusbar.tokens')}</span>
|
||||
<span class="val">{fmtTokens(totalTokens)}</span>
|
||||
</span>
|
||||
<span class="status-segment" title="Total cost">
|
||||
<span>cost</span>
|
||||
<span class="status-segment" title={t('statusbar.cost')}>
|
||||
<span>{t('statusbar.cost')}</span>
|
||||
<span class="val cost">{fmtCost(totalCost)}</span>
|
||||
</span>
|
||||
|
||||
<kbd class="palette-hint" title="Search (Ctrl+Shift+F)">Ctrl+Shift+F</kbd>
|
||||
<kbd class="palette-hint" title={t('statusbar.search')}>Ctrl+Shift+F</kbd>
|
||||
</footer>
|
||||
|
||||
<!-- Attention dropdown -->
|
||||
|
|
|
|||
153
ui-electrobun/src/mainview/i18n.svelte.ts
Normal file
153
ui-electrobun/src/mainview/i18n.svelte.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/**
|
||||
* i18n store — Svelte 5 runes + @formatjs/intl.
|
||||
*
|
||||
* Usage:
|
||||
* import { t, setLocale, getLocale, getDir, initI18n } from './i18n.svelte.ts';
|
||||
* const label = t('agent.status.running');
|
||||
* const msg = t('sidebar.notifCount', { count: 3 });
|
||||
*/
|
||||
|
||||
import { createIntl, createIntlCache, type IntlShape } from '@formatjs/intl';
|
||||
import type { TranslationKey } from './i18n.types';
|
||||
import { appRpc } from './rpc.ts';
|
||||
|
||||
// ── Locale metadata ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface LocaleMeta {
|
||||
tag: string;
|
||||
label: string;
|
||||
nativeLabel: string;
|
||||
dir: 'ltr' | 'rtl';
|
||||
}
|
||||
|
||||
export const AVAILABLE_LOCALES: LocaleMeta[] = [
|
||||
{ tag: 'en', label: 'English', nativeLabel: 'English', dir: 'ltr' },
|
||||
{ tag: 'pl', label: 'Polish', nativeLabel: 'Polski', dir: 'ltr' },
|
||||
{ tag: 'ar', label: 'Arabic', nativeLabel: '\u0627\u0644\u0639\u0631\u0628\u064a\u0629', dir: 'rtl' },
|
||||
];
|
||||
|
||||
// ── Reactive state ───────────────────────────────────────────────────────────
|
||||
|
||||
let locale = $state<string>('en');
|
||||
/** Version counter — incremented on every setLocale() so $derived consumers re-evaluate. */
|
||||
let _v = $state(0);
|
||||
|
||||
// ── @formatjs/intl instance ──────────────────────────────────────────────────
|
||||
|
||||
const cache = createIntlCache();
|
||||
type Messages = Record<string, string>;
|
||||
|
||||
let _messages: Messages = {};
|
||||
let _intl: IntlShape<string> = createIntl({ locale: 'en', messages: {} }, cache);
|
||||
|
||||
// ── Locale loaders (dynamic import) ──────────────────────────────────────────
|
||||
|
||||
const loaders: Record<string, () => Promise<Messages>> = {
|
||||
en: () => import('../../locales/en.json').then(m => m.default as Messages),
|
||||
pl: () => import('../../locales/pl.json').then(m => m.default as Messages),
|
||||
ar: () => import('../../locales/ar.json').then(m => m.default as Messages),
|
||||
};
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Change the active locale. Dynamically loads the JSON message file,
|
||||
* recreates the intl instance, and bumps the reactivity version counter.
|
||||
*/
|
||||
export async function setLocale(tag: string): Promise<void> {
|
||||
const loader = loaders[tag];
|
||||
if (!loader) {
|
||||
console.warn(`[i18n] unknown locale: ${tag}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
_messages = await loader();
|
||||
} catch (err) {
|
||||
console.error(`[i18n] failed to load locale "${tag}":`, err);
|
||||
return;
|
||||
}
|
||||
|
||||
locale = tag;
|
||||
_intl = createIntl({ locale: tag, messages: _messages }, cache);
|
||||
_v++;
|
||||
|
||||
// Persist preference
|
||||
try {
|
||||
await appRpc?.request['settings.set']({ key: 'locale', value: tag });
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a message key, optionally with ICU values.
|
||||
* Reads `_v` to trigger Svelte 5 reactivity on locale change.
|
||||
*/
|
||||
export function t(key: TranslationKey, values?: Record<string, any>): string {
|
||||
// Touch reactive version so $derived consumers re-run.
|
||||
void _v;
|
||||
|
||||
const msg = _messages[key];
|
||||
if (!msg) return key;
|
||||
|
||||
try {
|
||||
return _intl.formatMessage({ id: key, defaultMessage: msg }, values);
|
||||
} catch (err) {
|
||||
console.warn(`[i18n] format error for "${key}":`, err);
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
|
||||
/** Format a Date using the current locale. */
|
||||
export function formatDate(date: Date | number, options?: Intl.DateTimeFormatOptions): string {
|
||||
void _v;
|
||||
return _intl.formatDate(date, options);
|
||||
}
|
||||
|
||||
/** Format a number using the current locale. */
|
||||
export function formatNumber(num: number, options?: Intl.NumberFormatOptions): string {
|
||||
void _v;
|
||||
return _intl.formatNumber(num, options);
|
||||
}
|
||||
|
||||
/** Format a relative time (e.g. -5, 'minute' -> "5 minutes ago"). */
|
||||
export function formatRelativeTime(
|
||||
value: number,
|
||||
unit: Intl.RelativeTimeFormatUnit,
|
||||
options?: Parameters<IntlShape['formatRelativeTime']>[2],
|
||||
): string {
|
||||
void _v;
|
||||
return _intl.formatRelativeTime(value, unit, options);
|
||||
}
|
||||
|
||||
/** Current locale tag (e.g. 'en', 'pl', 'ar'). */
|
||||
export function getLocale(): string {
|
||||
void _v;
|
||||
return locale;
|
||||
}
|
||||
|
||||
/** Text direction for the current locale. */
|
||||
export function getDir(): 'ltr' | 'rtl' {
|
||||
void _v;
|
||||
const meta = AVAILABLE_LOCALES.find(l => l.tag === locale);
|
||||
return meta?.dir ?? 'ltr';
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize i18n on app startup.
|
||||
* Loads saved locale from settings, falls back to 'en'.
|
||||
*/
|
||||
export async function initI18n(): Promise<void> {
|
||||
// Load PluralRules polyfill for WebKitGTK (needed for Arabic 6-form plurals)
|
||||
await import('@formatjs/intl-pluralrules/polyfill-force.js').catch(() => {});
|
||||
|
||||
let savedLocale = 'en';
|
||||
|
||||
try {
|
||||
const result = await appRpc?.request['settings.get']({ key: 'locale' });
|
||||
if (result?.value && loaders[result.value]) {
|
||||
savedLocale = result.value;
|
||||
}
|
||||
} catch { /* use default */ }
|
||||
|
||||
await setLocale(savedLocale);
|
||||
}
|
||||
140
ui-electrobun/src/mainview/i18n.types.ts
Normal file
140
ui-electrobun/src/mainview/i18n.types.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
/**
|
||||
* Auto-generated by scripts/i18n-types.ts — do not edit manually.
|
||||
* Run: bun scripts/i18n-types.ts
|
||||
*/
|
||||
|
||||
export type TranslationKey =
|
||||
| 'agent.contextMeter'
|
||||
| 'agent.prompt.placeholder'
|
||||
| 'agent.prompt.send'
|
||||
| 'agent.prompt.stop'
|
||||
| 'agent.status.done'
|
||||
| 'agent.status.error'
|
||||
| 'agent.status.idle'
|
||||
| 'agent.status.running'
|
||||
| 'agent.status.stalled'
|
||||
| 'agent.status.thinking'
|
||||
| 'agent.tokens'
|
||||
| 'agent.toolCall'
|
||||
| 'agent.toolResult'
|
||||
| 'common.add'
|
||||
| 'common.back'
|
||||
| 'common.cancel'
|
||||
| 'common.close'
|
||||
| 'common.confirm'
|
||||
| 'common.delete'
|
||||
| 'common.edit'
|
||||
| 'common.noItems'
|
||||
| 'common.refresh'
|
||||
| 'common.save'
|
||||
| 'comms.channels'
|
||||
| 'comms.directMessages'
|
||||
| 'comms.placeholder'
|
||||
| 'comms.sendMessage'
|
||||
| 'errors.connectionFailed'
|
||||
| 'errors.fileNotFound'
|
||||
| 'errors.generic'
|
||||
| 'errors.sessionExpired'
|
||||
| 'errors.unhandled'
|
||||
| 'files.empty'
|
||||
| 'files.modified'
|
||||
| 'files.open'
|
||||
| 'files.save'
|
||||
| 'files.saving'
|
||||
| 'files.tooLarge'
|
||||
| 'notifications.clearAll'
|
||||
| 'notifications.noNotifications'
|
||||
| 'notifications.title'
|
||||
| 'palette.addProject'
|
||||
| 'palette.addProjectDesc'
|
||||
| 'palette.changeTheme'
|
||||
| 'palette.changeThemeDesc'
|
||||
| 'palette.clearAgent'
|
||||
| 'palette.clearAgentDesc'
|
||||
| 'palette.closeTab'
|
||||
| 'palette.copyCost'
|
||||
| 'palette.focusNext'
|
||||
| 'palette.focusPrev'
|
||||
| 'palette.newTerminal'
|
||||
| 'palette.openDocs'
|
||||
| 'palette.openSettings'
|
||||
| 'palette.placeholder'
|
||||
| 'palette.reloadPlugins'
|
||||
| 'palette.searchMessages'
|
||||
| 'palette.splitH'
|
||||
| 'palette.splitV'
|
||||
| 'palette.title'
|
||||
| 'palette.toggleSidebar'
|
||||
| 'palette.toggleTerminal'
|
||||
| 'palette.zoomIn'
|
||||
| 'palette.zoomOut'
|
||||
| 'project.clone'
|
||||
| 'project.cloneBranch'
|
||||
| 'project.cwd'
|
||||
| 'project.deleteConfirm'
|
||||
| 'project.emptyGroup'
|
||||
| 'project.name'
|
||||
| 'search.noResults'
|
||||
| 'search.placeholder'
|
||||
| 'search.resultsCount'
|
||||
| 'search.searching'
|
||||
| 'settings.advanced'
|
||||
| 'settings.agents'
|
||||
| 'settings.appearance'
|
||||
| 'settings.close'
|
||||
| 'settings.cursorBlink'
|
||||
| 'settings.cursorOff'
|
||||
| 'settings.cursorOn'
|
||||
| 'settings.customTheme'
|
||||
| 'settings.deleteTheme'
|
||||
| 'settings.diagnostics'
|
||||
| 'settings.editTheme'
|
||||
| 'settings.keyboard'
|
||||
| 'settings.language'
|
||||
| 'settings.machines'
|
||||
| 'settings.marketplace'
|
||||
| 'settings.orchestration'
|
||||
| 'settings.projects'
|
||||
| 'settings.scrollback'
|
||||
| 'settings.scrollbackHint'
|
||||
| 'settings.security'
|
||||
| 'settings.termCursor'
|
||||
| 'settings.termFont'
|
||||
| 'settings.theme'
|
||||
| 'settings.title'
|
||||
| 'settings.uiFont'
|
||||
| 'sidebar.addGroup'
|
||||
| 'sidebar.addProject'
|
||||
| 'sidebar.close'
|
||||
| 'sidebar.groupName'
|
||||
| 'sidebar.maximize'
|
||||
| 'sidebar.minimize'
|
||||
| 'sidebar.notifCount'
|
||||
| 'sidebar.notifications'
|
||||
| 'sidebar.settings'
|
||||
| 'splash.loading'
|
||||
| 'statusbar.activeGroup'
|
||||
| 'statusbar.attention'
|
||||
| 'statusbar.burnRate'
|
||||
| 'statusbar.cost'
|
||||
| 'statusbar.idle'
|
||||
| 'statusbar.needsAttention'
|
||||
| 'statusbar.projects'
|
||||
| 'statusbar.running'
|
||||
| 'statusbar.search'
|
||||
| 'statusbar.session'
|
||||
| 'statusbar.stalled'
|
||||
| 'statusbar.tokens'
|
||||
| 'tasks.addTask'
|
||||
| 'tasks.blocked'
|
||||
| 'tasks.deleteTask'
|
||||
| 'tasks.done'
|
||||
| 'tasks.inProgress'
|
||||
| 'tasks.review'
|
||||
| 'tasks.taskCount'
|
||||
| 'tasks.todo'
|
||||
| 'terminal.addTab'
|
||||
| 'terminal.closeTab'
|
||||
| 'terminal.collapse'
|
||||
| 'terminal.expand'
|
||||
| 'terminal.shell';
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
import { THEMES, THEME_GROUPS, getPalette, type ThemeId, type ThemeMeta } from '../themes.ts';
|
||||
import { themeStore } from '../theme-store.svelte.ts';
|
||||
import { fontStore } from '../font-store.svelte.ts';
|
||||
import { t, getLocale, setLocale, AVAILABLE_LOCALES } from '../i18n.svelte.ts';
|
||||
import ThemeEditor from './ThemeEditor.svelte';
|
||||
|
||||
const UI_FONTS = [
|
||||
|
|
@ -50,6 +51,16 @@
|
|||
let themeOpen = $state(false);
|
||||
let uiFontOpen = $state(false);
|
||||
let termFontOpen = $state(false);
|
||||
let langOpen = $state(false);
|
||||
|
||||
// ── Language ──────────────────────────────────────────────────────────────
|
||||
let currentLocale = $derived(getLocale());
|
||||
let langLabel = $derived(AVAILABLE_LOCALES.find(l => l.tag === currentLocale)?.nativeLabel ?? 'English');
|
||||
|
||||
function selectLang(tag: string): void {
|
||||
langOpen = false;
|
||||
setLocale(tag);
|
||||
}
|
||||
|
||||
// ── All themes (built-in + custom) ────────────────────────────────────────
|
||||
let allThemes = $derived<ThemeMeta[]>([
|
||||
|
|
@ -106,7 +117,7 @@
|
|||
appRpc?.request['settings.set']({ key: 'scrollback', value: String(v) }).catch(console.error);
|
||||
}
|
||||
|
||||
function closeAll(): void { themeOpen = false; uiFontOpen = false; termFontOpen = false; }
|
||||
function closeAll(): void { themeOpen = false; uiFontOpen = false; termFontOpen = false; langOpen = false; }
|
||||
function handleOutsideClick(e: MouseEvent): void {
|
||||
if (!(e.target as HTMLElement).closest('.dd-wrap')) closeAll();
|
||||
}
|
||||
|
|
@ -149,7 +160,7 @@
|
|||
/>
|
||||
{:else}
|
||||
|
||||
<h3 class="sh">Theme</h3>
|
||||
<h3 class="sh">{t('settings.theme')}</h3>
|
||||
<div class="field">
|
||||
<div class="dd-wrap">
|
||||
<button class="dd-btn" onclick={() => { themeOpen = !themeOpen; uiFontOpen = false; termFontOpen = false; }}>
|
||||
|
|
@ -186,7 +197,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="sh">UI Font</h3>
|
||||
<h3 class="sh">{t('settings.uiFont')}</h3>
|
||||
<div class="field row">
|
||||
<div class="dd-wrap flex1">
|
||||
<button class="dd-btn" onclick={() => { uiFontOpen = !uiFontOpen; themeOpen = false; termFontOpen = false; }}>
|
||||
|
|
@ -211,7 +222,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="sh">Terminal Font</h3>
|
||||
<h3 class="sh">{t('settings.termFont')}</h3>
|
||||
<div class="field row">
|
||||
<div class="dd-wrap flex1">
|
||||
<button class="dd-btn" onclick={() => { termFontOpen = !termFontOpen; themeOpen = false; uiFontOpen = false; }}>
|
||||
|
|
@ -236,7 +247,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="sh">Terminal Cursor</h3>
|
||||
<h3 class="sh">{t('settings.termCursor')}</h3>
|
||||
<div class="field row">
|
||||
<div class="seg">
|
||||
{#each ['block', 'line', 'underline'] as s}
|
||||
|
|
@ -249,12 +260,35 @@
|
|||
</label>
|
||||
</div>
|
||||
|
||||
<h3 class="sh">Scrollback</h3>
|
||||
<h3 class="sh">{t('settings.scrollback')}</h3>
|
||||
<div class="field row">
|
||||
<input type="number" class="num-in" min="100" max="100000" step="100" value={scrollback}
|
||||
onchange={e => persistScrollback(parseInt((e.target as HTMLInputElement).value, 10) || 1000)}
|
||||
aria-label="Scrollback lines" />
|
||||
<span class="hint">lines (100–100k)</span>
|
||||
<span class="hint">{t('settings.scrollbackHint')}</span>
|
||||
</div>
|
||||
|
||||
<h3 class="sh">{t('settings.language')}</h3>
|
||||
<div class="field">
|
||||
<div class="dd-wrap">
|
||||
<button class="dd-btn" onclick={() => { langOpen = !langOpen; themeOpen = false; uiFontOpen = false; termFontOpen = false; }}>
|
||||
{langLabel}
|
||||
<svg class="chev" class:open={langOpen} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
{#if langOpen}
|
||||
<ul class="dd-list" role="listbox">
|
||||
{#each AVAILABLE_LOCALES as loc}
|
||||
<li class="dd-item" class:sel={currentLocale === loc.tag} role="option" aria-selected={currentLocale === loc.tag}
|
||||
tabindex="0" onclick={() => selectLang(loc.tag)}
|
||||
onkeydown={e => (e.key === 'Enter' || e.key === ' ') && selectLang(loc.tag)}
|
||||
>
|
||||
<span class="dd-item-label">{loc.nativeLabel}</span>
|
||||
<span style="color: var(--ctp-overlay0); font-size: 0.6875rem;">{loc.label}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/if}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue