feat: add app localization foundation

This commit is contained in:
lucaronin
2026-04-26 08:18:47 +02:00
parent e4083ded1a
commit f9719f11b5
27 changed files with 1069 additions and 196 deletions

View File

@@ -553,6 +553,17 @@ The app uses internal light and dark themes owned by Tolaria (see [ADR-0081](adr
2. **Editor theme** (`src/theme.json`): BlockNote typography, flattened to CSS vars by `useEditorTheme`
3. **Runtime theme bridge**: Applies `data-theme` and `.dark` for shadcn/ui, while CodeMirror and editor-specific consumers derive any non-CSS-variable values from the same semantic contract
## Localization
App UI strings are centralized in `src/lib/i18n.ts` (see [ADR-0084](adr/0084-app-localization-foundation.md)):
- `AppLocale`: currently `'en' | 'zh-Hans'`
- `UiLanguagePreference`: `'system' | AppLocale`; persisted settings serialize `system` as `null`
- `resolveEffectiveLocale()`: maps an explicit preference or system/browser language list to the effective supported locale
- `translate()` / `createTranslator()`: resolve keys with English fallback and simple `{name}` interpolation
`App.tsx` owns the effective locale and passes it to localized app chrome through props. Settings and command-palette language commands call back into `saveSettings`, so UI language changes update the current session without touching vault content or reopening the vault.
## Inspector Abstraction
The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
@@ -663,11 +674,12 @@ interface Settings {
anonymous_id: string | null
release_channel: string | null // null = stable default, "alpha" = every-push prerelease feed
theme_mode: 'light' | 'dark' | null
ui_language: 'en' | 'zh-Hans' | null
default_ai_agent: 'claude_code' | 'codex' | null
}
```
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
## Telemetry

View File

@@ -32,6 +32,7 @@ Examples:
- ✅ Vault: `_pinned_properties` in a Type note (every device should show the same pinned properties)
- ✅ Vault: `_icon: shapes` in a Type note (icon is part of the type's identity)
- ✅ App settings: `zoom: 1.3` (machine-specific preference)
- ✅ App settings: `ui_language: "zh-Hans"` (installation-specific UI language)
### No hardcoded exceptions
@@ -99,6 +100,7 @@ flowchart LR
| Frontmatter parsing | gray_matter | 0.2 |
| AI (agent panel) | CLI agent adapters (Claude Code + Codex) | - |
| Search | Keyword (walkdir-based file scan) | - |
| Localization | App-owned dictionary (`src/lib/i18n.ts`) | English fallback + `zh-Hans` |
| MCP | @modelcontextprotocol/sdk | 1.0 |
| Tests | Vitest (unit), Playwright (E2E/smoke), cargo test (Rust) | - |
| Package manager | pnpm | - |
@@ -414,6 +416,12 @@ The app uses internal app-owned light and dark themes (see [ADR-0081](adr/0081-i
2. **Editor theme** (`src/theme.json`): BlockNote-specific typography. Flattened to CSS vars by `useEditorTheme`; editor colors resolve through the same semantic app variables.
3. **Theme runtime**: Applies `data-theme` and the shadcn-compatible `.dark` class before React consumers render, with a localStorage mirror to avoid startup flash when dark mode is selected.
## Localization
Tolaria's app chrome uses an app-owned localization layer in `src/lib/i18n.ts` (see [ADR-0084](adr/0084-app-localization-foundation.md)). English is the canonical fallback, and Simplified Chinese (`zh-Hans`) is the first additional locale. The installation-local `ui_language` setting stores an explicit locale when the user chooses one; `null` means "follow the system language when Tolaria supports it, otherwise English." Missing translation keys fall back to English so partially translated locales do not render broken placeholders.
`App.tsx` derives the effective locale from settings and browser/system language hints, then passes it down to localized surfaces. Settings exposes a keyboard-accessible shadcn `Select`, and the command palette includes actions to open language settings or switch directly to a supported language.
## Vault Management
### Vault List
@@ -763,7 +771,7 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
| `useCommitFlow` | Commit dialog state, shared manual/automatic checkpoint runner | Git commit/push orchestration |
| `useGitRemoteStatus` | `remoteStatus`, `refreshRemoteStatus()` | On-demand remote detection for commit UI |
| `useUnifiedSearch` | Query, results, loading state | Keyword search |
| `useSettings` | App settings (telemetry, release channel, theme mode, auto-sync interval, AutoGit thresholds, default AI agent) | Persistent settings |
| `useSettings` | App settings (telemetry, release channel, theme mode, UI language, auto-sync interval, AutoGit thresholds, default AI agent) | Persistent settings |
| `useVaultConfig` | Per-vault UI preferences | Vault-specific config |
| `appCommandDispatcher` | Canonical shortcut/menu command IDs | Shared execution path for renderer and native menu commands |

View File

@@ -157,6 +157,7 @@ tolaria/
│ ├── lib/
│ │ ├── aiAgents.ts # Shared agent registry + status helpers
│ │ ├── appUpdater.ts # Frontend wrapper around channel-aware updater commands
│ │ ├── i18n.ts # App-owned localization dictionary and locale resolution
│ │ ├── releaseChannel.ts # Alpha/stable normalization helpers
│ │ └── utils.ts # Tailwind merge + cn() helper
│ │
@@ -294,12 +295,12 @@ tolaria/
| File | Why it matters |
|------|---------------|
| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, theme mode, auto-sync interval, default AI agent). |
| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, theme mode, UI language, auto-sync interval, default AI agent). |
| `src/lib/releaseChannel.ts` | Normalizes persisted updater-channel values (`stable` default, optional `alpha`). |
| `src/lib/appUpdater.ts` | Frontend wrapper for channel-aware updater commands. |
| `src/hooks/useMainWindowSizeConstraints.ts` | Derives the main-window minimum width from the visible panes and asks Tauri to grow back to fit wider layouts. |
| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow). |
| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, sync interval, default AI agent, and the vault-level explicit organization toggle. |
| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, sync interval, UI language, default AI agent, and the vault-level explicit organization toggle. |
| `src/hooks/useUpdater.ts` | In-app updates using the selected alpha/stable feed. |
## Architecture Patterns

View File

@@ -0,0 +1,35 @@
---
type: ADR
id: "0084"
title: "App-owned localization foundation"
status: active
date: 2026-04-26
---
## Context
Tolaria was effectively English-only. Users requested a general i18n foundation and Chinese-language support. We need a path that lets the UI adopt additional locales without pushing UI-language preferences into vault files or making every partially translated string a runtime failure.
## Decision
Tolaria owns a dependency-free frontend localization layer in `src/lib/i18n.ts`.
- English is the canonical fallback locale.
- Simplified Chinese (`zh-Hans`) is the first additional locale.
- `ui_language` is an installation-local app setting in `~/.config/com.tolaria.app/settings.json`; `null` means "follow system language when supported, otherwise English".
- Missing translation keys fall back to English.
- App-level chrome receives locale through props from `App.tsx`, following the existing props-down/callbacks-up architecture instead of introducing global React context.
- Language switching is exposed in Settings and through command-palette actions.
## Alternatives considered
- **Add an i18n dependency now**: useful long term, but unnecessary for the first locale and would add framework surface before we know Tolaria's locale workflow.
- **Store language in the vault**: rejected because UI language is an installation preference, not content structure.
- **Translate ad hoc strings inline**: rejected because it would make fallback behavior inconsistent and future locales expensive.
## Consequences
- New UI strings should be added to `src/lib/i18n.ts` first and rendered through `translate()` / `createTranslator()` where the surface already receives locale.
- Partially translated locales remain usable because English is the fallback for missing keys.
- Locale choice changes UI chrome immediately after settings save or command-palette language commands without reopening the vault.
- Larger feature surfaces can migrate to the shared localization module incrementally.

View File

@@ -18,6 +18,7 @@ pub struct Settings {
pub anonymous_id: Option<String>,
pub release_channel: Option<String>,
pub theme_mode: Option<String>,
pub ui_language: Option<String>,
pub initial_h1_auto_rename_enabled: Option<bool>,
pub default_ai_agent: Option<String>,
}
@@ -61,6 +62,34 @@ pub fn normalize_theme_mode(value: Option<&str>) -> Option<String> {
}
}
fn canonical_language_code(value: &str) -> Option<String> {
let code = value.trim().replace('_', "-").to_ascii_lowercase();
if code.is_empty() {
None
} else {
Some(code)
}
}
fn is_english_language(code: &str) -> bool {
code == "en" || code.starts_with("en-")
}
fn is_simplified_chinese_language(code: &str) -> bool {
matches!(code, "zh" | "zh-cn" | "zh-hans" | "zh-sg")
}
pub fn normalize_ui_language(value: Option<&str>) -> Option<String> {
let language = canonical_language_code(value?)?;
if is_english_language(&language) {
return Some("en".to_string());
}
if is_simplified_chinese_language(&language) {
return Some("zh-Hans".to_string());
}
None
}
fn normalize_settings(settings: Settings) -> Settings {
Settings {
auto_pull_interval_minutes: settings.auto_pull_interval_minutes,
@@ -78,6 +107,7 @@ fn normalize_settings(settings: Settings) -> Settings {
anonymous_id: normalize_optional_string(settings.anonymous_id),
release_channel: normalize_release_channel(settings.release_channel.as_deref()),
theme_mode: normalize_theme_mode(settings.theme_mode.as_deref()),
ui_language: normalize_ui_language(settings.ui_language.as_deref()),
initial_h1_auto_rename_enabled: settings.initial_h1_auto_rename_enabled,
default_ai_agent: normalize_default_ai_agent(settings.default_ai_agent.as_deref()),
}
@@ -219,6 +249,7 @@ mod tests {
anonymous_id: Some("abc-123-uuid".to_string()),
release_channel: Some("alpha".to_string()),
theme_mode: Some("dark".to_string()),
ui_language: Some("zh-Hans".to_string()),
initial_h1_auto_rename_enabled: Some(false),
default_ai_agent: Some("codex".to_string()),
};
@@ -245,6 +276,7 @@ mod tests {
auto_advance_inbox_after_organize: Some(true),
release_channel: Some("alpha".to_string()),
theme_mode: Some("dark".to_string()),
ui_language: Some("zh-Hans".to_string()),
initial_h1_auto_rename_enabled: Some(false),
default_ai_agent: Some("codex".to_string()),
..Default::default()
@@ -256,6 +288,7 @@ mod tests {
assert_eq!(loaded.auto_advance_inbox_after_organize, Some(true));
assert_eq!(loaded.release_channel.as_deref(), Some("alpha"));
assert_eq!(loaded.theme_mode.as_deref(), Some("dark"));
assert_eq!(loaded.ui_language.as_deref(), Some("zh-Hans"));
assert_eq!(loaded.initial_h1_auto_rename_enabled, Some(false));
assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex"));
}
@@ -266,12 +299,14 @@ mod tests {
anonymous_id: Some(" test-uuid ".to_string()),
release_channel: Some(" alpha ".to_string()),
theme_mode: Some(" dark ".to_string()),
ui_language: Some(" zh-cn ".to_string()),
default_ai_agent: Some(" codex ".to_string()),
..Default::default()
});
assert_eq!(loaded.anonymous_id.as_deref(), Some("test-uuid"));
assert_eq!(loaded.release_channel.as_deref(), Some("alpha"));
assert_eq!(loaded.theme_mode.as_deref(), Some("dark"));
assert_eq!(loaded.ui_language.as_deref(), Some("zh-Hans"));
assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex"));
}
@@ -322,6 +357,24 @@ mod tests {
assert!(loaded.theme_mode.is_none());
}
#[test]
fn test_invalid_ui_language_is_filtered() {
let loaded = save_and_reload(Settings {
ui_language: Some("fr-FR".to_string()),
..Default::default()
});
assert!(loaded.ui_language.is_none());
}
#[test]
fn test_ui_language_aliases_are_canonicalized() {
assert_eq!(normalize_ui_language(Some("en-US")).as_deref(), Some("en"));
assert_eq!(
normalize_ui_language(Some("zh_CN")).as_deref(),
Some("zh-Hans")
);
}
#[test]
fn test_get_settings_normalizes_legacy_beta_channel() {
let dir = tempfile::TempDir::new().unwrap();

View File

@@ -90,6 +90,13 @@ import { openNoteListPropertiesPicker } from './components/note-list/noteListPro
import type { NoteListMultiSelectionCommands } from './components/note-list/multiSelectionCommands'
import { focusNoteIconPropertyEditor } from './components/noteIconPropertyEvents'
import { trackEvent } from './lib/telemetry'
import {
SYSTEM_UI_LANGUAGE,
getBrowserLanguagePreferences,
resolveEffectiveLocale,
serializeUiLanguagePreference,
type UiLanguagePreference,
} from './lib/i18n'
import { normalizeReleaseChannel } from './lib/releaseChannel'
import {
buildVaultAiGuidanceRefreshKey,
@@ -375,12 +382,27 @@ function App() {
})
}, [updateConfig, vaultConfig.inbox?.noteListProperties])
const { settings, loaded: settingsLoaded, saveSettings } = useSettings()
const systemLocale = useMemo(
() => resolveEffectiveLocale(SYSTEM_UI_LANGUAGE, getBrowserLanguagePreferences()),
[],
)
const appLocale = useMemo(
() => resolveEffectiveLocale(settings.ui_language, [systemLocale]),
[settings.ui_language, systemLocale],
)
const selectedUiLanguage = settings.ui_language ?? SYSTEM_UI_LANGUAGE
useEffect(() => {
document.documentElement.lang = appLocale
}, [appLocale])
useThemeMode(settings.theme_mode, settingsLoaded)
const documentThemeMode = useDocumentThemeMode()
const handleToggleThemeMode = useCallback(() => {
const theme_mode = documentThemeMode === 'dark' ? 'light' : 'dark'
void saveSettings({ ...settings, theme_mode })
}, [documentThemeMode, saveSettings, settings])
const handleSetUiLanguage = useCallback((uiLanguage: UiLanguagePreference) => {
void saveSettings({ ...settings, ui_language: serializeUiLanguagePreference(uiLanguage) })
}, [saveSettings, settings])
const aiAgentPreferences = useAiAgentPreferences({
settings,
saveSettings,
@@ -1320,6 +1342,10 @@ function App() {
onRestoreGettingStarted: cloneGettingStartedVault,
isGettingStartedHidden: vaultSwitcher.isGettingStartedHidden,
vaultCount: vaultSwitcher.allVaults.length,
locale: appLocale,
systemLocale,
selectedUiLanguage,
onSetUiLanguage: handleSetUiLanguage,
mcpStatus,
onInstallMcp: openMcpSetupDialog,
onOpenAiAgents: dialogs.openSettings,
@@ -1459,7 +1485,7 @@ function App() {
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} />
) : (
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={handleReplaceActiveTabWithQueuedDiff} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} />
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={handleReplaceActiveTabWithQueuedDiff} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} locale={appLocale} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
@@ -1537,6 +1563,7 @@ function App() {
entries={vault.entries}
aiAgentReady={aiAgentPreferences.defaultAiAgentReady}
aiAgentLabel={aiAgentPreferences.defaultAiAgentLabel}
locale={appLocale}
onClose={dialogs.closeCommandPalette}
/>
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
@@ -1570,7 +1597,7 @@ function App() {
onCommit={conflictResolver.commitResolution}
onClose={conflictFlow.handleCloseConflictResolver}
/>
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} isGitVault={isGitVault} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} locale={appLocale} systemLocale={systemLocale} isGitVault={isGitVault} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
<McpSetupDialog open={showMcpSetupDialog} status={mcpStatus} busyAction={mcpDialogAction} onClose={closeMcpSetupDialog} onConnect={handleConnectMcp} onDisconnect={handleDisconnectMcp} />
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />

View File

@@ -165,6 +165,15 @@ describe('CommandPalette', () => {
expect(screen.getByText('No matching commands')).toBeInTheDocument()
})
it('localizes command palette chrome', () => {
render(<CommandPalette open={true} commands={commands} locale="zh-Hans" onClose={onClose} />)
const input = screen.getByPlaceholderText('输入命令...')
fireEvent.change(input, { target: { value: 'zzzzzzz' } })
expect(screen.getByText('没有匹配的命令')).toBeInTheDocument()
expect(screen.getByText('↑↓ 导航')).toBeInTheDocument()
})
it('calls onClose when pressing Escape', () => {
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
fireEvent.keyDown(window, { key: 'Escape' })

View File

@@ -7,6 +7,7 @@ import type { NoteReference } from '../utils/ai-context'
import type { CommandAction, CommandGroup } from '../hooks/useCommandRegistry'
import { groupSortKey } from '../hooks/useCommandRegistry'
import { rememberFeedbackDialogOpener } from '../lib/feedbackDialogOpener'
import { createTranslator, type AppLocale } from '../lib/i18n'
import { CommandPaletteAiMode } from './CommandPaletteAiMode'
import { Input } from './ui/input'
@@ -17,6 +18,7 @@ interface CommandPaletteProps {
claudeCodeReady?: boolean
aiAgentReady?: boolean
aiAgentLabel?: string
locale?: AppLocale
onClose: () => void
}
@@ -119,17 +121,19 @@ function CommandPaletteInput({
inputRef,
query,
onChange,
placeholder,
}: {
inputRef: React.RefObject<HTMLInputElement | null>
query: string
onChange: (value: string) => void
placeholder: string
}) {
return (
<Input
ref={inputRef}
className="h-auto rounded-none border-x-0 border-t-0 border-b border-border bg-transparent px-4 py-3 text-[15px] text-foreground shadow-none transition-none outline-none placeholder:text-muted-foreground focus-visible:border-border focus-visible:ring-0 md:text-[15px]"
type="text"
placeholder="Type a command..."
placeholder={placeholder}
value={query}
spellCheck={false}
autoCorrect="off"
@@ -144,12 +148,14 @@ function CommandPaletteResults({
groups,
selectedIndex,
listRef,
emptyText,
onHover,
onSelect,
}: {
groups: { group: CommandGroup; items: CommandAction[] }[]
selectedIndex: number
listRef: React.RefObject<HTMLDivElement | null>
emptyText: string
onHover: (index: number) => void
onSelect: (command: CommandAction) => void
}) {
@@ -159,7 +165,7 @@ function CommandPaletteResults({
return (
<div className="flex-1 overflow-y-auto py-1" ref={listRef}>
<div className="px-4 py-6 text-center text-[13px] text-muted-foreground">
No matching commands
{emptyText}
</div>
</div>
)
@@ -207,15 +213,23 @@ function CommandPaletteResults({
function CommandPaletteFooter({
aiMode,
aiAgentLabel = 'Claude Code',
footerText,
}: {
aiMode: boolean
aiAgentLabel?: string
footerText: {
aiMode: string
navigate: string
select: string
send: string
close: string
}
}) {
return (
<div className="flex items-center gap-4 border-t border-border px-4 py-1.5 text-[11px] text-muted-foreground">
<span>{aiMode ? `${aiAgentLabel} mode` : '↑↓ navigate'}</span>
<span>{aiMode ? '↵ send' : '↵ select'}</span>
<span>esc close</span>
<span>{aiMode ? footerText.aiMode.replace('{agent}', aiAgentLabel) : footerText.navigate}</span>
<span>{aiMode ? footerText.send : footerText.select}</span>
<span>{footerText.close}</span>
</div>
)
}
@@ -231,6 +245,7 @@ function OpenCommandPalette({
claudeCodeReady = true,
aiAgentReady,
aiAgentLabel = 'Claude Code',
locale = 'en',
onClose,
}: Omit<CommandPaletteProps, 'open'>) {
const [query, setQuery] = useState('')
@@ -242,6 +257,14 @@ function OpenCommandPalette({
const aiMode = aiValue.startsWith(' ')
const resolvedAiAgentReady = aiAgentReady ?? claudeCodeReady
const { groups, flatList } = usePaletteResults(commands, query)
const t = createTranslator(locale)
const footerText = {
aiMode: t('command.aiMode', { agent: '{agent}' }),
navigate: t('command.footerNavigate'),
select: t('command.footerSelect'),
send: t('command.footerSend'),
close: t('command.footerClose'),
}
useLayoutEffect(() => {
const target = aiMode ? aiInputRef.current : inputRef.current
@@ -364,15 +387,21 @@ function OpenCommandPalette({
/>
) : (
<>
<CommandPaletteInput inputRef={inputRef} query={query} onChange={handleQueryChange} />
<CommandPaletteInput
inputRef={inputRef}
query={query}
placeholder={t('command.palettePlaceholder')}
onChange={handleQueryChange}
/>
<CommandPaletteResults
groups={groups}
selectedIndex={selectedIndex}
listRef={listRef}
emptyText={t('command.noMatches')}
onHover={setSelectedIndex}
onSelect={handleSelectCommand}
/>
<CommandPaletteFooter aiMode={false} aiAgentLabel={aiAgentLabel} />
<CommandPaletteFooter aiMode={false} aiAgentLabel={aiAgentLabel} footerText={footerText} />
</>
)}
</div>

View File

@@ -16,6 +16,7 @@ const emptySettings: Settings = {
anonymous_id: null,
release_channel: null,
theme_mode: null,
ui_language: null,
}
function installPointerCapturePolyfill() {
@@ -97,6 +98,51 @@ describe('SettingsPanel', () => {
expect(screen.getByRole('radio', { name: 'Dark' })).toHaveAttribute('aria-checked', 'false')
})
it('defaults the language selector to system language', () => {
render(
<SettingsPanel
open={true}
settings={emptySettings}
locale="en"
systemLocale="zh-Hans"
onSave={onSave}
onClose={onClose}
/>
)
expect(screen.getByTestId('settings-ui-language')).toHaveAttribute('data-value', 'system')
expect(screen.getByText('系统(简体中文)')).toBeInTheDocument()
})
it('keeps the language selector keyboard accessible', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
const trigger = screen.getByTestId('settings-ui-language')
trigger.focus()
fireEvent.keyDown(trigger, { key: 'ArrowDown', code: 'ArrowDown' })
expect(screen.getByRole('option', { name: 'Simplified Chinese' })).toBeInTheDocument()
})
it('saves the selected UI language and updates visible settings text', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
fireEvent.pointerDown(screen.getByTestId('settings-ui-language'), { button: 0, pointerType: 'mouse' })
fireEvent.click(screen.getByRole('option', { name: 'Simplified Chinese' }))
expect(screen.getByText('设置')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('settings-save'))
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
ui_language: 'zh-Hans',
}))
})
it('uses the stored color mode mirror when settings have no saved mode', () => {
window.localStorage.setItem(THEME_MODE_STORAGE_KEY, 'dark')

View File

@@ -17,6 +17,15 @@ import {
} from 'react'
import { Moon, Sun, X } from '@phosphor-icons/react'
import type { Settings } from '../types'
import {
SYSTEM_UI_LANGUAGE,
createTranslator,
localeDisplayName,
resolveEffectiveLocale,
serializeUiLanguagePreference,
type AppLocale,
type UiLanguagePreference,
} from '../lib/i18n'
import {
DEFAULT_THEME_MODE,
readStoredThemeMode,
@@ -40,6 +49,8 @@ interface SettingsPanelProps {
open: boolean
settings: Settings
aiAgentsStatus?: AiAgentsStatus
locale?: AppLocale
systemLocale?: AppLocale
onSave: (settings: Settings) => void
isGitVault?: boolean
explicitOrganizationEnabled?: boolean
@@ -56,6 +67,7 @@ interface SettingsDraft {
defaultAiAgent: AiAgentId
releaseChannel: ReleaseChannel
themeMode: ThemeMode
uiLanguage: UiLanguagePreference
initialH1AutoRename: boolean
crashReporting: boolean
analytics: boolean
@@ -63,6 +75,7 @@ interface SettingsDraft {
}
interface SettingsBodyProps {
t: Translate
pullInterval: number
setPullInterval: (value: number) => void
isGitVault: boolean
@@ -81,6 +94,10 @@ interface SettingsBodyProps {
setReleaseChannel: (value: ReleaseChannel) => void
themeMode: ThemeMode
setThemeMode: (value: ThemeMode) => void
uiLanguage: UiLanguagePreference
setUiLanguage: (value: UiLanguagePreference) => void
locale: AppLocale
systemLocale: AppLocale
initialH1AutoRename: boolean
setInitialH1AutoRename: (value: boolean) => void
explicitOrganization: boolean
@@ -94,6 +111,7 @@ interface SettingsBodyProps {
const PULL_INTERVAL_OPTIONS = [1, 2, 5, 10, 15, 30] as const
const DEFAULT_AUTOGIT_IDLE_THRESHOLD_SECONDS = 90
const DEFAULT_AUTOGIT_INACTIVE_THRESHOLD_SECONDS = 30
type Translate = ReturnType<typeof createTranslator>
function isSaveShortcut(event: ReactKeyboardEvent): boolean {
return event.key === 'Enter' && (event.metaKey || event.ctrlKey)
@@ -118,6 +136,7 @@ function createSettingsDraft(
defaultAiAgent: resolveDefaultAiAgent(settings.default_ai_agent),
releaseChannel: normalizeReleaseChannel(settings.release_channel),
themeMode: resolveSettingsDraftThemeMode(settings.theme_mode),
uiLanguage: settings.ui_language ?? SYSTEM_UI_LANGUAGE,
initialH1AutoRename: settings.initial_h1_auto_rename_enabled ?? true,
crashReporting: settings.crash_reporting_enabled ?? false,
analytics: settings.analytics_enabled ?? false,
@@ -157,6 +176,7 @@ function buildSettingsFromDraft(settings: Settings, draft: SettingsDraft): Setti
anonymous_id: resolveAnonymousId(settings, draft),
release_channel: serializeReleaseChannel(draft.releaseChannel),
theme_mode: draft.themeMode,
ui_language: serializeUiLanguagePreference(draft.uiLanguage),
initial_h1_auto_rename_enabled: draft.initialH1AutoRename,
default_ai_agent: draft.defaultAiAgent,
}
@@ -180,6 +200,8 @@ export function SettingsPanel({
open,
settings,
aiAgentsStatus = createMissingAiAgentsStatus(),
locale = 'en',
systemLocale = locale,
onSave,
isGitVault = true,
explicitOrganizationEnabled = true,
@@ -192,6 +214,8 @@ export function SettingsPanel({
<SettingsPanelInner
settings={settings}
aiAgentsStatus={aiAgentsStatus}
locale={locale}
systemLocale={systemLocale}
onSave={onSave}
isGitVault={isGitVault}
explicitOrganizationEnabled={explicitOrganizationEnabled}
@@ -203,6 +227,8 @@ export function SettingsPanel({
type SettingsPanelInnerProps = Omit<SettingsPanelProps, 'open' | 'explicitOrganizationEnabled' | 'aiAgentsStatus' | 'isGitVault'> & {
aiAgentsStatus: AiAgentsStatus
locale: AppLocale
systemLocale: AppLocale
isGitVault: boolean
explicitOrganizationEnabled: boolean
}
@@ -210,6 +236,7 @@ type SettingsPanelInnerProps = Omit<SettingsPanelProps, 'open' | 'explicitOrgani
function SettingsPanelInner({
settings,
aiAgentsStatus,
systemLocale,
onSave,
isGitVault,
explicitOrganizationEnabled,
@@ -218,6 +245,8 @@ function SettingsPanelInner({
}: SettingsPanelInnerProps) {
const [draft, setDraft] = useState(() => createSettingsDraft(settings, explicitOrganizationEnabled))
const panelRef = useRef<HTMLDivElement>(null)
const draftLocale = resolveEffectiveLocale(draft.uiLanguage, [systemLocale])
const t = createTranslator(draftLocale)
useEffect(() => {
const timer = setTimeout(() => {
@@ -277,8 +306,11 @@ function SettingsPanelInner({
className="rounded-lg border border-border bg-background shadow-[0_18px_55px_var(--shadow-dialog)]"
style={{ width: 520, maxHeight: '80vh', display: 'flex', flexDirection: 'column' }}
>
<SettingsHeader onClose={onClose} />
<SettingsHeader onClose={onClose} t={t} />
<SettingsBody
t={t}
locale={draftLocale}
systemLocale={systemLocale}
pullInterval={draft.pullInterval}
setPullInterval={(value) => updateDraft('pullInterval', value)}
isGitVault={isGitVault}
@@ -297,6 +329,8 @@ function SettingsPanelInner({
setReleaseChannel={(value) => updateDraft('releaseChannel', value)}
themeMode={draft.themeMode}
setThemeMode={(value) => updateDraft('themeMode', value)}
uiLanguage={draft.uiLanguage}
setUiLanguage={(value) => updateDraft('uiLanguage', value)}
initialH1AutoRename={draft.initialH1AutoRename}
setInitialH1AutoRename={(value) => updateDraft('initialH1AutoRename', value)}
explicitOrganization={draft.explicitOrganization}
@@ -306,25 +340,25 @@ function SettingsPanelInner({
analytics={draft.analytics}
setAnalytics={(value) => updateDraft('analytics', value)}
/>
<SettingsFooter onClose={onClose} onSave={handleSave} />
<SettingsFooter onClose={onClose} onSave={handleSave} t={t} />
</div>
</div>
)
}
function SettingsHeader({ onClose }: { onClose: () => void }) {
function SettingsHeader({ onClose, t }: { onClose: () => void; t: Translate }) {
return (
<div
className="flex items-center justify-between shrink-0"
style={{ height: 56, padding: '0 24px', borderBottom: '1px solid var(--border)' }}
>
<span style={{ fontSize: 16, fontWeight: 600, color: 'var(--foreground)' }}>Settings</span>
<span style={{ fontSize: 16, fontWeight: 600, color: 'var(--foreground)' }}>{t('settings.title')}</span>
<Button
variant="ghost"
size="icon-sm"
onClick={onClose}
title="Close settings"
aria-label="Close settings"
title={t('settings.close')}
aria-label={t('settings.close')}
>
<X size={16} />
</Button>
@@ -333,6 +367,9 @@ function SettingsHeader({ onClose }: { onClose: () => void }) {
}
function SettingsBody({
t,
locale,
systemLocale,
pullInterval,
setPullInterval,
isGitVault,
@@ -351,6 +388,8 @@ function SettingsBody({
setReleaseChannel,
themeMode,
setThemeMode,
uiLanguage,
setUiLanguage,
initialH1AutoRename,
setInitialH1AutoRename,
explicitOrganization,
@@ -364,6 +403,7 @@ function SettingsBody({
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 0, overflow: 'auto' }}>
<SettingsSection showDivider={false}>
<SyncAndUpdatesSection
t={t}
pullInterval={pullInterval}
setPullInterval={setPullInterval}
releaseChannel={releaseChannel}
@@ -373,6 +413,7 @@ function SettingsBody({
<SettingsSection>
<AutoGitSettingsSection
t={t}
isGitVault={isGitVault}
autoGitEnabled={autoGitEnabled}
setAutoGitEnabled={setAutoGitEnabled}
@@ -385,13 +426,25 @@ function SettingsBody({
<SettingsSection>
<AppearanceSettingsSection
t={t}
themeMode={themeMode}
setThemeMode={setThemeMode}
/>
</SettingsSection>
<SettingsSection>
<LanguageSettingsSection
t={t}
locale={locale}
systemLocale={systemLocale}
uiLanguage={uiLanguage}
setUiLanguage={setUiLanguage}
/>
</SettingsSection>
<SettingsSection>
<TitleSettingsSection
t={t}
initialH1AutoRename={initialH1AutoRename}
setInitialH1AutoRename={setInitialH1AutoRename}
/>
@@ -399,6 +452,7 @@ function SettingsBody({
<SettingsSection>
<AiAgentSettingsSection
t={t}
aiAgentsStatus={aiAgentsStatus}
defaultAiAgent={defaultAiAgent}
setDefaultAiAgent={setDefaultAiAgent}
@@ -407,6 +461,7 @@ function SettingsBody({
<SettingsSection>
<OrganizationWorkflowSection
t={t}
checked={explicitOrganization}
onChange={setExplicitOrganization}
autoAdvanceInboxAfterOrganize={autoAdvanceInboxAfterOrganize}
@@ -416,6 +471,7 @@ function SettingsBody({
<SettingsSection>
<PrivacySettingsSection
t={t}
crashReporting={crashReporting}
setCrashReporting={setCrashReporting}
analytics={analytics}
@@ -427,20 +483,21 @@ function SettingsBody({
}
function SyncAndUpdatesSection({
t,
pullInterval,
setPullInterval,
releaseChannel,
setReleaseChannel,
}: Pick<SettingsBodyProps, 'pullInterval' | 'setPullInterval' | 'releaseChannel' | 'setReleaseChannel'>) {
}: Pick<SettingsBodyProps, 't' | 'pullInterval' | 'setPullInterval' | 'releaseChannel' | 'setReleaseChannel'>) {
return (
<>
<SectionHeading
title="Sync & Updates"
description="Configure background pulling and which update feed Tolaria follows. Stable only receives manually promoted releases, while Alpha follows every push to main."
title={t('settings.sync.title')}
description={t('settings.sync.description')}
/>
<LabeledSelect
label="Pull interval (minutes)"
label={t('settings.pullInterval')}
value={`${pullInterval}`}
onValueChange={(value) => setPullInterval(Number(value))}
options={PULL_INTERVAL_OPTIONS.map((value) => ({
@@ -452,12 +509,12 @@ function SyncAndUpdatesSection({
/>
<LabeledSelect
label="Release channel"
label={t('settings.releaseChannel')}
value={releaseChannel}
onValueChange={(value) => setReleaseChannel(value as ReleaseChannel)}
options={[
{ value: 'stable', label: 'Stable' },
{ value: 'alpha', label: 'Alpha' },
{ value: 'stable', label: t('settings.releaseStable') },
{ value: 'alpha', label: t('settings.releaseAlpha') },
]}
testId="settings-release-channel"
/>
@@ -466,17 +523,18 @@ function SyncAndUpdatesSection({
}
function AppearanceSettingsSection({
t,
themeMode,
setThemeMode,
}: Pick<SettingsBodyProps, 'themeMode' | 'setThemeMode'>) {
}: Pick<SettingsBodyProps, 't' | 'themeMode' | 'setThemeMode'>) {
return (
<>
<SectionHeading
title="Appearance"
description="Choose the app color mode used for Tolaria chrome, editor surfaces, menus, and dialogs."
title={t('settings.appearance.title')}
description={t('settings.appearance.description')}
/>
<ThemeModeControl value={themeMode} onChange={setThemeMode} />
<ThemeModeControl value={themeMode} onChange={setThemeMode} t={t} />
</>
)
}
@@ -484,21 +542,23 @@ function AppearanceSettingsSection({
function ThemeModeControl({
value,
onChange,
t,
}: {
value: ThemeMode
onChange: (value: ThemeMode) => void
t: Translate
}) {
return (
<div
className="inline-flex w-full rounded-md border border-border bg-muted p-1"
role="radiogroup"
aria-label="Theme"
aria-label={t('settings.theme.label')}
data-testid="settings-theme-mode"
>
<ThemeModeButton label="Light" selected={value === 'light'} value="light" onSelect={onChange}>
<ThemeModeButton label={t('settings.theme.light')} selected={value === 'light'} value="light" onSelect={onChange}>
<Sun size={14} />
</ThemeModeButton>
<ThemeModeButton label="Dark" selected={value === 'dark'} value="dark" onSelect={onChange}>
<ThemeModeButton label={t('settings.theme.dark')} selected={value === 'dark'} value="dark" onSelect={onChange}>
<Moon size={14} />
</ThemeModeButton>
</div>
@@ -540,13 +600,14 @@ function ThemeModeButton({
)
}
function autoGitSectionDescription(isGitVault: boolean): string {
function autoGitSectionDescription(isGitVault: boolean, t: Translate): string {
return isGitVault
? 'Automatically create conservative Git checkpoints after editing pauses or when the app is no longer active.'
: 'AutoGit is unavailable until the current vault is Git-enabled. Initialize Git for this vault first.'
? t('settings.autogit.description.enabled')
: t('settings.autogit.description.disabled')
}
function AutoGitSettingsSection({
t,
isGitVault,
autoGitEnabled,
setAutoGitEnabled,
@@ -556,6 +617,7 @@ function AutoGitSettingsSection({
setAutoGitInactiveThresholdSeconds,
}: Pick<
SettingsBodyProps,
| 't'
| 'isGitVault'
| 'autoGitEnabled'
| 'setAutoGitEnabled'
@@ -567,13 +629,13 @@ function AutoGitSettingsSection({
return (
<>
<SectionHeading
title="AutoGit"
description={autoGitSectionDescription(isGitVault)}
title={t('settings.autogit.title')}
description={autoGitSectionDescription(isGitVault, t)}
/>
<SettingsSwitchRow
label="Enable AutoGit"
description="When enabled, Tolaria will commit and push saved local changes automatically after an idle pause or after the app becomes inactive."
label={t('settings.autogit.enable')}
description={t('settings.autogit.enableDescription')}
checked={autoGitEnabled}
onChange={setAutoGitEnabled}
disabled={!isGitVault}
@@ -581,7 +643,7 @@ function AutoGitSettingsSection({
/>
<LabeledNumberInput
label="Idle threshold (seconds)"
label={t('settings.autogit.idleThreshold')}
value={autoGitIdleThresholdSeconds}
onValueChange={setAutoGitIdleThresholdSeconds}
testId="settings-autogit-idle-threshold"
@@ -589,7 +651,7 @@ function AutoGitSettingsSection({
/>
<LabeledNumberInput
label="Inactive-app grace period (seconds)"
label={t('settings.autogit.inactiveThreshold')}
value={autoGitInactiveThresholdSeconds}
onValueChange={setAutoGitInactiveThresholdSeconds}
testId="settings-autogit-inactive-threshold"
@@ -599,20 +661,63 @@ function AutoGitSettingsSection({
)
}
function TitleSettingsSection({
initialH1AutoRename,
setInitialH1AutoRename,
}: Pick<SettingsBodyProps, 'initialH1AutoRename' | 'setInitialH1AutoRename'>) {
function buildLanguageOptions(t: Translate, locale: AppLocale, systemLocale: AppLocale) {
return [
{
value: SYSTEM_UI_LANGUAGE,
label: t('settings.language.system', {
language: localeDisplayName(systemLocale, locale),
}),
},
{ value: 'en', label: t('settings.language.en') },
{ value: 'zh-Hans', label: t('settings.language.zhHans') },
]
}
function LanguageSettingsSection({
t,
locale,
systemLocale,
uiLanguage,
setUiLanguage,
}: Pick<SettingsBodyProps, 't' | 'locale' | 'systemLocale' | 'uiLanguage' | 'setUiLanguage'>) {
return (
<>
<SectionHeading
title="Titles & Filenames"
description="Choose whether Tolaria automatically syncs untitled note filenames from the first H1 title."
title={t('settings.language.title')}
description={t('settings.language.description')}
/>
<LabeledSelect
label={t('settings.language.label')}
value={uiLanguage}
onValueChange={(value) => setUiLanguage(value as UiLanguagePreference)}
options={buildLanguageOptions(t, locale, systemLocale)}
testId="settings-ui-language"
/>
<div style={{ fontSize: 11, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
{t('settings.language.summary')}
</div>
</>
)
}
function TitleSettingsSection({
t,
initialH1AutoRename,
setInitialH1AutoRename,
}: Pick<SettingsBodyProps, 't' | 'initialH1AutoRename' | 'setInitialH1AutoRename'>) {
return (
<>
<SectionHeading
title={t('settings.titles.title')}
description={t('settings.titles.description')}
/>
<SettingsSwitchRow
label="Auto-rename untitled notes from first H1"
description="When enabled, Tolaria renames untitled-note files as soon as the first H1 becomes a real title. Turn this off to keep the filename unchanged until you rename it manually from the breadcrumb bar."
label={t('settings.titles.autoRename')}
description={t('settings.titles.autoRenameDescription')}
checked={initialH1AutoRename}
onChange={setInitialH1AutoRename}
testId="settings-initial-h1-auto-rename"
@@ -621,12 +726,12 @@ function TitleSettingsSection({
)
}
function buildDefaultAiAgentOptions(aiAgentsStatus: AiAgentsStatus): Array<{ value: string; label: string }> {
function buildDefaultAiAgentOptions(aiAgentsStatus: AiAgentsStatus, t: Translate): Array<{ value: string; label: string }> {
return AI_AGENT_DEFINITIONS.map((definition) => {
const status = aiAgentsStatus[definition.id]
const suffix = status.status === 'installed'
? ` (installed${status.version ? ` ${status.version}` : ''})`
: ' (missing)'
? ` (${t('settings.aiAgents.installed')}${status.version ? ` ${status.version}` : ''})`
: ` (${t('settings.aiAgents.missing')})`
return {
value: definition.id,
label: `${definition.label}${suffix}`,
@@ -635,55 +740,57 @@ function buildDefaultAiAgentOptions(aiAgentsStatus: AiAgentsStatus): Array<{ val
}
function AiAgentSettingsSection({
t,
aiAgentsStatus,
defaultAiAgent,
setDefaultAiAgent,
}: Pick<SettingsBodyProps, 'aiAgentsStatus' | 'defaultAiAgent' | 'setDefaultAiAgent'>) {
}: Pick<SettingsBodyProps, 't' | 'aiAgentsStatus' | 'defaultAiAgent' | 'setDefaultAiAgent'>) {
return (
<>
<SectionHeading
title="AI Agents"
description="Choose which CLI AI agent Tolaria uses in the AI panel and command palette."
title={t('settings.aiAgents.title')}
description={t('settings.aiAgents.description')}
/>
<LabeledSelect
label="Default AI agent"
label={t('settings.aiAgents.default')}
value={defaultAiAgent}
onValueChange={(value) => setDefaultAiAgent(value as AiAgentId)}
options={buildDefaultAiAgentOptions(aiAgentsStatus)}
options={buildDefaultAiAgentOptions(aiAgentsStatus, t)}
testId="settings-default-ai-agent"
/>
<div style={{ fontSize: 11, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
{renderDefaultAiAgentSummary(defaultAiAgent, aiAgentsStatus)}
{renderDefaultAiAgentSummary(defaultAiAgent, aiAgentsStatus, t)}
</div>
</>
)
}
function PrivacySettingsSection({
t,
crashReporting,
setCrashReporting,
analytics,
setAnalytics,
}: Pick<SettingsBodyProps, 'crashReporting' | 'setCrashReporting' | 'analytics' | 'setAnalytics'>) {
}: Pick<SettingsBodyProps, 't' | 'crashReporting' | 'setCrashReporting' | 'analytics' | 'setAnalytics'>) {
return (
<>
<SectionHeading
title="Privacy & Telemetry"
description="Anonymous data helps us fix bugs and improve Tolaria. No vault content, note titles, or file paths are ever sent."
title={t('settings.privacy.title')}
description={t('settings.privacy.description')}
/>
<TelemetryToggle
label="Crash reporting"
description="Send anonymous error reports"
label={t('settings.privacy.crashReporting')}
description={t('settings.privacy.crashReportingDescription')}
checked={crashReporting}
onChange={setCrashReporting}
testId="settings-crash-reporting"
/>
<TelemetryToggle
label="Usage analytics"
description="Share anonymous usage patterns"
label={t('settings.privacy.analytics')}
description={t('settings.privacy.analyticsDescription')}
checked={analytics}
onChange={setAnalytics}
testId="settings-analytics"
@@ -738,13 +845,16 @@ function Divider() {
return <div style={{ height: 1, background: 'color-mix(in srgb, var(--border) 82%, transparent)' }} />
}
function renderDefaultAiAgentSummary(defaultAiAgent: AiAgentId, aiAgentsStatus: AiAgentsStatus): string {
function renderDefaultAiAgentSummary(defaultAiAgent: AiAgentId, aiAgentsStatus: AiAgentsStatus, t: Translate): string {
const definition = getAiAgentDefinition(defaultAiAgent)
const status = aiAgentsStatus[defaultAiAgent]
if (status.status === 'installed') {
return `${definition.label}${status.version ? ` ${status.version}` : ''} is ready to use.`
return t('settings.aiAgents.ready', {
agent: definition.label,
version: status.version ? ` ${status.version}` : '',
})
}
return `${definition.label} is not installed yet. You can still select it now and install it later.`
return t('settings.aiAgents.notInstalled', { agent: definition.label })
}
function LabeledSelect({
@@ -818,11 +928,13 @@ function LabeledNumberInput({
}
function OrganizationWorkflowSection({
t,
checked,
onChange,
autoAdvanceInboxAfterOrganize,
onChangeAutoAdvanceInboxAfterOrganize,
}: {
t: Translate
checked: boolean
onChange: (value: boolean) => void
autoAdvanceInboxAfterOrganize: boolean
@@ -831,21 +943,21 @@ function OrganizationWorkflowSection({
return (
<>
<SectionHeading
title="Workflow"
description="Choose whether Tolaria shows the Inbox workflow, plus how it moves through items while you triage them."
title={t('settings.workflow.title')}
description={t('settings.workflow.description')}
/>
<SettingsSwitchRow
label="Organize notes explicitly"
description="When enabled, an Inbox section shows unorganized notes, and a toggle lets you mark notes as organized."
label={t('settings.workflow.explicit')}
description={t('settings.workflow.explicitDescription')}
checked={checked}
onChange={onChange}
testId="settings-explicit-organization"
/>
<SettingsSwitchRow
label="Auto-advance to next Inbox item"
description="When enabled, marking an Inbox note as organized immediately opens the next visible Inbox note."
label={t('settings.workflow.autoAdvance')}
description={t('settings.workflow.autoAdvanceDescription')}
checked={autoAdvanceInboxAfterOrganize}
onChange={onChangeAutoAdvanceInboxAfterOrganize}
testId="settings-auto-advance-inbox-after-organize"
@@ -908,19 +1020,19 @@ function TelemetryToggle({
)
}
function SettingsFooter({ onClose, onSave }: { onClose: () => void; onSave: () => void }) {
function SettingsFooter({ onClose, onSave, t }: { onClose: () => void; onSave: () => void; t: Translate }) {
return (
<div
className="flex items-center justify-between shrink-0"
style={{ height: 56, padding: '0 24px', borderTop: '1px solid var(--border)' }}
>
<span style={{ fontSize: 11, color: 'var(--muted-foreground)' }}>{'\u2318'}, to open settings</span>
<span style={{ fontSize: 11, color: 'var(--muted-foreground)' }}>{t('settings.footerShortcut')}</span>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose}>
Cancel
{t('settings.cancel')}
</Button>
<Button onClick={onSave} data-testid="settings-save">
Save
{t('settings.save')}
</Button>
</div>
</div>

View File

@@ -2,6 +2,7 @@ import { MagnifyingGlass, Plus } from '@phosphor-icons/react'
import { Loader2 } from 'lucide-react'
import type { VaultEntry } from '../../types'
import type { SortOption, SortDirection } from '../../utils/noteListHelpers'
import { translate, type AppLocale } from '../../lib/i18n'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useDragRegion } from '../../hooks/useDragRegion'
@@ -10,7 +11,7 @@ import { ListPropertiesPopover, type ListPropertiesPopoverProps } from './ListPr
const NOTE_LIST_ACTION_BUTTON_CLASSNAME = '!h-auto !w-auto !min-w-0 !rounded-none !p-0 !text-muted-foreground hover:!bg-transparent hover:!text-foreground focus-visible:!bg-transparent data-[state=open]:!bg-transparent data-[state=open]:!text-foreground [&_svg]:!size-4'
export function NoteListHeader({ title, typeDocument, isEntityView, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, isSearching, searchInputRef, propertyPicker, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange, onSearchKeyDown }: {
export function NoteListHeader({ title, typeDocument, isEntityView, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, isSearching, searchInputRef, propertyPicker, locale = 'en', onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange, onSearchKeyDown }: {
title: string
typeDocument: VaultEntry | null
isEntityView: boolean
@@ -23,6 +24,7 @@ export function NoteListHeader({ title, typeDocument, isEntityView, listSort, li
isSearching: boolean
searchInputRef: React.RefObject<HTMLInputElement | null>
propertyPicker?: ListPropertiesPopoverProps | null
locale?: AppLocale
onSortChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
onCreateNote: () => void
onOpenType: (entry: VaultEntry) => void
@@ -44,11 +46,11 @@ export function NoteListHeader({ title, typeDocument, isEntityView, listSort, li
</h3>
<div className="ml-3 flex shrink-0 items-center justify-end gap-2" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
{!isEntityView && <SortDropdown groupLabel="__list__" current={listSort} direction={listDirection} customProperties={customProperties} onChange={onSortChange} />}
<Button type="button" variant="ghost" size="icon-xs" className={NOTE_LIST_ACTION_BUTTON_CLASSNAME} onClick={onToggleSearch} title="Search notes" aria-label="Search notes">
<Button type="button" variant="ghost" size="icon-xs" className={NOTE_LIST_ACTION_BUTTON_CLASSNAME} onClick={onToggleSearch} title={translate(locale, 'noteList.searchAction')} aria-label={translate(locale, 'noteList.searchAction')}>
<MagnifyingGlass size={16} />
</Button>
{propertyPicker && <ListPropertiesPopover {...propertyPicker} triggerClassName={NOTE_LIST_ACTION_BUTTON_CLASSNAME} />}
<Button type="button" variant="ghost" size="icon-xs" className={NOTE_LIST_ACTION_BUTTON_CLASSNAME} onClick={onCreateNote} title="Create new note" aria-label="Create new note">
<Button type="button" variant="ghost" size="icon-xs" className={NOTE_LIST_ACTION_BUTTON_CLASSNAME} onClick={onCreateNote} title={translate(locale, 'noteList.createNote')} aria-label={translate(locale, 'noteList.createNote')}>
<Plus size={16} />
</Button>
</div>
@@ -58,7 +60,7 @@ export function NoteListHeader({ title, typeDocument, isEntityView, listSort, li
<div className="relative flex-1" aria-live="polite">
<Input
ref={searchInputRef}
placeholder="Search notes..."
placeholder={translate(locale, 'noteList.searchPlaceholder')}
value={search}
onChange={(e) => onSearchChange(e.target.value)}
onKeyDown={onSearchKeyDown}

View File

@@ -46,6 +46,7 @@ function NoteListContent({
modifiedFilesError,
searched,
noteListVirtuosoRef,
locale,
}: Pick<
NoteListLayoutProps,
| 'entitySelection'
@@ -62,6 +63,7 @@ function NoteListContent({
| 'modifiedFilesError'
| 'searched'
| 'noteListVirtuosoRef'
| 'locale'
>) {
return (
<div className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>
@@ -75,6 +77,7 @@ function NoteListContent({
onToggleGroup={toggleGroup}
onSortChange={handleSortChange}
renderItem={renderItem}
locale={locale}
/>
) : (
<ListView
@@ -86,6 +89,7 @@ function NoteListContent({
query={query}
renderItem={renderItem}
virtuosoRef={noteListVirtuosoRef}
locale={locale}
/>
)}
</div>
@@ -112,6 +116,7 @@ function NoteListBody({
isInboxView,
modifiedFilesError,
searched,
locale,
showFilterPills,
noteListFilter,
filterCounts,
@@ -137,6 +142,7 @@ function NoteListBody({
| 'isInboxView'
| 'modifiedFilesError'
| 'searched'
| 'locale'
| 'showFilterPills'
| 'noteListFilter'
| 'filterCounts'
@@ -169,6 +175,7 @@ function NoteListBody({
modifiedFilesError={modifiedFilesError}
searched={searched}
noteListVirtuosoRef={noteListVirtuosoRef}
locale={locale}
/>
{showFilterPills && (
<FilterPills
@@ -182,13 +189,14 @@ function NoteListBody({
)
}
export function NoteListLayout({
function NoteListLayoutHeader({
title,
typeDocument,
isEntityView,
listSort,
listDirection,
customProperties,
locale,
sidebarCollapsed,
searchVisible,
search,
@@ -201,38 +209,93 @@ export function NoteListLayout({
toggleSearch,
setSearch,
handleSearchKeyDown,
noteListPanelRef,
handleNoteListPanelBlurCapture,
handleNoteListPanelFocusCapture,
handleListKeyDown,
noteListContainerRef,
handleNoteListBlur,
handleNoteListFocus,
focusNoteList,
noteListVirtuosoRef,
entitySelection,
searchedGroups,
collapsedGroups,
sortPrefs,
toggleGroup,
renderItem,
isArchivedView,
isChangesView,
isInboxView,
modifiedFilesError,
searched,
query,
showFilterPills,
noteListFilter,
filterCounts,
onNoteListFilterChange,
}: Pick<
NoteListLayoutProps,
| 'title'
| 'typeDocument'
| 'isEntityView'
| 'listSort'
| 'listDirection'
| 'customProperties'
| 'locale'
| 'sidebarCollapsed'
| 'searchVisible'
| 'search'
| 'isSearching'
| 'searchInputRef'
| 'propertyPicker'
| 'handleSortChange'
| 'handleCreateNote'
| 'onOpenType'
| 'toggleSearch'
| 'setSearch'
| 'handleSearchKeyDown'
>) {
return (
<NoteListHeader
title={title}
typeDocument={typeDocument}
isEntityView={isEntityView}
listSort={listSort}
listDirection={listDirection}
customProperties={customProperties}
locale={locale}
sidebarCollapsed={sidebarCollapsed}
searchVisible={searchVisible}
search={search}
isSearching={isSearching}
searchInputRef={searchInputRef}
propertyPicker={propertyPicker}
onSortChange={handleSortChange}
onCreateNote={handleCreateNote}
onOpenType={onOpenType}
onToggleSearch={toggleSearch}
onSearchChange={setSearch}
onSearchKeyDown={handleSearchKeyDown}
/>
)
}
function NoteListFooter({
multiSelect,
isArchivedView,
handleBulkOrganize,
handleBulkArchive,
handleBulkDeletePermanently,
handleBulkUnarchive,
contextMenuNode,
dialogNode,
}: Pick<
NoteListLayoutProps,
| 'multiSelect'
| 'isArchivedView'
| 'handleBulkOrganize'
| 'handleBulkArchive'
| 'handleBulkDeletePermanently'
| 'handleBulkUnarchive'
| 'contextMenuNode'
| 'dialogNode'
>) {
return (
<>
<MultiSelectBar
multiSelect={multiSelect}
isArchivedView={isArchivedView}
handleBulkOrganize={handleBulkOrganize}
handleBulkArchive={handleBulkArchive}
handleBulkDeletePermanently={handleBulkDeletePermanently}
handleBulkUnarchive={handleBulkUnarchive}
/>
{contextMenuNode}{dialogNode}
</>
)
}
export function NoteListLayout({
noteListPanelRef,
handleNoteListPanelBlurCapture,
handleNoteListPanelFocusCapture,
...contentProps
}: NoteListLayoutProps) {
return (
<div
@@ -242,61 +305,9 @@ export function NoteListLayout({
onBlurCapture={handleNoteListPanelBlurCapture}
onFocusCapture={handleNoteListPanelFocusCapture}
>
<NoteListHeader
title={title}
typeDocument={typeDocument}
isEntityView={isEntityView}
listSort={listSort}
listDirection={listDirection}
customProperties={customProperties}
sidebarCollapsed={sidebarCollapsed}
searchVisible={searchVisible}
search={search}
isSearching={isSearching}
searchInputRef={searchInputRef}
propertyPicker={propertyPicker}
onSortChange={handleSortChange}
onCreateNote={handleCreateNote}
onOpenType={onOpenType}
onToggleSearch={toggleSearch}
onSearchChange={setSearch}
onSearchKeyDown={handleSearchKeyDown}
/>
<NoteListBody
handleListKeyDown={handleListKeyDown}
noteListContainerRef={noteListContainerRef}
handleNoteListBlur={handleNoteListBlur}
handleNoteListFocus={handleNoteListFocus}
focusNoteList={focusNoteList}
noteListVirtuosoRef={noteListVirtuosoRef}
entitySelection={entitySelection}
searchedGroups={searchedGroups}
query={query}
collapsedGroups={collapsedGroups}
sortPrefs={sortPrefs}
toggleGroup={toggleGroup}
handleSortChange={handleSortChange}
renderItem={renderItem}
isArchivedView={isArchivedView}
isChangesView={isChangesView}
isInboxView={isInboxView}
modifiedFilesError={modifiedFilesError}
searched={searched}
showFilterPills={showFilterPills}
noteListFilter={noteListFilter}
filterCounts={filterCounts}
onNoteListFilterChange={onNoteListFilterChange}
/>
<MultiSelectBar
multiSelect={multiSelect}
isArchivedView={isArchivedView}
handleBulkOrganize={handleBulkOrganize}
handleBulkArchive={handleBulkArchive}
handleBulkDeletePermanently={handleBulkDeletePermanently}
handleBulkUnarchive={handleBulkUnarchive}
/>
{contextMenuNode}
{dialogNode}
<NoteListLayoutHeader {...contentProps} />
<NoteListBody {...contentProps} />
<NoteListFooter {...contentProps} />
</div>
)
}

View File

@@ -1,6 +1,7 @@
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import type { VaultEntry } from '../../types'
import type { SortOption, SortDirection, SortConfig, RelationshipGroup } from '../../utils/noteListHelpers'
import { translate, type AppLocale } from '../../lib/i18n'
import { PinnedCard } from './PinnedCard'
import { RelationshipGroupSection } from './RelationshipGroupSection'
import { EmptyMessage } from './TrashWarningBanner'
@@ -11,31 +12,34 @@ function resolveEmptyText({
isArchivedView,
isInboxView,
query,
locale,
}: {
isChangesView: boolean
changesError: string | null | undefined
isArchivedView: boolean
isInboxView: boolean
query: string
locale: AppLocale
}): string {
if (isChangesView && changesError) return `Failed to load changes: ${changesError}`
if (isChangesView) return 'No pending changes'
if (isArchivedView) return 'No archived notes'
if (isInboxView) return query ? 'No matching notes' : 'All notes are organized'
return query ? 'No matching notes' : 'No notes found'
if (isChangesView && changesError) return translate(locale, 'noteList.empty.changesError', { error: changesError })
if (isChangesView) return translate(locale, 'noteList.empty.noChanges')
if (isArchivedView) return translate(locale, 'noteList.empty.noArchived')
if (isInboxView) return query ? translate(locale, 'noteList.empty.noMatching') : translate(locale, 'noteList.empty.allOrganized')
return query ? translate(locale, 'noteList.empty.noMatching') : translate(locale, 'noteList.empty.noNotes')
}
export function EntityView({ entity, groups, query, collapsedGroups, sortPrefs, onToggleGroup, onSortChange, renderItem }: {
export function EntityView({ entity, groups, query, collapsedGroups, sortPrefs, onToggleGroup, onSortChange, renderItem, locale = 'en' }: {
entity: VaultEntry; groups: RelationshipGroup[]; query: string
collapsedGroups: Set<string>; sortPrefs: Record<string, SortConfig>
onToggleGroup: (label: string) => void; onSortChange: (label: string, opt: SortOption, dir: SortDirection) => void
renderItem: (entry: VaultEntry, options?: { forceSelected?: boolean }) => React.ReactNode
locale?: AppLocale
}) {
return (
<div className="h-full overflow-y-auto">
<PinnedCard entry={entity} renderItem={renderItem} />
{groups.length === 0
? <EmptyMessage text={query ? 'No matching items' : 'No related items'} />
? <EmptyMessage text={query ? translate(locale, 'noteList.empty.noMatchingItems') : translate(locale, 'noteList.empty.noRelatedItems')} />
: groups.map((group) => (
<RelationshipGroupSection key={group.label} group={group} isCollapsed={collapsedGroups.has(group.label)} sortPrefs={sortPrefs} onToggle={() => onToggleGroup(group.label)} handleSortChange={onSortChange} renderItem={renderItem} />
))
@@ -44,11 +48,12 @@ export function EntityView({ entity, groups, query, collapsedGroups, sortPrefs,
)
}
export function ListView({ isArchivedView, isChangesView, isInboxView, changesError, searched, query, renderItem, virtuosoRef }: {
export function ListView({ isArchivedView, isChangesView, isInboxView, changesError, searched, query, renderItem, virtuosoRef, locale = 'en' }: {
isArchivedView?: boolean; isChangesView?: boolean; isInboxView?: boolean; changesError?: string | null
searched: VaultEntry[]; query: string
renderItem: (entry: VaultEntry) => React.ReactNode
virtuosoRef?: React.RefObject<VirtuosoHandle | null>
locale?: AppLocale
}) {
const emptyText = resolveEmptyText({
isChangesView: !!isChangesView,
@@ -56,6 +61,7 @@ export function ListView({ isArchivedView, isChangesView, isInboxView, changesEr
isArchivedView: !!isArchivedView,
isInboxView: !!isInboxView,
query,
locale,
})
if (searched.length === 0) {

View File

@@ -37,6 +37,26 @@ describe('resolveHeaderTitle', () => {
const selection: SidebarSelection = { kind: 'filter', filter: 'pulse' }
expect(resolveHeaderTitle(selection, null)).toBe('History')
})
it('localizes built-in note list titles', () => {
const selection: SidebarSelection = { kind: 'filter', filter: 'archived' }
expect(resolveHeaderTitle(selection, null, [], 'zh-Hans')).toBe('归档')
})
it('keeps user-authored view names unchanged', () => {
const selection: SidebarSelection = { kind: 'view', filename: 'custom.yml' }
expect(resolveHeaderTitle(selection, null, [{
filename: 'custom.yml',
definition: {
name: '客户',
icon: null,
color: null,
sort: null,
filters: { all: [] },
},
}], 'en')).toBe('客户')
})
})
describe('routeNoteClick', () => {

View File

@@ -1,5 +1,6 @@
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, ViewFile } from '../../types'
import type { VaultEntry, SidebarSelection, SidebarFilter, ModifiedFile, NoteStatus, ViewFile } from '../../types'
import type { RelationshipGroup } from '../../utils/noteListHelpers'
import { translate, type AppLocale } from '../../lib/i18n'
import { filenameStemToTitle } from '../../utils/noteTitle'
export interface DeletedNoteEntry extends VaultEntry {
@@ -10,27 +11,34 @@ export interface DeletedNoteEntry extends VaultEntry {
__changeBinary: boolean
}
const FILTER_TITLES: Partial<Record<'archived' | 'changes' | 'inbox' | 'pulse', string>> = {
archived: 'Archive',
changes: 'Changes',
inbox: 'Inbox',
pulse: 'History',
const FILTER_TITLE_KEYS = {
archived: 'noteList.title.archive',
changes: 'noteList.title.changes',
inbox: 'noteList.title.inbox',
pulse: 'noteList.title.history',
} as const
type LocalizedFilter = keyof typeof FILTER_TITLE_KEYS
function isLocalizedFilter(filter: SidebarFilter): filter is LocalizedFilter {
return filter in FILTER_TITLE_KEYS
}
function resolveSelectionFilterTitle(selection: SidebarSelection): string | null {
function resolveSelectionFilterTitle(selection: SidebarSelection, locale: AppLocale): string | null {
if (selection.kind !== 'filter') return null
return FILTER_TITLES[selection.filter as keyof typeof FILTER_TITLES] ?? null
if (!isLocalizedFilter(selection.filter)) return null
return translate(locale, FILTER_TITLE_KEYS[selection.filter])
}
export function resolveHeaderTitle(selection: SidebarSelection, typeDocument: VaultEntry | null, views?: ViewFile[]): string {
export function resolveHeaderTitle(selection: SidebarSelection, typeDocument: VaultEntry | null, views?: ViewFile[], locale: AppLocale = 'en'): string {
if (selection.kind === 'view') {
const view = views?.find((v) => v.filename === selection.filename)
return view?.definition.name ?? 'View'
return view?.definition.name ?? translate(locale, 'noteList.title.view')
}
if (selection.kind === 'entity') return selection.entry.title
if (typeDocument) return typeDocument.title
return resolveSelectionFilterTitle(selection) ?? 'Notes'
return resolveSelectionFilterTitle(selection, locale) ?? translate(locale, 'noteList.title.notes')
}
export function filterByQuery<T extends { title: string }>(items: T[], query: string): T[] {

View File

@@ -8,6 +8,7 @@ import type {
ViewDefinition,
ViewFile,
} from '../../types'
import type { AppLocale } from '../../lib/i18n'
import type { NoteListFilter } from '../../utils/noteListHelpers'
import { countByFilter, countAllByFilter, countAllNotesByFilter } from '../../utils/noteListHelpers'
import { NoteItem } from '../NoteItem'
@@ -428,6 +429,7 @@ export interface NoteListProps {
onUpdateViewDefinition?: (filename: string, patch: Partial<ViewDefinition>) => void
views?: ViewFile[]
visibleNotesRef?: React.MutableRefObject<VaultEntry[]>
locale?: AppLocale
}
function buildNoteListLayoutModel(params: {
@@ -439,6 +441,7 @@ function buildNoteListLayoutModel(params: {
filterCounts: ReturnType<typeof useFilterCounts>
onNoteListFilterChange: (filter: NoteListFilter) => void
onOpenType: (entry: VaultEntry) => void
locale: AppLocale
content: ReturnType<typeof useNoteListContent> & {
handleSearchKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
}
@@ -448,7 +451,8 @@ function buildNoteListLayoutModel(params: {
}
}) {
return {
title: resolveHeaderTitle(params.selection, params.content.typeDocument, params.views),
title: resolveHeaderTitle(params.selection, params.content.typeDocument, params.views, params.locale),
locale: params.locale,
typeDocument: params.content.typeDocument,
isEntityView: params.content.isEntityView,
listSort: params.content.listSort,
@@ -531,6 +535,7 @@ export function useNoteListModel({
onUpdateViewDefinition,
views,
visibleNotesRef,
locale = 'en',
}: NoteListProps) {
const selectedNotePath = selectedNote?.path ?? null
const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus)
@@ -626,6 +631,7 @@ export function useNoteListModel({
noteListFilter,
filterCounts,
onNoteListFilterChange,
locale,
content: {
...content,
handleSearchKeyDown,

View File

@@ -31,6 +31,60 @@ describe('buildSettingsCommands', () => {
})
})
it('adds a discoverable language settings command', () => {
const onOpenSettings = vi.fn()
const commands = buildSettingsCommands({ onOpenSettings })
const command = commands.find((item) => item.id === 'open-language-settings')
expect(command).toMatchObject({
label: 'Open Language Settings',
enabled: true,
group: 'Settings',
})
command?.execute()
expect(onOpenSettings).toHaveBeenCalledTimes(1)
})
it('adds language switch commands when a setter is available', () => {
const onOpenSettings = vi.fn()
const onSetUiLanguage = vi.fn()
const commands = buildSettingsCommands({
onOpenSettings,
selectedUiLanguage: 'en',
onSetUiLanguage,
})
const chinese = commands.find((item) => item.id === 'switch-language-zh-hans')
expect(chinese).toMatchObject({
label: 'Switch Language to Simplified Chinese',
enabled: true,
})
chinese?.execute()
expect(onSetUiLanguage).toHaveBeenCalledWith('zh-Hans')
})
it('localizes language commands', () => {
const commands = buildSettingsCommands({
onOpenSettings: vi.fn(),
locale: 'zh-Hans',
systemLocale: 'zh-Hans',
selectedUiLanguage: 'system',
onSetUiLanguage: vi.fn(),
})
expect(commands.find((item) => item.id === 'open-language-settings')).toMatchObject({
label: '打开语言设置',
})
expect(commands.find((item) => item.id === 'use-system-language')).toMatchObject({
label: '使用系统语言 (简体中文)',
enabled: false,
})
})
it('adds a create-empty-vault command when the handler is available', () => {
const onOpenSettings = vi.fn()
const onCreateEmptyVault = vi.fn()

View File

@@ -1,6 +1,13 @@
import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../appCommandCatalog'
import type { CommandAction } from './types'
import { rememberFeedbackDialogOpener } from '../../lib/feedbackDialogOpener'
import {
SYSTEM_UI_LANGUAGE,
createTranslator,
localeDisplayName,
type AppLocale,
type UiLanguagePreference,
} from '../../lib/i18n'
interface SettingsCommandsConfig {
mcpStatus?: string
@@ -16,18 +23,36 @@ interface SettingsCommandsConfig {
onInstallMcp?: () => void
onReloadVault?: () => void
onRepairVault?: () => void
locale?: AppLocale
systemLocale?: AppLocale
selectedUiLanguage?: UiLanguagePreference
onSetUiLanguage?: (language: UiLanguagePreference) => void
}
function commandKeywords(raw: string): string[] {
return raw.split(/\s+/).filter(Boolean)
}
function buildPrimarySettingsCommands({
locale = 'en',
onOpenSettings,
onOpenFeedback,
onCheckForUpdates,
}: Pick<SettingsCommandsConfig, 'onOpenSettings' | 'onOpenFeedback' | 'onCheckForUpdates'>): CommandAction[] {
}: Pick<SettingsCommandsConfig, 'locale' | 'onOpenSettings' | 'onOpenFeedback' | 'onCheckForUpdates'>): CommandAction[] {
const t = createTranslator(locale)
return [
{ id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.appSettings), keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings },
{
id: 'open-settings',
label: t('command.openSettings'),
group: 'Settings',
shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.appSettings),
keywords: commandKeywords(t('command.openSettings.keywords')),
enabled: true,
execute: onOpenSettings,
},
{
id: 'open-h1-auto-rename-setting',
label: 'Open H1 Auto-Rename Setting',
label: t('command.openH1Setting'),
group: 'Settings',
keywords: ['h1', 'title', 'filename', 'rename', 'auto', 'untitled', 'sync', 'preference'],
enabled: true,
@@ -35,7 +60,7 @@ function buildPrimarySettingsCommands({
},
{
id: 'open-contribute',
label: 'Contribute',
label: t('command.contribute'),
group: 'Settings',
keywords: ['contribute', 'feedback', 'feature', 'canny', 'discussion', 'github', 'bug', 'report'],
enabled: !!onOpenFeedback,
@@ -44,7 +69,53 @@ function buildPrimarySettingsCommands({
onOpenFeedback?.()
},
},
{ id: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: true, execute: () => onCheckForUpdates?.() },
{ id: 'check-updates', label: t('command.checkUpdates'), group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: true, execute: () => onCheckForUpdates?.() },
]
}
function buildLanguageCommands({
locale = 'en',
systemLocale = locale,
selectedUiLanguage = SYSTEM_UI_LANGUAGE,
onOpenSettings,
onSetUiLanguage,
}: Pick<SettingsCommandsConfig, 'locale' | 'systemLocale' | 'selectedUiLanguage' | 'onOpenSettings' | 'onSetUiLanguage'>): CommandAction[] {
const t = createTranslator(locale)
const canSwitchLanguage = !!onSetUiLanguage
return [
{
id: 'open-language-settings',
label: t('command.openLanguageSettings'),
group: 'Settings',
keywords: commandKeywords(t('command.openLanguageSettings.keywords')),
enabled: true,
execute: onOpenSettings,
},
{
id: 'use-system-language',
label: `${t('command.useSystemLanguage')} (${localeDisplayName(systemLocale, locale)})`,
group: 'Settings',
keywords: ['language', 'locale', 'system', 'auto'],
enabled: canSwitchLanguage && selectedUiLanguage !== SYSTEM_UI_LANGUAGE,
execute: () => onSetUiLanguage?.(SYSTEM_UI_LANGUAGE),
},
{
id: 'switch-language-en',
label: t('command.switchToEnglish'),
group: 'Settings',
keywords: ['language', 'locale', 'english', 'en'],
enabled: canSwitchLanguage && selectedUiLanguage !== 'en',
execute: () => onSetUiLanguage?.('en'),
},
{
id: 'switch-language-zh-hans',
label: t('command.switchToChinese'),
group: 'Settings',
keywords: ['language', 'locale', 'chinese', 'simplified', 'zh', '中文'],
enabled: canSwitchLanguage && selectedUiLanguage !== 'zh-Hans',
execute: () => onSetUiLanguage?.('zh-Hans'),
},
]
}
@@ -89,10 +160,18 @@ export function buildSettingsCommands(config: SettingsCommandsConfig): CommandAc
mcpStatus, vaultCount, isGettingStartedHidden,
onOpenSettings, onOpenFeedback, onOpenVault, onCreateEmptyVault, onRemoveActiveVault, onRestoreGettingStarted,
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault,
locale = 'en', systemLocale = locale, selectedUiLanguage = SYSTEM_UI_LANGUAGE, onSetUiLanguage,
} = config
return [
...buildPrimarySettingsCommands({ onOpenSettings, onOpenFeedback, onCheckForUpdates }),
...buildPrimarySettingsCommands({ locale, onOpenSettings, onOpenFeedback, onCheckForUpdates }),
...buildLanguageCommands({
locale,
systemLocale,
selectedUiLanguage,
onOpenSettings,
onSetUiLanguage,
}),
...buildVaultSettingsCommands({
vaultCount,
isGettingStartedHidden,

View File

@@ -1,5 +1,6 @@
import { useCallback, useRef } from 'react'
import type { AiAgentId, AiAgentsStatus } from '../lib/aiAgents'
import type { AppLocale, UiLanguagePreference } from '../lib/i18n'
import type { VaultAiGuidanceStatus } from '../lib/vaultAiGuidance'
import { useAppKeyboard } from './useAppKeyboard'
import { useCommandRegistry } from './useCommandRegistry'
@@ -66,6 +67,10 @@ interface AppCommandsConfig {
onRestoreGettingStarted?: () => void
isGettingStartedHidden?: boolean
vaultCount?: number
locale?: AppLocale
systemLocale?: AppLocale
selectedUiLanguage?: UiLanguagePreference
onSetUiLanguage?: (language: UiLanguagePreference) => void
mcpStatus?: string
onInstallMcp?: () => void
aiAgentsStatus?: AiAgentsStatus
@@ -149,6 +154,10 @@ type CommandRegistryVaultActions = Pick<
| 'canAddRemote'
| 'onCheckForUpdates'
| 'onCreateType'
| 'locale'
| 'systemLocale'
| 'selectedUiLanguage'
| 'onSetUiLanguage'
| 'onRemoveActiveVault'
| 'onRestoreGettingStarted'
| 'isGettingStartedHidden'
@@ -398,6 +407,10 @@ function createCommandRegistryVaultConfig(
canAddRemote: config.canAddRemote ?? true,
onCheckForUpdates: config.onCheckForUpdates,
onCreateType: config.onCreateType,
locale: config.locale,
systemLocale: config.systemLocale,
selectedUiLanguage: config.selectedUiLanguage,
onSetUiLanguage: config.onSetUiLanguage,
onRemoveActiveVault: config.onRemoveActiveVault,
onRestoreGettingStarted: config.onRestoreGettingStarted,
isGettingStartedHidden: config.isGettingStartedHidden,

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react'
import type { AiAgentId, AiAgentsStatus } from '../lib/aiAgents'
import type { AppLocale, UiLanguagePreference } from '../lib/i18n'
import type { VaultAiGuidanceStatus } from '../lib/vaultAiGuidance'
import type { NoteLayout, SidebarSelection, VaultEntry } from '../types'
import type { NoteListFilter } from '../utils/noteListHelpers'
@@ -40,6 +41,10 @@ interface CommandRegistryConfig {
onRepairVault?: () => void
onSetNoteIcon?: () => void
onRemoveNoteIcon?: () => void
locale?: AppLocale
systemLocale?: AppLocale
selectedUiLanguage?: UiLanguagePreference
onSetUiLanguage?: (language: UiLanguagePreference) => void
onChangeNoteType?: () => void
onMoveNoteToFolder?: () => void
canMoveNoteToFolder?: boolean
@@ -114,6 +119,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
mcpStatus, onInstallMcp, aiAgentsStatus, vaultAiGuidanceStatus,
onOpenAiAgents, onRestoreVaultAiGuidance, onSetDefaultAiAgent, selectedAiAgent, onCycleDefaultAiAgent, selectedAiAgentLabel,
onReloadVault, onRepairVault,
locale, systemLocale, selectedUiLanguage, onSetUiLanguage,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onChangeNoteType, onMoveNoteToFolder, canMoveNoteToFolder,
onOpenInNewWindow, onToggleFavorite, onToggleOrganized,
onCustomizeNoteListColumns, canCustomizeNoteListColumns,
@@ -178,6 +184,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
mcpStatus, vaultCount, isGettingStartedHidden,
onOpenSettings, onOpenFeedback, onOpenVault, onCreateEmptyVault, onRemoveActiveVault, onRestoreGettingStarted,
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault,
locale, systemLocale, selectedUiLanguage, onSetUiLanguage,
}),
...buildAiAgentCommands({
aiAgentsStatus,
@@ -205,7 +212,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
mcpStatus, onInstallMcp, aiAgentsStatus, vaultAiGuidanceStatus,
onOpenAiAgents, onRestoreVaultAiGuidance, onSetDefaultAiAgent, selectedAiAgent, onCycleDefaultAiAgent, selectedAiAgentLabel,
onReloadVault, onRepairVault,
onReloadVault, onRepairVault, locale, systemLocale, selectedUiLanguage, onSetUiLanguage,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onChangeNoteType, onMoveNoteToFolder, canMoveNoteToFolder,
isSectionGroup, noteListFilter, onSetNoteListFilter,
selection,

View File

@@ -15,6 +15,7 @@ const defaultSettings: Settings = {
anonymous_id: null,
release_channel: null,
theme_mode: null,
ui_language: null,
default_ai_agent: null,
}
@@ -30,6 +31,7 @@ const savedSettings: Settings = {
anonymous_id: null,
release_channel: null,
theme_mode: null,
ui_language: null,
default_ai_agent: null,
}
@@ -53,6 +55,16 @@ vi.mock('../mock-tauri', () => ({
mockInvoke: (cmd: string, args?: Record<string, unknown>) => mockInvokeFn(cmd, args),
}))
async function renderLoadedSettings(): Promise<Settings> {
const { result } = renderHook(() => useSettings())
await waitFor(() => {
expect(result.current.loaded).toBe(true)
})
return result.current.settings
}
describe('useSettings', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -86,13 +98,18 @@ describe('useSettings', () => {
release_channel: 'beta',
}
const { result } = renderHook(() => useSettings())
const settings = await renderLoadedSettings()
expect(settings.release_channel).toBeNull()
})
await waitFor(() => {
expect(result.current.loaded).toBe(true)
})
it('normalizes unsupported language preferences on load', async () => {
mockSettingsStore = {
...savedSettings,
ui_language: 'fr-FR' as Settings['ui_language'],
}
expect(result.current.settings.release_channel).toBeNull()
const settings = await renderLoadedSettings()
expect(settings.ui_language).toBeNull()
})
it('saves settings via backend', async () => {
@@ -114,6 +131,7 @@ describe('useSettings', () => {
anonymous_id: null,
release_channel: null,
theme_mode: null,
ui_language: 'zh-Hans',
default_ai_agent: null,
}

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import { normalizeStoredAiAgent } from '../lib/aiAgents'
import { serializeUiLanguagePreference } from '../lib/i18n'
import { normalizeReleaseChannel, serializeReleaseChannel } from '../lib/releaseChannel'
import { normalizeThemeMode } from '../lib/themeMode'
import type { Settings } from '../types'
@@ -22,6 +23,7 @@ const EMPTY_SETTINGS: Settings = {
anonymous_id: null,
release_channel: null,
theme_mode: null,
ui_language: null,
default_ai_agent: null,
}
@@ -32,6 +34,7 @@ function normalizeSettings(settings: Settings): Settings {
normalizeReleaseChannel(settings.release_channel),
),
theme_mode: normalizeThemeMode(settings.theme_mode),
ui_language: serializeUiLanguagePreference(settings.ui_language),
default_ai_agent: normalizeStoredAiAgent(settings.default_ai_agent),
}
}

37
src/lib/i18n.test.ts Normal file
View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import {
localeDisplayName,
normalizeUiLanguagePreference,
resolveEffectiveLocale,
serializeUiLanguagePreference,
translate,
} from './i18n'
describe('i18n', () => {
it('uses supported system languages before falling back to English', () => {
expect(resolveEffectiveLocale(null, ['zh-CN'])).toBe('zh-Hans')
expect(resolveEffectiveLocale('system', ['fr-FR'])).toBe('en')
})
it('normalizes stored language preferences', () => {
expect(normalizeUiLanguagePreference(' zh-cn ')).toBe('zh-Hans')
expect(normalizeUiLanguagePreference('auto')).toBe('system')
expect(normalizeUiLanguagePreference('fr-FR')).toBeNull()
})
it('serializes system preference as the settings default', () => {
expect(serializeUiLanguagePreference('system')).toBeNull()
expect(serializeUiLanguagePreference('zh-Hans')).toBe('zh-Hans')
})
it('falls back to English when a locale is partially translated', () => {
expect(translate('zh-Hans', 'settings.aiAgents.description')).toBe(
translate('en', 'settings.aiAgents.description'),
)
})
it('formats locale display names in the active language', () => {
expect(localeDisplayName('zh-Hans', 'zh-Hans')).toBe('简体中文')
expect(localeDisplayName('en', 'zh-Hans')).toBe('英文')
})
})

271
src/lib/i18n.ts Normal file
View File

@@ -0,0 +1,271 @@
export const DEFAULT_APP_LOCALE = 'en'
export const SYSTEM_UI_LANGUAGE = 'system'
export const APP_LOCALES = ['en', 'zh-Hans'] as const
export type AppLocale = typeof APP_LOCALES[number]
export type UiLanguagePreference = typeof SYSTEM_UI_LANGUAGE | AppLocale
const SIMPLIFIED_CHINESE_LANGUAGE_CODES = new Set(['zh', 'zh-cn', 'zh-hans', 'zh-sg'])
const EN_TRANSLATIONS = {
'command.noMatches': 'No matching commands',
'command.palettePlaceholder': 'Type a command...',
'command.footerNavigate': '↑↓ navigate',
'command.footerSelect': '↵ select',
'command.footerClose': 'esc close',
'command.footerSend': '↵ send',
'command.aiMode': '{agent} mode',
'command.openSettings': 'Open Settings',
'command.openSettings.keywords': 'preferences config',
'command.openLanguageSettings': 'Open Language Settings',
'command.openLanguageSettings.keywords': 'language locale i18n internationalization localization chinese english 中文',
'command.useSystemLanguage': 'Use System Language',
'command.switchToEnglish': 'Switch Language to English',
'command.switchToChinese': 'Switch Language to Simplified Chinese',
'command.openH1Setting': 'Open H1 Auto-Rename Setting',
'command.contribute': 'Contribute',
'command.checkUpdates': 'Check for Updates',
'settings.title': 'Settings',
'settings.close': 'Close settings',
'settings.sync.title': 'Sync & Updates',
'settings.sync.description': 'Configure background pulling and which update feed Tolaria follows. Stable only receives manually promoted releases, while Alpha follows every push to main.',
'settings.pullInterval': 'Pull interval (minutes)',
'settings.releaseChannel': 'Release channel',
'settings.releaseStable': 'Stable',
'settings.releaseAlpha': 'Alpha',
'settings.appearance.title': 'Appearance',
'settings.appearance.description': 'Choose the app color mode used for Tolaria chrome, editor surfaces, menus, and dialogs.',
'settings.theme.label': 'Theme',
'settings.theme.light': 'Light',
'settings.theme.dark': 'Dark',
'settings.language.title': 'Language',
'settings.language.description': 'Choose the display language for Tolaria chrome. System follows macOS when that language is supported, with English as the fallback.',
'settings.language.label': 'Display language',
'settings.language.system': 'System ({language})',
'settings.language.en': 'English',
'settings.language.zhHans': 'Simplified Chinese',
'settings.language.summary': 'Missing translations fall back to English so partially translated locales stay usable.',
'settings.autogit.title': 'AutoGit',
'settings.autogit.description.enabled': 'Automatically create conservative Git checkpoints after editing pauses or when the app is no longer active.',
'settings.autogit.description.disabled': 'AutoGit is unavailable until the current vault is Git-enabled. Initialize Git for this vault first.',
'settings.autogit.enable': 'Enable AutoGit',
'settings.autogit.enableDescription': 'When enabled, Tolaria will commit and push saved local changes automatically after an idle pause or after the app becomes inactive.',
'settings.autogit.idleThreshold': 'Idle threshold (seconds)',
'settings.autogit.inactiveThreshold': 'Inactive-app grace period (seconds)',
'settings.titles.title': 'Titles & Filenames',
'settings.titles.description': 'Choose whether Tolaria automatically syncs untitled note filenames from the first H1 title.',
'settings.titles.autoRename': 'Auto-rename untitled notes from first H1',
'settings.titles.autoRenameDescription': 'When enabled, Tolaria renames untitled-note files as soon as the first H1 becomes a real title. Turn this off to keep the filename unchanged until you rename it manually from the breadcrumb bar.',
'settings.aiAgents.title': 'AI Agents',
'settings.aiAgents.description': 'Choose which CLI AI agent Tolaria uses in the AI panel and command palette.',
'settings.aiAgents.default': 'Default AI agent',
'settings.aiAgents.installed': 'installed',
'settings.aiAgents.missing': 'missing',
'settings.aiAgents.ready': '{agent}{version} is ready to use.',
'settings.aiAgents.notInstalled': '{agent} is not installed yet. You can still select it now and install it later.',
'settings.workflow.title': 'Workflow',
'settings.workflow.description': 'Choose whether Tolaria shows the Inbox workflow, plus how it moves through items while you triage them.',
'settings.workflow.explicit': 'Organize notes explicitly',
'settings.workflow.explicitDescription': 'When enabled, an Inbox section shows unorganized notes, and a toggle lets you mark notes as organized.',
'settings.workflow.autoAdvance': 'Auto-advance to next Inbox item',
'settings.workflow.autoAdvanceDescription': 'When enabled, marking an Inbox note as organized immediately opens the next visible Inbox note.',
'settings.privacy.title': 'Privacy & Telemetry',
'settings.privacy.description': 'Anonymous data helps us fix bugs and improve Tolaria. No vault content, note titles, or file paths are ever sent.',
'settings.privacy.crashReporting': 'Crash reporting',
'settings.privacy.crashReportingDescription': 'Send anonymous error reports',
'settings.privacy.analytics': 'Usage analytics',
'settings.privacy.analyticsDescription': 'Share anonymous usage patterns',
'settings.footerShortcut': '⌘, to open settings',
'settings.cancel': 'Cancel',
'settings.save': 'Save',
'locale.en': 'English',
'locale.zhHans': 'Simplified Chinese',
'noteList.title.archive': 'Archive',
'noteList.title.changes': 'Changes',
'noteList.title.inbox': 'Inbox',
'noteList.title.history': 'History',
'noteList.title.view': 'View',
'noteList.title.notes': 'Notes',
'noteList.searchPlaceholder': 'Search notes...',
'noteList.searchAction': 'Search notes',
'noteList.createNote': 'Create new note',
'noteList.empty.changesError': 'Failed to load changes: {error}',
'noteList.empty.noChanges': 'No pending changes',
'noteList.empty.noArchived': 'No archived notes',
'noteList.empty.noMatching': 'No matching notes',
'noteList.empty.allOrganized': 'All notes are organized',
'noteList.empty.noNotes': 'No notes found',
'noteList.empty.noMatchingItems': 'No matching items',
'noteList.empty.noRelatedItems': 'No related items',
} as const
export type TranslationKey = keyof typeof EN_TRANSLATIONS
type TranslationValues = Record<string, string | number>
const ZH_HANS_TRANSLATIONS: Partial<Record<TranslationKey, string>> = {
'command.noMatches': '没有匹配的命令',
'command.palettePlaceholder': '输入命令...',
'command.footerNavigate': '↑↓ 导航',
'command.footerSelect': '↵ 选择',
'command.footerClose': 'esc 关闭',
'command.footerSend': '↵ 发送',
'command.aiMode': '{agent} 模式',
'command.openSettings': '打开设置',
'command.openSettings.keywords': '设置 偏好 配置',
'command.openLanguageSettings': '打开语言设置',
'command.openLanguageSettings.keywords': '语言 区域 i18n 国际化 本地化 中文 english',
'command.useSystemLanguage': '使用系统语言',
'command.switchToEnglish': '切换到英文',
'command.switchToChinese': '切换到简体中文',
'command.openH1Setting': '打开 H1 自动重命名设置',
'command.contribute': '参与贡献',
'command.checkUpdates': '检查更新',
'settings.title': '设置',
'settings.close': '关闭设置',
'settings.sync.title': '同步与更新',
'settings.sync.description': '配置后台拉取以及 Tolaria 使用的更新通道。Stable 只接收手动推广的版本Alpha 跟随 main 的每次推送。',
'settings.pullInterval': '拉取间隔(分钟)',
'settings.releaseChannel': '发布通道',
'settings.releaseStable': 'Stable',
'settings.releaseAlpha': 'Alpha',
'settings.appearance.title': '外观',
'settings.appearance.description': '选择 Tolaria 界面、编辑器、菜单和对话框使用的颜色模式。',
'settings.theme.label': '主题',
'settings.theme.light': '浅色',
'settings.theme.dark': '深色',
'settings.language.title': '语言',
'settings.language.description': '选择 Tolaria 界面的显示语言。系统选项会在支持时跟随 macOS否则回退到英文。',
'settings.language.label': '显示语言',
'settings.language.system': '系统({language}',
'settings.language.en': '英文',
'settings.language.zhHans': '简体中文',
'settings.language.summary': '缺失的翻译会回退到英文,因此部分翻译的语言也能正常使用。',
'settings.autogit.title': 'AutoGit',
'settings.autogit.description.enabled': '在编辑暂停或应用不再活跃后,自动创建保守的 Git 检查点。',
'settings.autogit.description.disabled': '当前仓库启用 Git 后才能使用 AutoGit。请先为此仓库初始化 Git。',
'settings.autogit.enable': '启用 AutoGit',
'settings.autogit.enableDescription': '启用后Tolaria 会在空闲暂停或应用变为非活跃后自动提交并推送保存的本地更改。',
'settings.autogit.idleThreshold': '空闲阈值(秒)',
'settings.autogit.inactiveThreshold': '应用非活跃宽限期(秒)',
'settings.titles.title': '标题与文件名',
'settings.titles.description': '选择 Tolaria 是否根据第一个 H1 标题自动同步未命名笔记的文件名。',
'settings.titles.autoRename': '根据第一个 H1 自动重命名未命名笔记',
'settings.titles.autoRenameDescription': '启用后,只要第一个 H1 成为真实标题Tolaria 就会重命名 untitled-note 文件。关闭后,文件名会保持不变,直到你从面包屑栏手动重命名。',
'settings.aiAgents.title': 'AI 代理',
'settings.aiAgents.default': '默认 AI 代理',
'settings.aiAgents.installed': '已安装',
'settings.aiAgents.missing': '缺失',
'settings.aiAgents.ready': '{agent}{version} 已可使用。',
'settings.aiAgents.notInstalled': '{agent} 尚未安装。你仍可先选择它,稍后再安装。',
'settings.workflow.title': '工作流',
'settings.workflow.description': '选择 Tolaria 是否显示 Inbox 工作流,以及整理时如何移动到下一项。',
'settings.workflow.explicit': '显式整理笔记',
'settings.workflow.explicitDescription': '启用后Inbox 会显示尚未整理的笔记,并提供一个开关用于标记为已整理。',
'settings.workflow.autoAdvance': '自动前进到下一条 Inbox',
'settings.workflow.autoAdvanceDescription': '启用后,将 Inbox 笔记标记为已整理会立即打开下一条可见的 Inbox 笔记。',
'settings.privacy.title': '隐私与遥测',
'settings.privacy.description': '匿名数据可帮助我们修复错误并改进 Tolaria。不会发送仓库内容、笔记标题或文件路径。',
'settings.privacy.crashReporting': '崩溃报告',
'settings.privacy.crashReportingDescription': '发送匿名错误报告',
'settings.privacy.analytics': '使用分析',
'settings.privacy.analyticsDescription': '分享匿名使用模式',
'settings.footerShortcut': '⌘, 打开设置',
'settings.cancel': '取消',
'settings.save': '保存',
'locale.en': '英文',
'locale.zhHans': '简体中文',
'noteList.title.archive': '归档',
'noteList.title.changes': '更改',
'noteList.title.inbox': 'Inbox',
'noteList.title.history': '历史',
'noteList.title.view': '视图',
'noteList.title.notes': '笔记',
'noteList.searchPlaceholder': '搜索笔记...',
'noteList.searchAction': '搜索笔记',
'noteList.createNote': '新建笔记',
'noteList.empty.changesError': '加载更改失败:{error}',
'noteList.empty.noChanges': '没有待处理更改',
'noteList.empty.noArchived': '没有归档笔记',
'noteList.empty.noMatching': '没有匹配的笔记',
'noteList.empty.allOrganized': '所有笔记都已整理',
'noteList.empty.noNotes': '没有笔记',
'noteList.empty.noMatchingItems': '没有匹配项',
'noteList.empty.noRelatedItems': '没有相关项',
}
const TRANSLATIONS: Record<AppLocale, Partial<Record<TranslationKey, string>>> = {
en: EN_TRANSLATIONS,
'zh-Hans': ZH_HANS_TRANSLATIONS,
}
export function interpolate(template: string, values: TranslationValues = {}): string {
return template.replace(/\{(\w+)\}/g, (match, key) => {
const value = values[key]
return value === undefined ? match : String(value)
})
}
export function translate(locale: AppLocale, key: TranslationKey, values?: TranslationValues): string {
const template = TRANSLATIONS[locale]?.[key] ?? EN_TRANSLATIONS[key]
return interpolate(template, values)
}
export function createTranslator(locale: AppLocale) {
return (key: TranslationKey, values?: TranslationValues) => translate(locale, key, values)
}
function normalizeLocaleCode(value: string): AppLocale | null {
const normalized = value.trim().replace('_', '-').toLowerCase()
if (normalized === 'en' || normalized.startsWith('en-')) return 'en'
if (SIMPLIFIED_CHINESE_LANGUAGE_CODES.has(normalized)) return 'zh-Hans'
return null
}
export function normalizeUiLanguagePreference(value: unknown): UiLanguagePreference | null {
if (typeof value !== 'string') return null
const trimmed = value.trim()
if (!trimmed) return null
const lower = trimmed.toLowerCase()
if (lower === SYSTEM_UI_LANGUAGE || lower === 'auto') return SYSTEM_UI_LANGUAGE
return normalizeLocaleCode(trimmed)
}
export function serializeUiLanguagePreference(value: unknown): AppLocale | null {
const normalized = normalizeUiLanguagePreference(value)
if (!normalized || normalized === SYSTEM_UI_LANGUAGE) return null
return normalized
}
export function getBrowserLanguagePreferences(): string[] {
if (typeof navigator === 'undefined') return []
const languages = Array.isArray(navigator.languages) ? navigator.languages : []
if (languages.length > 0) return [...languages]
return navigator.language ? [navigator.language] : []
}
export function resolveEffectiveLocale(
preference: unknown,
languagePreferences: readonly string[] = getBrowserLanguagePreferences(),
): AppLocale {
const normalizedPreference = normalizeUiLanguagePreference(preference)
if (normalizedPreference && normalizedPreference !== SYSTEM_UI_LANGUAGE) {
return normalizedPreference
}
for (const language of languagePreferences) {
const locale = normalizeLocaleCode(language)
if (locale) return locale
}
return DEFAULT_APP_LOCALE
}
export function localeDisplayName(locale: AppLocale, displayLocale: AppLocale = locale): string {
return translate(displayLocale, locale === 'zh-Hans' ? 'locale.zhHans' : 'locale.en')
}

View File

@@ -152,6 +152,7 @@ describe('mockHandlers coverage', () => {
analytics_enabled: true,
anonymous_id: 'anon-1',
release_channel: 'alpha',
ui_language: 'zh-Hans',
default_ai_agent: 'codex',
},
})
@@ -168,6 +169,7 @@ describe('mockHandlers coverage', () => {
anonymous_id: 'anon-1',
release_channel: 'alpha',
theme_mode: null,
ui_language: 'zh-Hans',
default_ai_agent: 'codex',
})

View File

@@ -112,6 +112,7 @@ let mockSettings: Settings = {
anonymous_id: null,
release_channel: null,
theme_mode: null,
ui_language: null,
default_ai_agent: 'claude_code',
}
@@ -418,6 +419,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
anonymous_id: s.anonymous_id,
release_channel: s.release_channel,
theme_mode: s.theme_mode ?? null,
ui_language: s.ui_language ?? null,
default_ai_agent: s.default_ai_agent ?? null,
}
return null

View File

@@ -1,5 +1,6 @@
import type { AiAgentId } from './lib/aiAgents'
import type { ThemeMode } from './lib/themeMode'
import type { AppLocale } from './lib/i18n'
export interface VaultEntry {
path: string
@@ -91,6 +92,7 @@ export interface Settings {
anonymous_id: string | null
release_channel: string | null
theme_mode?: ThemeMode | null
ui_language?: AppLocale | null
initial_h1_auto_rename_enabled?: boolean | null
default_ai_agent?: AiAgentId | null
}