feat: add app localization foundation
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
35
docs/adr/0084-app-localization-foundation.md
Normal file
35
docs/adr/0084-app-localization-foundation.md
Normal 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.
|
||||
@@ -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();
|
||||
|
||||
31
src/App.tsx
31
src/App.tsx
@@ -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} />
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
37
src/lib/i18n.test.ts
Normal 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
271
src/lib/i18n.ts
Normal 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')
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user