fix: complete app localization pass

This commit is contained in:
lucaronin
2026-04-26 19:37:21 +02:00
parent 1d546dde3d
commit a2f8ba72a2
57 changed files with 2566 additions and 1016 deletions

View File

@@ -1015,6 +1015,32 @@ describe('App', () => {
})
})
it('clears the Git setup dialog when switching to a Git-enabled vault', async () => {
mockCommandResults.load_vault_list = {
vaults: [
{ label: 'Missing Git', path: '/work' },
{ label: 'Git Vault', path: '/vault-2' },
],
active_vault: '/work',
hidden_defaults: [],
}
mockCommandResults.is_git_repo = ({ vaultPath }: { vaultPath?: string } = {}) => vaultPath === '/vault-2'
render(<App />)
expect(await screen.findByText('Enable Git for this vault?')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('status-vault-trigger'))
fireEvent.click(screen.getByTestId('vault-menu-item-Git Vault'))
await waitFor(() => {
expect(screen.getByTestId('status-vault-trigger')).toHaveTextContent('Git Vault')
})
await waitFor(() => {
expect(screen.queryByText('Enable Git for this vault?')).not.toBeInTheDocument()
})
})
it('Cmd+1 hides sidebar and note list (editor-only mode)', async () => {
render(<App />)
await waitFor(() => {

View File

@@ -341,9 +341,15 @@ function App() {
setShowGitSetupDialog(true)
}, [gitRepoState, noteWindowParams, resolvedPath])
useEffect(() => {
if (gitRepoState === 'missing') return
setShowGitSetupDialog(false)
}, [gitRepoState])
const openGitSetupDialog = useCallback(() => {
if (gitRepoState !== 'missing') return
setShowGitSetupDialog(true)
}, [])
}, [gitRepoState])
const dismissGitSetupDialog = useCallback(() => {
dismissedGitSetupPathRef.current = resolvedPath
@@ -893,6 +899,7 @@ function App() {
})
const suggestedCommitMessage = useMemo(() => generateCommitMessage(vault.modifiedFiles), [vault.modifiedFiles])
const isGitVault = gitRepoState !== 'missing'
const shouldShowGitSetupDialog = !noteWindowParams && gitRepoState === 'missing' && showGitSetupDialog
const modifiedFilesSignature = useMemo(
() => vault.modifiedFiles.map((file) => `${file.relativePath}:${file.status}`).sort().join('|'),
[vault.modifiedFiles],
@@ -1505,7 +1512,7 @@ function App() {
{sidebarVisible && (
<>
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} />
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} locale={appLocale} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>
@@ -1514,7 +1521,7 @@ function App() {
<>
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} />
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} locale={appLocale} />
) : (
<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} />
)}
@@ -1579,13 +1586,14 @@ function App() {
onKeepMine={conflictFlow.handleKeepMine}
onKeepTheirs={conflictFlow.handleKeepTheirs}
flushPendingRawContentRef={flushPendingRawContentRef}
locale={appLocale}
/>
</div>
</div>
<UpdateBanner status={updateStatus} actions={updateActions} />
<UpdateBanner status={updateStatus} actions={updateActions} locale={appLocale} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
<GitSetupDialog open={!noteWindowParams && showGitSetupDialog} onInitGit={handleInitGitRepo} onDismiss={dismissGitSetupDialog} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} locale={appLocale} />
<GitSetupDialog open={shouldShowGitSetupDialog} onInitGit={handleInitGitRepo} onDismiss={dismissGitSetupDialog} />
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />

View File

@@ -12,6 +12,7 @@ import {
} from '../utils/propertyTypes'
import { StatusPill, StatusDropdown } from './StatusDropdown'
import { DISPLAY_MODE_OPTIONS, DISPLAY_MODE_ICONS } from '../utils/propertyTypes'
import { translate, type AppLocale } from '../lib/i18n'
function parseDateValue(value: string): Date | undefined {
const iso = toISODate(value)
@@ -39,7 +40,7 @@ function canSubmitProperty({ key, value, displayMode }: { key: string; value: st
return displayMode !== 'number' || isValidNumberValue(value)
}
function AddBooleanInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
function AddBooleanInput({ value, locale, onChange }: { value: string; locale: AppLocale; onChange: (v: string) => void }) {
const boolVal = value.toLowerCase() === 'true'
return (
<button
@@ -47,12 +48,12 @@ function AddBooleanInput({ value, onChange }: { value: string; onChange: (v: str
onClick={() => onChange(boolVal ? 'false' : 'true')}
data-testid="add-property-boolean-toggle"
>
{boolVal ? '\u2713 Yes' : '\u2717 No'}
{boolVal ? `\u2713 ${translate(locale, 'inspector.properties.yes')}` : `\u2717 ${translate(locale, 'inspector.properties.no')}`}
</button>
)
}
function AddDateInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
function AddDateInput({ value, locale, onChange }: { value: string; locale: AppLocale; onChange: (v: string) => void }) {
const selectedDate = value ? parseDateValue(value) : undefined
const formatted = value ? formatDateValue(value) : ''
return (
@@ -64,7 +65,7 @@ function AddDateInput({ value, onChange }: { value: string; onChange: (v: string
>
<CalendarIcon className="size-3 shrink-0 text-muted-foreground" />
<span className={`min-w-0 truncate${!formatted ? ' text-muted-foreground' : ' text-foreground'}`}>
{formatted || 'Pick a date\u2026'}
{formatted || translate(locale, 'inspector.properties.pickDate')}
</span>
</button>
</PopoverTrigger>
@@ -122,15 +123,16 @@ function AddNumberInput({ value, onChange, onKeyDown }: {
)
}
function AddPropertyValueInput({ displayMode, value, onChange, onKeyDown, vaultStatuses }: {
function AddPropertyValueInput({ displayMode, value, onChange, onKeyDown, vaultStatuses, locale }: {
displayMode: PropertyDisplayMode; value: string; onChange: (v: string) => void
onKeyDown: (e: React.KeyboardEvent) => void; vaultStatuses: string[]
locale: AppLocale
}) {
switch (displayMode) {
case 'number':
return <AddNumberInput value={value} onChange={onChange} onKeyDown={onKeyDown} />
case 'boolean': return <AddBooleanInput value={value} onChange={onChange} />
case 'date': return <AddDateInput value={value} onChange={onChange} />
case 'boolean': return <AddBooleanInput value={value} locale={locale} onChange={onChange} />
case 'date': return <AddDateInput value={value} locale={locale} onChange={onChange} />
case 'status': return <AddStatusInput value={value} onChange={onChange} vaultStatuses={vaultStatuses} />
case 'tags': return (
<Input className={ADD_INPUT_CLASS} type="text" placeholder="tag1, tag2, ..." value={value}
@@ -138,16 +140,17 @@ function AddPropertyValueInput({ displayMode, value, onChange, onKeyDown, vaultS
/>
)
default: return (
<Input className={ADD_INPUT_CLASS} type="text" placeholder="Value" value={value}
<Input className={ADD_INPUT_CLASS} type="text" placeholder={translate(locale, 'inspector.properties.valuePlaceholder')} value={value}
onChange={(e) => onChange(e.target.value)} onKeyDown={onKeyDown}
/>
)
}
}
export function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
export function AddPropertyForm({ onAdd, onCancel, vaultStatuses, locale = 'en' }: {
onAdd: (key: string, value: string, displayMode: PropertyDisplayMode) => void; onCancel: () => void
vaultStatuses: string[]
locale?: AppLocale
}) {
const [newKey, setNewKey] = useState('')
const [newValue, setNewValue] = useState('')
@@ -169,7 +172,7 @@ export function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
<div className="mt-1 flex flex-wrap items-center gap-1.5 rounded px-1.5 py-1" data-testid="add-property-form">
<Input
className="h-[26px] w-20 shrink-0 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary"
type="text" placeholder="Property name" value={newKey}
type="text" placeholder={translate(locale, 'inspector.properties.propertyName')} value={newKey}
onChange={(e) => setNewKey(e.target.value)} onKeyDown={handleKeyDown} autoFocus
/>
<Select value={displayMode} onValueChange={(v) => handleModeChange(v as PropertyDisplayMode)}>
@@ -193,15 +196,15 @@ export function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
})}
</SelectContent>
</Select>
<AddPropertyValueInput displayMode={displayMode} value={newValue} onChange={setNewValue} onKeyDown={handleKeyDown} vaultStatuses={vaultStatuses} />
<AddPropertyValueInput displayMode={displayMode} value={newValue} onChange={setNewValue} onKeyDown={handleKeyDown} vaultStatuses={vaultStatuses} locale={locale} />
<Button
size="icon-xs" onClick={() => onAdd(newKey, newValue, displayMode)}
disabled={!canSubmit} title="Add property"
disabled={!canSubmit} title={translate(locale, 'inspector.properties.addProperty')}
data-testid="add-property-confirm"
>
<Check className="size-3.5" />
</Button>
<Button size="icon-xs" variant="outline" onClick={onCancel} title="Cancel" data-testid="add-property-cancel">
<Button size="icon-xs" variant="outline" onClick={onCancel} title={translate(locale, 'common.cancel')} data-testid="add-property-cancel">
<X className="size-3.5" />
</Button>
</div>

View File

@@ -125,13 +125,7 @@ function SidebarGroupSkeleton({ rows, count }: { rows: string[]; count?: string
function SidebarSkeleton() {
return (
<aside className="flex h-full flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-sidebar text-sidebar-foreground">
<div className="flex h-[52px] shrink-0 items-center border-b border-border px-3">
<div className="flex gap-2">
<span className="h-3 w-3 rounded-full bg-[#ff5f57]" />
<span className="h-3 w-3 rounded-full bg-[#ffbd2e]" />
<span className="h-3 w-3 rounded-full bg-[#28c840]" />
</div>
</div>
<div className="h-[52px] shrink-0 border-b border-border" />
<nav className="flex-1 overflow-hidden py-1">
<div className="border-b border-border px-1.5 pb-1">
<SidebarRow icon={Inbox} width="48%" active countWidth="30px" />

View File

@@ -1,10 +1,12 @@
import { Archive, ArrowUUpLeft } from '@phosphor-icons/react'
import { translate, type AppLocale } from '../lib/i18n'
interface ArchivedNoteBannerProps {
onUnarchive: () => void
locale?: AppLocale
}
export function ArchivedNoteBanner({ onUnarchive }: ArchivedNoteBannerProps) {
export function ArchivedNoteBanner({ onUnarchive, locale = 'en' }: ArchivedNoteBannerProps) {
return (
<div
data-testid="archived-note-banner"
@@ -21,7 +23,7 @@ export function ArchivedNoteBanner({ onUnarchive }: ArchivedNoteBannerProps) {
}}
>
<Archive size={13} weight="bold" />
<span>Archived</span>
<span>{translate(locale, 'editor.banner.archived')}</span>
<button
data-testid="unarchive-btn"
onClick={onUnarchive}
@@ -38,10 +40,10 @@ export function ArchivedNoteBanner({ onUnarchive }: ArchivedNoteBannerProps) {
color: 'var(--muted-foreground)',
cursor: 'pointer',
}}
title="Unarchive"
title={translate(locale, 'editor.banner.unarchive')}
>
<ArrowUUpLeft size={12} />
Unarchive
{translate(locale, 'editor.banner.unarchive')}
</button>
</div>
)

View File

@@ -1,6 +1,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react'
import type { NoteLayout, VaultEntry } from '../types'
import { cn } from '@/lib/utils'
import { translate, type AppLocale } from '../lib/i18n'
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -49,6 +50,7 @@ interface BreadcrumbBarProps {
onToggleNoteLayout?: () => void
/** Ref for direct DOM manipulation — avoids re-render on scroll. */
barRef?: React.Ref<HTMLDivElement>
locale?: AppLocale
}
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
@@ -146,6 +148,12 @@ interface ToggleIconActionProps {
shortcut: string
}
interface TranslatedToggleIconActionProps extends Omit<ToggleIconActionProps, 'activeLabel' | 'inactiveLabel'> {
activeLabelKey: Parameters<typeof translate>[1]
inactiveLabelKey: Parameters<typeof translate>[1]
locale?: AppLocale
}
function ToggleIconAction({
active,
activeClassName,
@@ -170,26 +178,88 @@ function ToggleIconAction({
)
}
function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggleRaw?: () => void }) {
function TranslatedToggleIconAction({
activeLabelKey,
inactiveLabelKey,
locale = 'en',
...props
}: TranslatedToggleIconActionProps) {
return (
<ToggleIconAction
active={!!rawMode}
activeClassName="text-foreground"
activeLabel="Return to the editor"
inactiveLabel="Open the raw editor"
onClick={onToggleRaw}
shortcut={formatShortcutDisplay({ display: '⌘\\' })}
>
<Code size={16} className={BREADCRUMB_ICON_CLASS} />
</ToggleIconAction>
{...props}
activeLabel={translate(locale, activeLabelKey)}
inactiveLabel={translate(locale, inactiveLabelKey)}
/>
)
}
const TOGGLE_ACTION_CONFIGS = {
raw: {
activeClassName: 'text-foreground',
activeLabelKey: 'editor.toolbar.rawReturn',
inactiveLabelKey: 'editor.toolbar.rawOpen',
shortcut: '⌘\\',
renderIcon: () => <Code size={16} className={BREADCRUMB_ICON_CLASS} />,
},
favorite: {
activeClassName: 'text-[var(--accent-yellow)]',
activeLabelKey: 'editor.toolbar.removeFavorite',
inactiveLabelKey: 'editor.toolbar.addFavorite',
shortcut: '⌘D',
renderIcon: (active: boolean) => <Star size={16} weight={active ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />,
},
organized: {
activeClassName: 'text-[var(--accent-green)]',
activeLabelKey: 'editor.toolbar.markUnorganized',
inactiveLabelKey: 'editor.toolbar.markOrganized',
shortcut: '⌘E',
renderIcon: (active: boolean) => <CheckCircle size={16} weight={active ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />,
},
} satisfies Record<string, {
activeClassName: string
activeLabelKey: Parameters<typeof translate>[1]
inactiveLabelKey: Parameters<typeof translate>[1]
shortcut: string
renderIcon: (active: boolean) => ReactNode
}>
function ConfiguredToggleAction({
active,
config,
locale = 'en',
onClick,
}: {
active: boolean
config: (typeof TOGGLE_ACTION_CONFIGS)[keyof typeof TOGGLE_ACTION_CONFIGS]
locale?: AppLocale
onClick?: () => void
}) {
return (
<TranslatedToggleIconAction
active={active}
activeClassName={config.activeClassName}
activeLabelKey={config.activeLabelKey}
inactiveLabelKey={config.inactiveLabelKey}
locale={locale}
onClick={onClick}
shortcut={formatShortcutDisplay({ display: config.shortcut })}
>
{config.renderIcon(active)}
</TranslatedToggleIconAction>
)
}
function RawToggleButton({ rawMode, locale = 'en', onToggleRaw }: { rawMode?: boolean; locale?: AppLocale; onToggleRaw?: () => void }) {
return <ConfiguredToggleAction active={!!rawMode} config={TOGGLE_ACTION_CONFIGS.raw} locale={locale} onClick={onToggleRaw} />
}
function NoteLayoutAction({
noteLayout = 'centered',
locale = 'en',
onToggleNoteLayout,
}: {
noteLayout?: NoteLayout
locale?: AppLocale
onToggleNoteLayout?: () => void
}) {
if (!onToggleNoteLayout) return null
@@ -197,7 +267,7 @@ function NoteLayoutAction({
const isLeftAligned = noteLayout === 'left'
return (
<IconActionButton
copy={{ label: isLeftAligned ? 'Switch to centered note layout' : 'Switch to left-aligned note layout' }}
copy={{ label: translate(locale, isLeftAligned ? 'editor.toolbar.centerLayout' : 'editor.toolbar.leftLayout') }}
onClick={onToggleNoteLayout}
className={cn(isLeftAligned ? 'text-foreground' : 'hover:text-foreground')}
>
@@ -208,60 +278,41 @@ function NoteLayoutAction({
)
}
function FavoriteAction({ favorite, onToggleFavorite }: { favorite: boolean; onToggleFavorite?: () => void }) {
return (
<ToggleIconAction
active={favorite}
activeClassName="text-[var(--accent-yellow)]"
activeLabel="Remove from favorites"
inactiveLabel="Add to favorites"
onClick={onToggleFavorite}
shortcut={formatShortcutDisplay({ display: '⌘D' })}
>
<Star size={16} weight={favorite ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
</ToggleIconAction>
)
function FavoriteAction({ favorite, locale = 'en', onToggleFavorite }: { favorite: boolean; locale?: AppLocale; onToggleFavorite?: () => void }) {
return <ConfiguredToggleAction active={favorite} config={TOGGLE_ACTION_CONFIGS.favorite} locale={locale} onClick={onToggleFavorite} />
}
function OrganizedAction({
organized,
locale = 'en',
onToggleOrganized,
}: {
organized: boolean
locale?: AppLocale
onToggleOrganized?: () => void
}) {
if (!onToggleOrganized) return null
return (
<ToggleIconAction
active={organized}
activeClassName="text-[var(--accent-green)]"
activeLabel="Set note as not organized"
inactiveLabel="Set note as organized"
onClick={onToggleOrganized}
shortcut={formatShortcutDisplay({ display: '⌘E' })}
>
<CheckCircle size={16} weight={organized ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
</ToggleIconAction>
)
return <ConfiguredToggleAction active={organized} config={TOGGLE_ACTION_CONFIGS.organized} locale={locale} onClick={onToggleOrganized} />
}
function DiffAction({
showDiffToggle,
diffMode,
diffLoading,
locale = 'en',
onToggleDiff,
}: Pick<BreadcrumbBarProps, 'showDiffToggle' | 'diffMode' | 'diffLoading' | 'onToggleDiff'>) {
}: Pick<BreadcrumbBarProps, 'showDiffToggle' | 'diffMode' | 'diffLoading' | 'locale' | 'onToggleDiff'>) {
if (!showDiffToggle) {
return (
<IconActionButton copy={{ label: 'No diff is available yet' }} style={DISABLED_ICON_STYLE}>
<IconActionButton copy={{ label: translate(locale, 'editor.toolbar.noDiff') }} style={DISABLED_ICON_STYLE}>
<GitBranch size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
}
const copy: ActionTooltipCopy = diffLoading
? { label: 'Loading the diff' }
: { label: diffMode ? 'Return to the editor' : 'Show the current diff' }
? { label: translate(locale, 'editor.toolbar.loadingDiff') }
: { label: translate(locale, diffMode ? 'editor.toolbar.rawReturn' : 'editor.toolbar.showDiff') }
return (
<IconActionButton
copy={copy}
@@ -273,13 +324,13 @@ function DiffAction({
)
}
function AIChatAction({ showAIChat, onToggleAIChat }: Pick<BreadcrumbBarProps, 'showAIChat' | 'onToggleAIChat'>) {
function AIChatAction({ showAIChat, locale = 'en', onToggleAIChat }: Pick<BreadcrumbBarProps, 'showAIChat' | 'locale' | 'onToggleAIChat'>) {
return (
<ToggleIconAction
active={!!showAIChat}
activeClassName="text-primary"
activeLabel="Close the AI panel"
inactiveLabel="Open the AI panel"
activeLabel={translate(locale, 'editor.toolbar.closeAi')}
inactiveLabel={translate(locale, 'editor.toolbar.openAi')}
onClick={onToggleAIChat}
shortcut={formatShortcutDisplay({ display: '⌘⇧L' })}
>
@@ -290,29 +341,30 @@ function AIChatAction({ showAIChat, onToggleAIChat }: Pick<BreadcrumbBarProps, '
function ArchiveAction({
archived,
locale = 'en',
onArchive,
onUnarchive,
}: Pick<VaultEntry, 'archived'> & Pick<BreadcrumbBarProps, 'onArchive' | 'onUnarchive'>) {
}: Pick<VaultEntry, 'archived'> & Pick<BreadcrumbBarProps, 'locale' | 'onArchive' | 'onUnarchive'>) {
if (archived) {
return (
<IconActionButton copy={{ label: 'Restore this archived note' }} onClick={onUnarchive} className="hover:text-foreground">
<IconActionButton copy={{ label: translate(locale, 'editor.toolbar.restoreArchived') }} onClick={onUnarchive} className="hover:text-foreground">
<ArrowUUpLeft size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
}
return (
<IconActionButton copy={{ label: 'Archive this note' }} onClick={onArchive} className="hover:text-foreground">
<IconActionButton copy={{ label: translate(locale, 'editor.toolbar.archive') }} onClick={onArchive} className="hover:text-foreground">
<Archive size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
}
function DeleteAction({ onDelete }: Pick<BreadcrumbBarProps, 'onDelete'>) {
function DeleteAction({ locale = 'en', onDelete }: Pick<BreadcrumbBarProps, 'locale' | 'onDelete'>) {
return (
<IconActionButton
copy={{
label: 'Delete this note',
label: translate(locale, 'editor.toolbar.delete'),
shortcut: formatShortcutDisplay({ display: '⌘⌫ / ⌘⌦' }),
}}
onClick={onDelete}
@@ -325,13 +377,14 @@ function DeleteAction({ onDelete }: Pick<BreadcrumbBarProps, 'onDelete'>) {
function InspectorAction({
inspectorCollapsed,
locale = 'en',
onToggleInspector,
}: Pick<BreadcrumbBarProps, 'inspectorCollapsed' | 'onToggleInspector'>) {
}: Pick<BreadcrumbBarProps, 'inspectorCollapsed' | 'locale' | 'onToggleInspector'>) {
if (!inspectorCollapsed) return null
return (
<IconActionButton
copy={{
label: 'Open the properties panel',
label: translate(locale, 'editor.toolbar.openProperties'),
shortcut: formatShortcutDisplay({ display: '⌘⇧I' }),
}}
onClick={onToggleInspector}
@@ -358,12 +411,14 @@ function deriveSyncStem(entry: VaultEntry): string | null {
function FilenameInput({
inputRef,
draftStem,
locale = 'en',
onDraftStemChange,
onBlur,
onKeyDown,
}: {
inputRef: React.RefObject<HTMLInputElement | null>
draftStem: string
locale?: AppLocale
onDraftStemChange: (nextValue: string) => void
onBlur: () => void
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void
@@ -377,7 +432,7 @@ function FilenameInput({
onKeyDown={onKeyDown}
className="h-7 w-[180px] text-sm"
data-testid="breadcrumb-filename-input"
aria-label="Rename filename"
aria-label={translate(locale, 'editor.filename.rename')}
/>
)
}
@@ -385,10 +440,12 @@ function FilenameInput({
function FilenameTrigger({
entry,
filenameStem,
locale = 'en',
onStartEditing,
}: {
entry: VaultEntry
filenameStem: string
locale?: AppLocale
onStartEditing: () => void
}) {
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLButtonElement>) => {
@@ -406,7 +463,7 @@ function FilenameTrigger({
onDoubleClick={onStartEditing}
onKeyDown={handleKeyDown}
data-testid="breadcrumb-filename-trigger"
aria-label={`Filename ${filenameStem}. Press Enter to rename`}
aria-label={translate(locale, 'editor.filename.trigger', { filename: filenameStem })}
>
<NoteTitleIcon icon={entry.icon} size={15} testId="breadcrumb-note-icon" />
<span className="truncate">{filenameStem}</span>
@@ -417,15 +474,17 @@ function FilenameTrigger({
function SyncFilenameButton({
entryPath,
syncStem,
locale = 'en',
onRenameFilename,
}: {
entryPath: string
syncStem: string | null
locale?: AppLocale
onRenameFilename?: (path: string, newFilenameStem: string) => void
}) {
if (!syncStem || !onRenameFilename) return null
return (
<ActionTooltip copy={{ label: 'Rename the file to match the title' }} side="bottom">
<ActionTooltip copy={{ label: translate(locale, 'editor.filename.renameToTitle') }} side="bottom">
<Button
type="button"
variant="ghost"
@@ -433,7 +492,7 @@ function SyncFilenameButton({
className="text-muted-foreground hover:text-foreground"
onClick={() => onRenameFilename(entryPath, syncStem)}
data-testid="breadcrumb-sync-button"
aria-label="Rename the file to match the title"
aria-label={translate(locale, 'editor.filename.renameToTitle')}
>
<ArrowsClockwise size={14} />
</Button>
@@ -445,24 +504,26 @@ function FilenameDisplay({
entry,
filenameStem,
syncStem,
locale,
onRenameFilename,
onStartEditing,
}: {
entry: VaultEntry
filenameStem: string
syncStem: string | null
locale?: AppLocale
onRenameFilename?: (path: string, newFilenameStem: string) => void
onStartEditing: () => void
}) {
return (
<div className="flex min-w-0 items-center gap-1">
<FilenameTrigger entry={entry} filenameStem={filenameStem} onStartEditing={onStartEditing} />
<SyncFilenameButton entryPath={entry.path} syncStem={syncStem} onRenameFilename={onRenameFilename} />
<FilenameTrigger entry={entry} filenameStem={filenameStem} locale={locale} onStartEditing={onStartEditing} />
<SyncFilenameButton entryPath={entry.path} syncStem={syncStem} locale={locale} onRenameFilename={onRenameFilename} />
</div>
)
}
function FilenameCrumb({ entry, onRenameFilename }: Pick<BreadcrumbBarProps, 'entry' | 'onRenameFilename'>) {
function FilenameCrumb({ entry, locale = 'en', onRenameFilename }: Pick<BreadcrumbBarProps, 'entry' | 'locale' | 'onRenameFilename'>) {
const filenameStem = useMemo(() => entry.filename.replace(/\.md$/, ''), [entry.filename])
const syncStem = useMemo(() => deriveSyncStem(entry), [entry])
const [isEditing, setIsEditing] = useState(false)
@@ -498,6 +559,7 @@ function FilenameCrumb({ entry, onRenameFilename }: Pick<BreadcrumbBarProps, 'en
<FilenameInput
inputRef={inputRef}
draftStem={draftStem}
locale={locale}
onDraftStemChange={setDraftStem}
onBlur={submitRename}
onKeyDown={handleInputKeyDown}
@@ -510,6 +572,7 @@ function FilenameCrumb({ entry, onRenameFilename }: Pick<BreadcrumbBarProps, 'en
entry={entry}
filenameStem={filenameStem}
syncStem={syncStem}
locale={locale}
onRenameFilename={onRenameFilename}
onStartEditing={startEditing}
/>
@@ -536,38 +599,41 @@ function BreadcrumbActions({
onDelete,
onArchive,
onUnarchive,
locale = 'en',
}: Omit<BreadcrumbBarProps, 'wordCount' | 'barRef' | 'onRenameFilename'>) {
return (
<div className="breadcrumb-bar__actions ml-auto flex items-center" style={{ gap: 12 }}>
<FavoriteAction favorite={entry.favorite} onToggleFavorite={onToggleFavorite} />
<OrganizedAction organized={entry.organized} onToggleOrganized={onToggleOrganized} />
<FavoriteAction favorite={entry.favorite} locale={locale} onToggleFavorite={onToggleFavorite} />
<OrganizedAction organized={entry.organized} locale={locale} onToggleOrganized={onToggleOrganized} />
<DiffAction
showDiffToggle={showDiffToggle}
diffMode={diffMode}
diffLoading={diffLoading}
onToggleDiff={onToggleDiff}
locale={locale}
/>
{!forceRawMode && <RawToggleButton rawMode={rawMode} onToggleRaw={onToggleRaw} />}
<NoteLayoutAction noteLayout={noteLayout} onToggleNoteLayout={onToggleNoteLayout} />
<AIChatAction showAIChat={showAIChat} onToggleAIChat={onToggleAIChat} />
<ArchiveAction archived={entry.archived} onArchive={onArchive} onUnarchive={onUnarchive} />
<DeleteAction onDelete={onDelete} />
<InspectorAction inspectorCollapsed={inspectorCollapsed} onToggleInspector={onToggleInspector} />
{!forceRawMode && <RawToggleButton rawMode={rawMode} locale={locale} onToggleRaw={onToggleRaw} />}
<NoteLayoutAction noteLayout={noteLayout} locale={locale} onToggleNoteLayout={onToggleNoteLayout} />
<AIChatAction showAIChat={showAIChat} locale={locale} onToggleAIChat={onToggleAIChat} />
<ArchiveAction archived={entry.archived} locale={locale} onArchive={onArchive} onUnarchive={onUnarchive} />
<DeleteAction locale={locale} onDelete={onDelete} />
<InspectorAction inspectorCollapsed={inspectorCollapsed} locale={locale} onToggleInspector={onToggleInspector} />
</div>
)
}
function BreadcrumbTitle({
entry,
locale,
onRenameFilename,
}: Pick<BreadcrumbBarProps, 'entry' | 'onRenameFilename'>) {
}: Pick<BreadcrumbBarProps, 'entry' | 'locale' | 'onRenameFilename'>) {
const typeLabel = entry.isA ?? 'Note'
return (
<div className="flex items-center gap-1.5 min-w-0 text-sm text-muted-foreground">
<span className="shrink-0">{typeLabel}</span>
<span className="shrink-0 text-border"></span>
<div className="flex min-w-0 items-center gap-1 truncate">
<FilenameCrumb entry={entry} onRenameFilename={onRenameFilename} />
<FilenameCrumb entry={entry} locale={locale} onRenameFilename={onRenameFilename} />
</div>
</div>
)
@@ -576,6 +642,7 @@ function BreadcrumbTitle({
export const BreadcrumbBar = memo(function BreadcrumbBar({
entry,
barRef,
locale = 'en',
onRenameFilename,
...actionProps
}: BreadcrumbBarProps) {
@@ -597,14 +664,14 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
}}
>
<div className="breadcrumb-bar__title min-w-0">
<BreadcrumbTitle entry={entry} onRenameFilename={onRenameFilename} />
<BreadcrumbTitle entry={entry} locale={locale} onRenameFilename={onRenameFilename} />
</div>
<div
aria-hidden="true"
data-tauri-drag-region
className="breadcrumb-bar__drag-spacer min-w-0 flex-1"
/>
<BreadcrumbActions entry={entry} {...actionProps} />
<BreadcrumbActions entry={entry} locale={locale} {...actionProps} />
</div>
</TooltipProvider>
)

View File

@@ -6,6 +6,7 @@ import { queueAiPrompt, requestOpenAiChat } from '../utils/aiPromptBridge'
import type { NoteReference } from '../utils/ai-context'
import type { CommandAction, CommandGroup } from '../hooks/useCommandRegistry'
import { groupSortKey } from '../hooks/useCommandRegistry'
import { localizeCommandGroup } from '../hooks/commands/localizeCommands'
import { rememberFeedbackDialogOpener } from '../lib/feedbackDialogOpener'
import { createTranslator, type AppLocale } from '../lib/i18n'
import { formatDroppedPathList } from './inlineWikilinkDropText'
@@ -181,6 +182,7 @@ function CommandPaletteResults({
selectedIndex,
listRef,
emptyText,
locale,
onHover,
onSelect,
}: {
@@ -188,6 +190,7 @@ function CommandPaletteResults({
selectedIndex: number
listRef: React.RefObject<HTMLDivElement | null>
emptyText: string
locale: AppLocale
onHover: (index: number) => void
onSelect: (command: CommandAction) => void
}) {
@@ -221,7 +224,7 @@ function CommandPaletteResults({
return (
<div key={group}>
<div className="px-4 pb-1 pt-2 text-[11px] font-medium text-muted-foreground">
{group}
{localizeCommandGroup(group, locale)}
</div>
{items.map((command, index) => {
const globalIndex = startIndex + index
@@ -430,6 +433,7 @@ function OpenCommandPalette({
selectedIndex={selectedIndex}
listRef={listRef}
emptyText={t('command.noMatches')}
locale={locale}
onHover={setSelectedIndex}
onSelect={handleSelectCommand}
/>

View File

@@ -1,11 +1,13 @@
import { AlertTriangle } from 'lucide-react'
import { translate, type AppLocale } from '../lib/i18n'
interface ConflictNoteBannerProps {
onKeepMine: () => void
onKeepTheirs: () => void
locale?: AppLocale
}
export function ConflictNoteBanner({ onKeepMine, onKeepTheirs }: ConflictNoteBannerProps) {
export function ConflictNoteBanner({ onKeepMine, onKeepTheirs, locale = 'en' }: ConflictNoteBannerProps) {
return (
<div
data-testid="conflict-note-banner"
@@ -22,7 +24,7 @@ export function ConflictNoteBanner({ onKeepMine, onKeepTheirs }: ConflictNoteBan
}}
>
<AlertTriangle size={13} />
<span>This note has a merge conflict</span>
<span>{translate(locale, 'editor.banner.conflict')}</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
<button
data-testid="conflict-keep-mine-btn"
@@ -39,9 +41,9 @@ export function ConflictNoteBanner({ onKeepMine, onKeepTheirs }: ConflictNoteBan
color: 'var(--foreground)',
cursor: 'pointer',
}}
title="Keep my local version"
title={translate(locale, 'editor.banner.keepMineTooltip')}
>
Keep mine
{translate(locale, 'editor.banner.keepMine')}
</button>
<button
data-testid="conflict-keep-theirs-btn"
@@ -58,9 +60,9 @@ export function ConflictNoteBanner({ onKeepMine, onKeepTheirs }: ConflictNoteBan
color: 'var(--foreground)',
cursor: 'pointer',
}}
title="Keep the remote version"
title={translate(locale, 'editor.banner.keepTheirsTooltip')}
>
Keep theirs
{translate(locale, 'editor.banner.keepTheirs')}
</button>
</div>
</div>

View File

@@ -205,6 +205,19 @@ describe('DynamicPropertiesPanel', () => {
expect(screen.getByText('some-team')).toBeInTheDocument()
})
it('localizes UI actions without translating stored property names', () => {
renderPanel({
frontmatter: { Status: 'Active', 'Belongs to': 'some-team' },
onAddProperty,
locale: 'zh-Hans',
})
expect(screen.getByText('Type')).toBeInTheDocument()
expect(screen.getByText('Status')).toBeInTheDocument()
expect(screen.getByText('Belongs to')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '添加属性' })).toBeInTheDocument()
})
it('hides custom field with wikilink value from Properties', () => {
renderPanel({ frontmatter: { Mentor: '[[person/luca]]' } })
// Mentor contains a wikilink → shown in Relationships, not Properties

View File

@@ -21,6 +21,7 @@ import {
PROPERTY_PANEL_ROW_STYLE,
} from './propertyPanelLayout'
import { humanizePropertyKey } from '../utils/propertyLabels'
import { translate, type AppLocale } from '../lib/i18n'
import { canonicalSystemMetadataKey, hasSystemMetadataKey } from '../utils/systemMetadata'
// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component
@@ -32,7 +33,7 @@ export function containsWikilinks(value: FrontmatterValue): boolean {
const PROPERTY_ROW_CLASS_NAME = 'group/prop grid min-h-7 min-w-0 grid-cols-2 items-center gap-2 rounded px-1.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary'
function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultStatuses, vaultTags, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: {
function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultStatuses, vaultTags, locale, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: {
propKey: string; value: FrontmatterValue; editingKey: string | null
displayMode: PropertyDisplayMode; autoMode: PropertyDisplayMode
vaultStatuses: string[]; vaultTags: string[]
@@ -40,6 +41,7 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
onSaveList: (key: string, items: string[]) => void
onUpdate?: (key: string, value: FrontmatterValue) => void; onDelete?: (key: string) => void
onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void
locale: AppLocale
}) {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.target !== e.currentTarget) {
@@ -57,7 +59,7 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
<DisplayModeSelector propKey={propKey} currentMode={displayMode} autoMode={autoMode} onSelect={onDisplayModeChange} />
<span className="min-w-0 flex-1 truncate">{humanizePropertyKey(propKey)}</span>
{onDelete && (
<button className="border-none bg-transparent p-0 text-sm leading-none text-muted-foreground opacity-0 transition-all hover:text-destructive group-hover/prop:opacity-100" onClick={() => onDelete(propKey)} title="Delete property">&times;</button>
<button className="border-none bg-transparent p-0 text-sm leading-none text-muted-foreground opacity-0 transition-all hover:text-destructive group-hover/prop:opacity-100" onClick={() => onDelete(propKey)} title={translate(locale, 'inspector.properties.deleteProperty')}>&times;</button>
)}
</span>
<div className="min-w-0">
@@ -67,7 +69,7 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
)
}
function AddPropertyButton({ onClick, disabled }: { onClick: () => void; disabled: boolean }) {
function AddPropertyButton({ locale, onClick, disabled }: { locale: AppLocale; onClick: () => void; disabled: boolean }) {
return (
<Button
type="button"
@@ -86,7 +88,7 @@ function AddPropertyButton({ onClick, disabled }: { onClick: () => void; disable
>
<Plus className="size-3.5" aria-hidden="true" />
</span>
<span className="min-w-0 truncate">Add property</span>
<span className="min-w-0 truncate">{translate(locale, 'inspector.properties.addProperty')}</span>
</span>
<span aria-hidden="true" className={PROPERTY_PANEL_PLACEHOLDER_VALUE_CLASS_NAME} />
</Button>
@@ -230,6 +232,7 @@ function useSuggestedPropertyActions({
export function DynamicPropertiesPanel({
entry, frontmatter, entries,
onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate, onCreateMissingType,
locale = 'en',
}: {
entry: VaultEntry
content?: string | null
@@ -240,6 +243,7 @@ export function DynamicPropertiesPanel({
onAddProperty?: (key: string, value: FrontmatterValue) => void
onNavigate?: (target: string) => void
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
locale?: AppLocale
}) {
const {
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
@@ -279,6 +283,7 @@ export function DynamicPropertiesPanel({
onNavigate={onNavigate}
missingTypeName={missingTypeName}
onCreateMissingType={onCreateMissingType}
locale={locale}
/>
{propertyEntries.map(([key, value]) => (
<PropertyRow
@@ -290,6 +295,7 @@ export function DynamicPropertiesPanel({
onSaveList={handleSaveList} onUpdate={onUpdateProperty}
onDelete={onDeleteProperty}
onDisplayModeChange={handleDisplayModeChange}
locale={locale}
/>
))}
{pendingSuggestedKey && editingKey === pendingSuggestedKey && (
@@ -308,6 +314,7 @@ export function DynamicPropertiesPanel({
onUpdate={undefined}
onDelete={undefined}
onDisplayModeChange={handleDisplayModeChange}
locale={locale}
/>
)}
{missingSuggested.map(({ key, label }) => (
@@ -320,6 +327,7 @@ export function DynamicPropertiesPanel({
))}
{!showAddDialog && (
<AddPropertyButton
locale={locale}
onClick={() => setShowAddDialog(true)}
disabled={!onAddProperty}
/>
@@ -330,6 +338,7 @@ export function DynamicPropertiesPanel({
onAdd={handleAdd}
onCancel={() => setShowAddDialog(false)}
vaultStatuses={vaultStatuses}
locale={locale}
/>
)}
</div>

View File

@@ -210,14 +210,8 @@
}
.raw-editor-codemirror {
max-width: var(--editor-max-width, 760px);
width: 100%;
margin: 0 auto;
}
.editor-content-layout--left .raw-editor-codemirror {
margin-left: clamp(16px, 6%, 96px);
margin-right: auto;
margin: 0;
}
/* --- Note Icon Area --- */
@@ -340,8 +334,7 @@
}
@container editor (max-width: 900px) {
.editor-content-layout--left .editor-content-wrapper,
.editor-content-layout--left .raw-editor-codemirror {
.editor-content-layout--left .editor-content-wrapper {
margin-left: auto;
margin-right: auto;
}

View File

@@ -5,6 +5,7 @@ import '@blocknote/mantine/style.css'
import 'katex/dist/katex.min.css'
import { uploadImageFile } from '../hooks/useImageDrop'
import { DEFAULT_AI_AGENT, type AiAgentId } from '../lib/aiAgents'
import { translate, type AppLocale } from '../lib/i18n'
import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce'
import type { VaultEntry, GitCommit, NoteLayout, NoteStatus } from '../types'
import type { NoteListItem } from '../utils/ai-context'
@@ -96,6 +97,7 @@ interface EditorProps {
onKeepTheirs?: (path: string) => void
/** Registers a hook that flushes the raw editor buffer into app state before external actions. */
flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null>
locale?: AppLocale
}
function useEditorModeExclusion({
@@ -129,7 +131,7 @@ function useEditorModeExclusion({
return { handleToggleDiffExclusive, handleToggleRawExclusive }
}
function EditorEmptyState() {
function EditorEmptyState({ locale = 'en' }: { locale?: AppLocale }) {
const breadcrumbBarHeight = 52
const { onMouseDown } = useDragRegion()
const quickOpenShortcut = formatShortcutDisplay({ display: '⌘P / ⌘O' })
@@ -146,8 +148,8 @@ function EditorEmptyState() {
style={{ height: breadcrumbBarHeight }}
/>
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center text-muted-foreground">
<p className="m-0 text-[15px]">Select a note to start editing</p>
<span className="text-xs text-muted-foreground">{quickOpenShortcut} to search &middot; {newNoteShortcut} to create</span>
<p className="m-0 text-[15px]">{translate(locale, 'editor.empty.selectNote')}</p>
<span className="text-xs text-muted-foreground">{translate(locale, 'editor.empty.shortcuts', { quickOpen: quickOpenShortcut, newNote: newNoteShortcut })}</span>
</div>
</div>
)
@@ -328,6 +330,7 @@ function EditorLayout({
onFileModified,
onVaultChanged,
onUnsupportedAiPaste,
locale,
}: {
tabs: Tab[]
activeTab: Tab | null
@@ -384,12 +387,13 @@ function EditorLayout({
onFileModified?: (relativePath: string) => void
onVaultChanged?: () => void
onUnsupportedAiPaste?: (message: string) => void
locale?: AppLocale
}) {
return (
<div className="editor flex flex-col min-h-0 overflow-hidden bg-background text-foreground">
<div className="flex flex-1 min-h-0">
{tabs.length === 0
? <EditorEmptyState />
? <EditorEmptyState locale={locale} />
: <EditorContent
activeTab={activeTab}
isLoadingNewTab={isLoadingNewTab}
@@ -425,6 +429,7 @@ function EditorLayout({
isConflicted={isConflicted}
onKeepMine={onKeepMine}
onKeepTheirs={onKeepTheirs}
locale={locale}
/>
}
{(showAIChat || !inspectorCollapsed) && <ResizeHandle onResize={onInspectorResize} />}
@@ -457,6 +462,7 @@ function EditorLayout({
onFileCreated={onFileCreated}
onFileModified={onFileModified}
onVaultChanged={onVaultChanged}
locale={locale}
/>
</div>
</div>
@@ -481,6 +487,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
onFileCreated, onFileModified, onVaultChanged,
isConflicted, onKeepMine, onKeepTheirs,
flushPendingRawContentRef,
locale,
} = props
const {
@@ -563,6 +570,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
onFileCreated={onFileCreated}
onFileModified={onFileModified}
onVaultChanged={onVaultChanged}
locale={locale}
/>
)
})

View File

@@ -1,5 +1,6 @@
import { useEffect } from 'react'
import { DEFAULT_AI_AGENT, type AiAgentId } from '../lib/aiAgents'
import type { AppLocale } from '../lib/i18n'
import type { VaultEntry, GitCommit } from '../types'
import type { NoteListItem } from '../utils/ai-context'
import { Inspector, type FrontmatterValue } from './Inspector'
@@ -36,6 +37,7 @@ interface EditorRightPanelProps {
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
onVaultChanged?: () => void
locale?: AppLocale
}
export function EditorRightPanel({
@@ -47,6 +49,7 @@ export function EditorRightPanel({
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties, onToggleRawEditor, onOpenNote,
onFileCreated, onFileModified, onVaultChanged,
locale,
}: EditorRightPanelProps) {
const aiPanelController = useAiPanelController({
vaultPath,
@@ -117,6 +120,7 @@ export function EditorRightPanel({
onCreateAndOpenNote={onCreateAndOpenNote}
onInitializeProperties={onInitializeProperties}
onToggleRawEditor={onToggleRawEditor}
locale={locale}
/>
</div>
)

View File

@@ -13,6 +13,7 @@ import { FolderTreeRow } from './folder-tree/FolderTreeRow'
import { useFolderContextMenu } from './folder-tree/useFolderContextMenu'
import { useFolderTreeDisclosure } from './folder-tree/useFolderTreeDisclosure'
import { SidebarGroupHeader } from './sidebar/SidebarGroupHeader'
import { translate, type AppLocale } from '../lib/i18n'
interface FolderTreeProps {
folders: FolderNode[]
@@ -25,6 +26,7 @@ interface FolderTreeProps {
onStartRenameFolder?: (folderPath: string) => void
onCancelRenameFolder?: () => void
collapsed?: boolean
locale?: AppLocale
onToggle?: () => void
}
@@ -39,6 +41,7 @@ export const FolderTree = memo(function FolderTree({
onStartRenameFolder,
onCancelRenameFolder,
collapsed: externalCollapsed,
locale = 'en',
onToggle,
}: FolderTreeProps) {
const {
@@ -83,7 +86,7 @@ export const FolderTree = memo(function FolderTree({
return (
<div className="border-b border-border" style={{ padding: '0 6px' }}>
<SidebarGroupHeader label="FOLDERS" collapsed={sectionCollapsed} onToggle={handleToggleSection}>
<SidebarGroupHeader label={translate(locale, 'sidebar.group.folders')} collapsed={sectionCollapsed} onToggle={handleToggleSection}>
{onCreateFolder && (
<Button
type="button"
@@ -91,8 +94,8 @@ export const FolderTree = memo(function FolderTree({
size="icon-xs"
className="h-auto w-auto min-w-0 rounded-none p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
data-testid="create-folder-btn"
title="Create folder"
aria-label="Create folder"
title={translate(locale, 'sidebar.action.createFolder')}
aria-label={translate(locale, 'sidebar.action.createFolder')}
onClick={(event) => {
event.stopPropagation()
closeContextMenu()
@@ -118,6 +121,7 @@ export const FolderTree = memo(function FolderTree({
onStartRenameFolder={onStartRenameFolder}
onToggle={toggleFolder}
onCancelRenameFolder={onCancelRenameFolder}
locale={locale}
renamingFolderPath={renamingFolderPath}
selection={selection}
/>
@@ -125,9 +129,9 @@ export const FolderTree = memo(function FolderTree({
{isCreating && (
<div style={{ paddingLeft: 8 }}>
<FolderNameInput
ariaLabel="New folder name"
ariaLabel={translate(locale, 'sidebar.folder.newName')}
initialValue=""
placeholder="Folder name"
placeholder={translate(locale, 'sidebar.folder.name')}
submitOnBlur={true}
testId="new-folder-input"
onCancel={closeCreateForm}
@@ -142,6 +146,7 @@ export const FolderTree = memo(function FolderTree({
menuRef={menuRef}
onDelete={handleDeleteFromMenu}
onRename={handleRenameFromMenu}
locale={locale}
/>
</div>
)

View File

@@ -16,6 +16,7 @@ import type { ReferencedByItem } from './InspectorPanels'
import { EmptyInspector, InitializePropertiesPrompt, InspectorHeader, InvalidFrontmatterNotice } from './inspector/InspectorChrome'
import { useBacklinks, useReferencedBy } from './inspector/useInspectorData'
import { useInspectorPropertyActions } from './inspector/useInspectorPropertyActions'
import type { AppLocale } from '../lib/i18n'
export type FrontmatterValue = string | number | boolean | string[] | null
@@ -36,6 +37,7 @@ interface InspectorProps {
onCreateAndOpenNote?: (title: string) => Promise<boolean>
onInitializeProperties?: (path: string) => void
onToggleRawEditor?: () => void
locale?: AppLocale
}
function buildTypeEntryMap(entries: VaultEntry[]): Record<string, VaultEntry> {
@@ -59,6 +61,7 @@ function ValidFrontmatterPanels({
onDeleteProperty,
onAddProperty,
onCreateMissingType,
locale,
}: {
entry: VaultEntry
entries: VaultEntry[]
@@ -72,6 +75,7 @@ function ValidFrontmatterPanels({
onDeleteProperty?: (key: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
onCreateMissingType?: (typeName: string) => Promise<boolean | void>
locale: AppLocale
}) {
return (
<>
@@ -84,6 +88,7 @@ function ValidFrontmatterPanels({
onAddProperty={onAddProperty}
onNavigate={onNavigate}
onCreateMissingType={onCreateMissingType}
locale={locale}
/>
<Separator data-testid="inspector-properties-relationships-separator" />
<DynamicRelationshipsPanel
@@ -96,6 +101,7 @@ function ValidFrontmatterPanels({
onUpdateProperty={onUpdateProperty}
onDeleteProperty={onDeleteProperty}
onCreateAndOpenNote={onCreateAndOpenNote}
locale={locale}
/>
<InstancesPanel entry={entry} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
<ReferencedByPanel items={referencedBy} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
@@ -119,6 +125,7 @@ function PrimaryInspectorPanel({
onDeleteProperty,
onAddProperty,
onCreateMissingType,
locale,
}: {
entry: VaultEntry
frontmatterState: ReturnType<typeof detectFrontmatterState>
@@ -135,6 +142,7 @@ function PrimaryInspectorPanel({
onDeleteProperty?: (key: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
onCreateMissingType?: (typeName: string) => Promise<boolean | void>
locale: AppLocale
}) {
if (frontmatterState === 'valid') {
return (
@@ -151,15 +159,16 @@ function PrimaryInspectorPanel({
onDeleteProperty={onDeleteProperty}
onAddProperty={onAddProperty}
onCreateMissingType={onCreateMissingType}
locale={locale}
/>
)
}
if (frontmatterState === 'invalid') {
return onToggleRawEditor ? <InvalidFrontmatterNotice onFix={onToggleRawEditor} /> : null
return onToggleRawEditor ? <InvalidFrontmatterNotice locale={locale} onFix={onToggleRawEditor} /> : null
}
return onInitializeProperties ? <InitializePropertiesPrompt onClick={() => onInitializeProperties(entry.path)} /> : null
return onInitializeProperties ? <InitializePropertiesPrompt locale={locale} onClick={() => onInitializeProperties(entry.path)} /> : null
}
function InspectorBody({
@@ -177,6 +186,7 @@ function InspectorBody({
onCreateAndOpenNote,
onInitializeProperties,
onToggleRawEditor,
locale = 'en',
}: Omit<InspectorProps, 'collapsed' | 'onToggle'>) {
const referencedBy = useReferencedBy(entry, entries)
const backlinks = useBacklinks(entry, entries, referencedBy)
@@ -197,7 +207,7 @@ function InspectorBody({
})
if (!entry) {
return <EmptyInspector />
return <EmptyInspector locale={locale} />
}
return (
@@ -218,11 +228,12 @@ function InspectorBody({
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
onAddProperty={onAddProperty ? handleAddProperty : undefined}
onCreateMissingType={onCreateMissingType ? handleCreateMissingType : undefined}
locale={locale}
/>
{backlinks.length > 0 && <Separator />}
<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />
<Separator />
<NoteInfoPanel entry={entry} content={content} />
<NoteInfoPanel entry={entry} content={content} locale={locale} />
{gitHistory.length > 0 && <Separator />}
<GitHistoryPanel commits={gitHistory} onViewCommitDiff={onViewCommitDiff} />
</>
@@ -232,7 +243,7 @@ function InspectorBody({
export function Inspector({ collapsed, onToggle, ...bodyProps }: InspectorProps) {
return (
<aside className={cn('flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground transition-[width] duration-200', collapsed && '!w-10 !min-w-10')}>
<InspectorHeader collapsed={collapsed} onToggle={onToggle} />
<InspectorHeader collapsed={collapsed} locale={bodyProps.locale} onToggle={onToggle} />
{!collapsed && (
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-3">
<InspectorBody {...bodyProps} />

View File

@@ -5,6 +5,7 @@ import { isTauri, mockInvoke } from '../mock-tauri'
import { useDragRegion } from '../hooks/useDragRegion'
import type { PulseCommit, PulseFile } from '../types'
import { relativeDate } from '../utils/noteListHelpers'
import { translate, type AppLocale } from '../lib/i18n'
import {
Plus, Minus, PencilSimple, GitCommit, ArrowSquareOut,
FileText, CaretDown, CaretRight, Pulse,
@@ -19,13 +20,20 @@ interface PulseViewProps {
onOpenNote?: (relativePath: string, commitHash?: string) => void
sidebarCollapsed?: boolean
onExpandSidebar?: () => void
locale?: AppLocale
}
function formatDateKey(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function groupCommitsByDay(commits: PulseCommit[]): Map<string, PulseCommit[]> {
const groups = new Map<string, PulseCommit[]>()
for (const commit of commits) {
const date = new Date(commit.date * 1000)
const key = date.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
const key = formatDateKey(new Date(commit.date * 1000))
const existing = groups.get(key)
if (existing) {
existing.push(commit)
@@ -37,20 +45,20 @@ function groupCommitsByDay(commits: PulseCommit[]): Map<string, PulseCommit[]> {
}
function isToday(dateKey: string): boolean {
const today = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
return dateKey === today
return dateKey === formatDateKey(new Date())
}
function isYesterday(dateKey: string): boolean {
const yesterday = new Date(Date.now() - 86400000)
.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
return dateKey === yesterday
return dateKey === formatDateKey(new Date(Date.now() - 86400000))
}
function formatDayLabel(dateKey: string): string {
if (isToday(dateKey)) return 'Today'
if (isYesterday(dateKey)) return 'Yesterday'
return dateKey
function formatDayLabel(dateKey: string, locale: AppLocale): string {
if (isToday(dateKey)) return translate(locale, 'pulse.today')
if (isYesterday(dateKey)) return translate(locale, 'pulse.yesterday')
const date = new Date(`${dateKey}T00:00:00`)
const dateLocale = locale === 'zh-Hans' ? 'zh-CN' : 'en-US'
return date.toLocaleDateString(dateLocale, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
}
const STATUS_ICON = {
@@ -124,9 +132,11 @@ function FileItem({
function CommitCard({
commit,
locale,
onOpenNote,
}: {
commit: PulseCommit
locale: AppLocale
onOpenNote?: (path: string, commitHash?: string) => void
}) {
const [expanded, setExpanded] = useState(false)
@@ -173,7 +183,7 @@ function CommitCard({
window.open(commit.githubUrl!, '_blank')
}
}}
title="Open on GitHub"
title={translate(locale, 'pulse.openOnGitHub')}
>
{commit.shortHash}
<ArrowSquareOut size={10} />
@@ -192,7 +202,7 @@ function CommitCard({
event.stopPropagation()
toggleExpanded()
}}
aria-label={expanded ? 'Collapse files' : 'Expand files'}
aria-label={translate(locale, expanded ? 'pulse.collapseFiles' : 'pulse.expandFiles')}
>
<Chevron size={12} />
</button>
@@ -208,9 +218,10 @@ function CommitCard({
)
}
function DayGroup({ label, commits, onOpenNote }: {
function DayGroup({ label, commits, locale, onOpenNote }: {
label: string
commits: PulseCommit[]
locale: AppLocale
onOpenNote?: (path: string, commitHash?: string) => void
}) {
const [collapsed, setCollapsed] = useState(false)
@@ -236,11 +247,14 @@ function DayGroup({ label, commits, onOpenNote }: {
{label}
</span>
<span className="text-[11px] text-muted-foreground">
({commits.length} {commits.length === 1 ? 'commit' : 'commits'})
({translate(locale, 'pulse.commitCount', {
count: commits.length,
label: translate(locale, commits.length === 1 ? 'pulse.commitSingular' : 'pulse.commitPlural'),
})})
</span>
</div>
{!collapsed && commits.map((commit) => (
<CommitCard key={commit.hash} commit={commit} onOpenNote={onOpenNote} />
<CommitCard key={commit.hash} commit={commit} locale={locale} onOpenNote={onOpenNote} />
))}
</div>
)
@@ -249,7 +263,8 @@ function DayGroup({ label, commits, onOpenNote }: {
function PulseHeader({
sidebarCollapsed,
onExpandSidebar,
}: Pick<PulseViewProps, 'sidebarCollapsed' | 'onExpandSidebar'>) {
locale = 'en',
}: Pick<PulseViewProps, 'sidebarCollapsed' | 'onExpandSidebar' | 'locale'>) {
const { onMouseDown } = useDragRegion()
return (
@@ -266,31 +281,31 @@ function PulseHeader({
className="flex shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
style={{ width: 24, height: 24 }}
onClick={onExpandSidebar}
aria-label="Expand sidebar"
aria-label={translate(locale, 'sidebar.action.expand')}
>
<CaretRight size={14} weight="bold" />
</button>
)}
<Pulse size={16} className="text-primary" />
<span className="text-[14px] font-semibold text-foreground">History</span>
<span className="text-[14px] font-semibold text-foreground">{translate(locale, 'pulse.title')}</span>
</div>
</div>
)
}
function EmptyState() {
function EmptyState({ locale = 'en' }: { locale?: AppLocale }) {
return (
<div className="flex flex-1 flex-col items-center justify-center text-muted-foreground" style={{ padding: 32 }}>
<Pulse size={32} style={{ marginBottom: 8, opacity: 0.5 }} />
<p className="text-[13px]">No activity yet</p>
<p className="text-[13px]">{translate(locale, 'pulse.noActivity')}</p>
<p className="text-[12px]" style={{ marginTop: 4 }}>
Commit changes to see your vault's history
{translate(locale, 'pulse.emptyDescription')}
</p>
</div>
)
}
function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
function ErrorState({ message, locale = 'en', onRetry }: { message: string; locale?: AppLocale; onRetry: () => void }) {
return (
<div className="flex flex-1 flex-col items-center justify-center text-muted-foreground" style={{ padding: 32 }}>
<p className="text-[13px]">{message}</p>
@@ -298,7 +313,7 @@ function ErrorState({ message, onRetry }: { message: string; onRetry: () => void
className="mt-2 cursor-pointer rounded border border-border bg-transparent px-3 py-1 text-[12px] text-foreground transition-colors hover:bg-accent"
onClick={onRetry}
>
Retry
{translate(locale, 'pulse.retry')}
</button>
</div>
)
@@ -310,6 +325,7 @@ function PulseFeed({
loading,
loadingMore,
error,
locale,
onOpenNote,
onRetry,
sentinelRef,
@@ -319,6 +335,7 @@ function PulseFeed({
loading: boolean
loadingMore: boolean
error: string | null
locale: AppLocale
onOpenNote?: (path: string, commitHash?: string) => void
onRetry: () => void
sentinelRef: React.RefObject<HTMLDivElement | null>
@@ -326,17 +343,17 @@ function PulseFeed({
if (loading) {
return (
<div className="flex items-center justify-center" style={{ padding: 32 }}>
<span className="text-[13px] text-muted-foreground">Loading activity…</span>
<span className="text-[13px] text-muted-foreground">{translate(locale, 'pulse.loadingActivity')}</span>
</div>
)
}
if (error) {
return <ErrorState message={error} onRetry={onRetry} />
return <ErrorState message={error} locale={locale} onRetry={onRetry} />
}
if (commits.length === 0) {
return <EmptyState />
return <EmptyState locale={locale} />
}
return (
@@ -344,15 +361,16 @@ function PulseFeed({
{Array.from(dayGroups.entries()).map(([day, dayCommits]) => (
<DayGroup
key={day}
label={formatDayLabel(day)}
label={formatDayLabel(day, locale)}
commits={dayCommits}
locale={locale}
onOpenNote={onOpenNote}
/>
))}
<div ref={sentinelRef} style={{ height: 1 }} />
{loadingMore && (
<div className="flex items-center justify-center" style={{ padding: 12 }}>
<span className="text-[12px] text-muted-foreground">Loading…</span>
<span className="text-[12px] text-muted-foreground">{translate(locale, 'pulse.loading')}</span>
</div>
)}
</>
@@ -361,7 +379,7 @@ function PulseFeed({
const PAGE_SIZE = 20
export const PulseView = memo(function PulseView({ vaultPath, onOpenNote, sidebarCollapsed, onExpandSidebar }: PulseViewProps) {
export const PulseView = memo(function PulseView({ vaultPath, onOpenNote, sidebarCollapsed, onExpandSidebar, locale = 'en' }: PulseViewProps) {
const [commits, setCommits] = useState<PulseCommit[]>([])
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
@@ -383,12 +401,12 @@ export const PulseView = memo(function PulseView({ vaultPath, onOpenNote, sideba
setHasMore(result.length >= PAGE_SIZE)
setSkip(result.length)
} catch (err) {
const msg = typeof err === 'string' ? err : 'Failed to load activity'
const msg = typeof err === 'string' ? err : translate(locale, 'pulse.loadError')
setError(msg)
} finally {
setLoading(false)
}
}, [vaultPath])
}, [locale, vaultPath])
// Append next page
const loadMore = useCallback(async () => {
@@ -424,7 +442,7 @@ export const PulseView = memo(function PulseView({ vaultPath, onOpenNote, sideba
return (
<div className="flex h-full flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-background">
<PulseHeader sidebarCollapsed={sidebarCollapsed} onExpandSidebar={onExpandSidebar} />
<PulseHeader sidebarCollapsed={sidebarCollapsed} locale={locale} onExpandSidebar={onExpandSidebar} />
<div className="flex-1 overflow-y-auto">
<PulseFeed
@@ -433,6 +451,7 @@ export const PulseView = memo(function PulseView({ vaultPath, onOpenNote, sideba
loading={loading}
loadingMore={loadingMore}
error={error}
locale={locale}
onOpenNote={onOpenNote}
onRetry={loadInitial}
sentinelRef={sentinelRef}

View File

@@ -17,6 +17,7 @@ import {
} from '../utils/rawEditorUtils'
import { useCodeMirror } from '../hooks/useCodeMirror'
import type { VaultEntry } from '../types'
import { translate, type AppLocale } from '../lib/i18n'
export interface RawEditorViewProps {
content: string
@@ -28,6 +29,7 @@ export interface RawEditorViewProps {
/** Mutable ref updated on every keystroke with the latest doc string.
* Allows the parent to flush debounced content before unmount. */
latestContentRef?: React.MutableRefObject<string | null>
locale?: AppLocale
}
const DEBOUNCE_MS = 500
@@ -344,7 +346,7 @@ function useRawEditorWikilinkInsertion({
return { insertDroppedWikilink }
}
export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef, vaultPath }: RawEditorViewProps) {
export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef, vaultPath, locale = 'en' }: RawEditorViewProps) {
const containerRef = useRef<HTMLDivElement>(null)
const pendingChanges = useRawEditorPendingChanges({ content, latestContentRef, onContentChange, onSave, path })
const autocompleteController = useRawEditorAutocompleteController({ entries, vaultPath })
@@ -375,7 +377,7 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
ref={containerRef}
className="raw-editor-codemirror flex flex-1 min-h-0"
data-testid="raw-editor-codemirror"
aria-label="Raw editor"
aria-label={translate(locale, 'editor.raw.label')}
/>
<RawEditorAutocompleteDropdown
autocomplete={autocompleteController.autocomplete}

View File

@@ -22,6 +22,7 @@ import {
ViewsSection,
} from './sidebar/SidebarSections'
import { useSidebarTypeInteractions } from './sidebar/useSidebarTypeInteractions'
import type { AppLocale } from '../lib/i18n'
interface SidebarProps {
entries: VaultEntry[]
@@ -50,9 +51,160 @@ interface SidebarProps {
onCancelRenameFolder?: () => void
showInbox?: boolean
inboxCount?: number
locale?: AppLocale
onCollapse?: () => void
}
interface SidebarNavigationProps extends Pick<
SidebarProps,
| 'entries'
| 'selection'
| 'onSelect'
| 'onSelectFavorite'
| 'onReorderFavorites'
| 'views'
| 'onCreateView'
| 'onEditView'
| 'onDeleteView'
| 'folders'
| 'onCreateFolder'
| 'onRenameFolder'
| 'onDeleteFolder'
| 'renamingFolderPath'
| 'onStartRenameFolder'
| 'onCancelRenameFolder'
| 'showInbox'
| 'inboxCount'
| 'onCreateNewType'
| 'locale'
> {
activeCount: number
archivedCount: number
groupCollapsed: ReturnType<typeof useSidebarCollapsed>['collapsed']
toggleGroup: ReturnType<typeof useSidebarCollapsed>['toggle']
visibleSections: ReturnType<typeof useSidebarSections>['visibleSections']
allSectionGroups: ReturnType<typeof useSidebarSections>['allSectionGroups']
sectionIds: string[]
sensors: ReturnType<typeof useSensors>
handleDragEnd: (event: DragEndEvent) => void
sectionProps: SidebarSectionProps
typeInteractions: ReturnType<typeof useSidebarTypeInteractions>
isSectionVisible: (type: string) => boolean
toggleVisibility: (type: string) => void
}
function SidebarNavigation({
entries,
selection,
onSelect,
onSelectFavorite,
onReorderFavorites,
views = [],
onCreateView,
onEditView,
onDeleteView,
folders = [],
onCreateFolder,
onRenameFolder,
onDeleteFolder,
renamingFolderPath,
onStartRenameFolder,
onCancelRenameFolder,
showInbox = true,
inboxCount = 0,
locale = 'en',
onCreateNewType,
activeCount,
archivedCount,
groupCollapsed,
toggleGroup,
visibleSections,
allSectionGroups,
sectionIds,
sensors,
handleDragEnd,
sectionProps,
typeInteractions,
isSectionVisible,
toggleVisibility,
}: SidebarNavigationProps) {
const hasFavorites = entries.some((entry) => entry.favorite && !entry.archived)
const hasViews = views.length > 0 || !!onCreateView
return (
<nav className="flex-1 overflow-y-auto">
<SidebarTopNav
selection={selection}
onSelect={onSelect}
showInbox={showInbox}
inboxCount={inboxCount}
activeCount={activeCount}
archivedCount={archivedCount}
locale={locale}
/>
{hasFavorites && (
<div className="border-b border-border">
<FavoritesSection
entries={entries}
selection={selection}
onSelect={onSelect}
onSelectNote={onSelectFavorite}
onReorder={onReorderFavorites}
collapsed={groupCollapsed.favorites}
locale={locale}
onToggle={() => toggleGroup('favorites')}
/>
</div>
)}
{hasViews && (
<ViewsSection
views={views}
selection={selection}
onSelect={onSelect}
collapsed={groupCollapsed.views}
onToggle={() => toggleGroup('views')}
onCreateView={onCreateView}
onEditView={onEditView}
onDeleteView={onDeleteView}
entries={entries}
locale={locale}
/>
)}
<TypesSection
visibleSections={visibleSections}
allSectionGroups={allSectionGroups}
sectionIds={sectionIds}
sensors={sensors}
handleDragEnd={handleDragEnd}
sectionProps={sectionProps}
collapsed={groupCollapsed.sections}
onToggle={() => toggleGroup('sections')}
showCustomize={typeInteractions.showCustomize}
setShowCustomize={typeInteractions.setShowCustomize}
isSectionVisible={isSectionVisible}
toggleVisibility={toggleVisibility}
onCreateNewType={onCreateNewType}
customizeRef={typeInteractions.customizeRef}
locale={locale}
/>
<FolderTree
folders={folders}
selection={selection}
onSelect={onSelect}
onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
renamingFolderPath={renamingFolderPath}
onStartRenameFolder={onStartRenameFolder}
onCancelRenameFolder={onCancelRenameFolder}
collapsed={groupCollapsed.folders}
locale={locale}
onToggle={() => toggleGroup('folders')}
/>
</nav>
)
}
export const Sidebar = memo(function Sidebar({
entries,
selection,
@@ -77,6 +229,7 @@ export const Sidebar = memo(function Sidebar({
onCancelRenameFolder,
showInbox = true,
inboxCount = 0,
locale = 'en',
onCollapse,
onCreateNewType,
}: SidebarProps) {
@@ -109,6 +262,7 @@ export const Sidebar = memo(function Sidebar({
const sectionProps: SidebarSectionProps = {
entries,
selection,
locale,
onSelect,
onContextMenu: typeInteractions.handleContextMenu,
renamingType: typeInteractions.renamingType,
@@ -117,83 +271,51 @@ export const Sidebar = memo(function Sidebar({
onRenameCancel: typeInteractions.cancelRename,
}
const hasFavorites = entries.some((entry) => entry.favorite && !entry.archived)
const hasViews = views.length > 0 || !!onCreateView
return (
<aside className="flex h-full flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-sidebar text-sidebar-foreground">
<SidebarTitleBar onCollapse={onCollapse} />
<nav className="flex-1 overflow-y-auto">
<SidebarTopNav
selection={selection}
onSelect={onSelect}
showInbox={showInbox}
inboxCount={inboxCount}
activeCount={activeCount}
archivedCount={archivedCount}
/>
{hasFavorites && (
<div className="border-b border-border">
<FavoritesSection
entries={entries}
selection={selection}
onSelect={onSelect}
onSelectNote={onSelectFavorite}
onReorder={onReorderFavorites}
collapsed={groupCollapsed.favorites}
onToggle={() => toggleGroup('favorites')}
/>
</div>
)}
{hasViews && (
<ViewsSection
views={views}
selection={selection}
onSelect={onSelect}
collapsed={groupCollapsed.views}
onToggle={() => toggleGroup('views')}
onCreateView={onCreateView}
onEditView={onEditView}
onDeleteView={onDeleteView}
entries={entries}
/>
)}
<TypesSection
visibleSections={visibleSections}
allSectionGroups={allSectionGroups}
sectionIds={sectionIds}
sensors={sensors}
handleDragEnd={handleDragEnd}
sectionProps={sectionProps}
collapsed={groupCollapsed.sections}
onToggle={() => toggleGroup('sections')}
showCustomize={typeInteractions.showCustomize}
setShowCustomize={typeInteractions.setShowCustomize}
isSectionVisible={isSectionVisible}
toggleVisibility={toggleVisibility}
onCreateNewType={onCreateNewType}
customizeRef={typeInteractions.customizeRef}
/>
<FolderTree
folders={folders}
selection={selection}
onSelect={onSelect}
onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
renamingFolderPath={renamingFolderPath}
onStartRenameFolder={onStartRenameFolder}
onCancelRenameFolder={onCancelRenameFolder}
collapsed={groupCollapsed.folders}
onToggle={() => toggleGroup('folders')}
/>
</nav>
<SidebarTitleBar locale={locale} onCollapse={onCollapse} />
<SidebarNavigation
entries={entries}
selection={selection}
onSelect={onSelect}
onSelectFavorite={onSelectFavorite}
onReorderFavorites={onReorderFavorites}
views={views}
onCreateView={onCreateView}
onEditView={onEditView}
onDeleteView={onDeleteView}
folders={folders}
onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
renamingFolderPath={renamingFolderPath}
onStartRenameFolder={onStartRenameFolder}
onCancelRenameFolder={onCancelRenameFolder}
showInbox={showInbox}
inboxCount={inboxCount}
locale={locale}
onCreateNewType={onCreateNewType}
activeCount={activeCount}
archivedCount={archivedCount}
groupCollapsed={groupCollapsed}
toggleGroup={toggleGroup}
visibleSections={visibleSections}
allSectionGroups={allSectionGroups}
sectionIds={sectionIds}
sensors={sensors}
handleDragEnd={handleDragEnd}
sectionProps={sectionProps}
typeInteractions={typeInteractions}
isSectionVisible={isSectionVisible}
toggleVisibility={toggleVisibility}
/>
<ContextMenuOverlay
pos={typeInteractions.contextMenuPos}
type={typeInteractions.contextMenuType}
innerRef={typeInteractions.contextMenuRef}
onOpenCustomize={typeInteractions.openCustomizeTarget}
onStartRename={typeInteractions.handleStartRename}
locale={locale}
/>
<CustomizeOverlay
target={typeInteractions.customizeTarget}

View File

@@ -5,6 +5,7 @@ import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { type IconProps } from '@phosphor-icons/react'
import { SIDEBAR_ITEM_PADDING } from './sidebar/sidebarStyles'
import { Button } from './ui/button'
import { translate, type AppLocale } from '../lib/i18n'
const SIDEBAR_COUNT_PILL_STYLE = {
borderRadius: 9999,
@@ -275,12 +276,13 @@ export interface SectionContentProps {
renameInitialValue?: string
onRenameSubmit?: (value: string) => void
onRenameCancel?: () => void
locale?: AppLocale
}
export function SectionContent({
group, itemCount, selection, onSelect,
onContextMenu, dragHandleProps,
isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel,
isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel, locale,
}: SectionContentProps) {
const { label, type, Icon, customColor } = group
const { sectionColor, sectionLightColor } = resolveSectionColors(type, customColor)
@@ -299,14 +301,16 @@ export function SectionContent({
renameInitialValue={renameInitialValue}
onRenameSubmit={onRenameSubmit}
onRenameCancel={onRenameCancel}
locale={locale}
/>
)
}
function InlineRenameInput({ initialValue, onSubmit, onCancel }: {
function InlineRenameInput({ initialValue, onSubmit, onCancel, locale }: {
initialValue: string
onSubmit: (value: string) => void
onCancel: () => void
locale?: AppLocale
}) {
const [value, setValue] = useState(initialValue)
const inputRef = useRef<HTMLInputElement>(null)
@@ -326,7 +330,7 @@ function InlineRenameInput({ initialValue, onSubmit, onCancel }: {
onKeyDown={handleKeyDown}
onBlur={() => onSubmit(value.trim())}
onClick={(e) => e.stopPropagation()}
aria-label="Section name"
aria-label={translate(locale ?? 'en', 'sidebar.section.name')}
className="flex-1 rounded border border-primary bg-background text-[13px] font-medium text-foreground outline-none"
style={{ padding: '1px 4px' }}
/>
@@ -382,6 +386,7 @@ function SectionHeaderLabel({
renameInitialValue,
onRenameSubmit,
onRenameCancel,
locale,
}: {
type: string
label: string
@@ -391,6 +396,7 @@ function SectionHeaderLabel({
renameInitialValue?: string
onRenameSubmit?: (value: string) => void
onRenameCancel?: () => void
locale?: AppLocale
}) {
const inlineRenameHandlers = resolveInlineRenameHandlers({
isRenaming,
@@ -405,6 +411,7 @@ function SectionHeaderLabel({
initialValue={renameInitialValue ?? label}
onSubmit={inlineRenameHandlers.onRenameSubmit}
onCancel={inlineRenameHandlers.onRenameCancel}
locale={locale}
/>
)
}
@@ -431,13 +438,14 @@ function SectionHeaderCountPill({
)
}
function SectionHeader({ label, type, Icon, sectionColor, sectionLightColor, itemCount, isActive, onSelect, onContextMenu, dragHandleProps, isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel }: {
function SectionHeader({ label, type, Icon, sectionColor, sectionLightColor, itemCount, isActive, onSelect, onContextMenu, dragHandleProps, isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel, locale }: {
label: string; type: string; Icon: ComponentType<IconProps>
sectionColor: string; sectionLightColor: string; itemCount: number; isActive: boolean
onSelect: () => void; onContextMenu: (e: React.MouseEvent) => void
dragHandleProps?: Record<string, unknown>
isRenaming?: boolean; renameInitialValue?: string
onRenameSubmit?: (value: string) => void; onRenameCancel?: () => void
locale?: AppLocale
}) {
return (
<div
@@ -458,6 +466,7 @@ function SectionHeader({ label, type, Icon, sectionColor, sectionLightColor, ite
renameInitialValue={renameInitialValue}
onRenameSubmit={onRenameSubmit}
onRenameCancel={onRenameCancel}
locale={locale}
/>
</div>
<SectionHeaderCountPill itemCount={itemCount} isActive={isActive} sectionColor={sectionColor} />
@@ -469,10 +478,12 @@ function VisibilityPopoverItem({
group,
isVisible,
onToggle,
locale = 'en',
}: {
group: SectionGroup
isVisible: boolean
onToggle: (type: string) => void
locale?: AppLocale
}) {
const { label, type, Icon, customColor } = group
const { sectionColor } = resolveSectionColors(type, customColor)
@@ -485,7 +496,7 @@ function VisibilityPopoverItem({
className="h-auto w-full justify-start rounded-none px-3 py-1.5"
style={{ padding: '6px 12px', gap: 8 }}
onClick={() => onToggle(type)}
aria-label={`Toggle ${label}`}
aria-label={translate(locale, 'sidebar.section.toggle', { label })}
>
<Icon size={14} style={{ color: sectionColor }} />
<span className="flex-1 text-left text-[13px] text-foreground">{label}</span>
@@ -496,23 +507,25 @@ function VisibilityPopoverItem({
// --- Visibility Popover ---
export function VisibilityPopover({ sections, isSectionVisible, onToggle }: {
export function VisibilityPopover({ sections, isSectionVisible, onToggle, locale = 'en' }: {
sections: SectionGroup[]
isSectionVisible: (type: string) => boolean
onToggle: (type: string) => void
locale?: AppLocale
}) {
return (
<div
className="border border-border bg-popover text-popover-foreground"
style={{ position: 'absolute', top: '100%', left: 6, right: 6, zIndex: 50, borderRadius: 8, padding: '8px 0', boxShadow: '0 4px 12px var(--shadow-dialog)' }}
>
<div className="text-[12px] font-semibold text-muted-foreground" style={{ padding: '0 12px 4px' }}>Show in sidebar</div>
<div className="text-[12px] font-semibold text-muted-foreground" style={{ padding: '0 12px 4px' }}>{translate(locale, 'sidebar.section.showInSidebar')}</div>
{sections.map((group) => (
<VisibilityPopoverItem
key={group.type}
group={group}
isVisible={isSectionVisible(group.type)}
onToggle={onToggle}
locale={locale}
/>
))}
</div>

View File

@@ -1,19 +1,32 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import { cn } from '@/lib/utils'
import { ArrowUp, ArrowDown } from '@phosphor-icons/react'
import { type SortOption, type SortDirection, getDefaultDirection, SORT_OPTIONS, getSortOptionLabel } from '../utils/noteListHelpers'
import { translate, type AppLocale, type TranslationKey } from '../lib/i18n'
import { type SortOption, type SortDirection, getDefaultDirection, SORT_OPTIONS } from '../utils/noteListHelpers'
interface SortItem {
value: SortOption
label: string
}
const SORT_LABEL_KEYS = {
modified: 'noteList.sort.modified',
created: 'noteList.sort.created',
title: 'noteList.sort.title',
status: 'noteList.sort.status',
} satisfies Record<string, TranslationKey>
type SortMenuAction =
| { type: 'close' }
| { type: 'focus'; index: number }
function buildSortItems(customProperties?: string[]): SortItem[] {
const builtInItems = SORT_OPTIONS.map(({ value, label }) => ({ value, label }))
function getLocalizedSortOptionLabel(option: SortOption, locale: AppLocale): string {
if (option.startsWith('property:')) return option.slice('property:'.length)
return translate(locale, SORT_LABEL_KEYS[option as keyof typeof SORT_LABEL_KEYS])
}
function buildSortItems(locale: AppLocale, customProperties?: string[]): SortItem[] {
const builtInItems = SORT_OPTIONS.map(({ value }) => ({ value, label: getLocalizedSortOptionLabel(value, locale) }))
const customItems = (customProperties ?? []).map((key) => ({
value: `property:${key}` as SortOption,
label: key,
@@ -148,6 +161,7 @@ function SortDropdownTrigger({
current,
groupLabel,
direction,
locale,
onToggle,
}: {
triggerRef: React.RefObject<HTMLButtonElement | null>
@@ -155,9 +169,11 @@ function SortDropdownTrigger({
current: SortOption
groupLabel: string
direction: SortDirection
locale: AppLocale
onToggle: () => void
}) {
const DirectionIcon = direction === 'asc' ? ArrowUp : ArrowDown
const currentLabel = getLocalizedSortOptionLabel(current, locale)
return (
<button
@@ -168,13 +184,13 @@ function SortDropdownTrigger({
event.stopPropagation()
onToggle()
}}
title={`Sort by ${getSortOptionLabel(current)}`}
title={translate(locale, 'noteList.sort.by', { label: currentLabel })}
aria-haspopup="menu"
aria-expanded={open}
data-testid={`sort-button-${groupLabel}`}
>
<DirectionIcon size={12} data-testid={`sort-direction-icon-${groupLabel}`} />
<span className="text-[10px] font-medium">{getSortOptionLabel(current)}</span>
<span className="text-[10px] font-medium">{currentLabel}</span>
</button>
)
}
@@ -186,6 +202,7 @@ function SortDropdownMenu({
direction,
sortItems,
sortButtonRefs,
locale,
onKeyDown,
onSelect,
}: {
@@ -195,6 +212,7 @@ function SortDropdownMenu({
direction: SortDirection
sortItems: SortItem[]
sortButtonRefs: React.MutableRefObject<Array<HTMLButtonElement | null>>
locale: AppLocale
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
onSelect: (option: SortOption, nextDirection: SortDirection) => void
}) {
@@ -206,7 +224,7 @@ function SortDropdownMenu({
return (
<div
role="menu"
aria-label={`Sort ${groupLabel}`}
aria-label={translate(locale, 'noteList.sort.menu', { label: groupLabel })}
className="absolute right-0 top-full mt-1 rounded-md border border-border bg-popover p-1 shadow-md"
style={{ width: 170, maxHeight: 280, overflowY: 'auto' }}
onKeyDown={onKeyDown}
@@ -225,6 +243,7 @@ function SortDropdownMenu({
sortButtonRefs.current[index] = node
}}
showSeparator={hasCustom && index === builtInOptionCount}
locale={locale}
onSelect={onSelect}
/>
))}
@@ -232,14 +251,15 @@ function SortDropdownMenu({
)
}
export function SortDropdown({ groupLabel, current, direction, customProperties, onChange }: {
export function SortDropdown({ groupLabel, current, direction, customProperties, locale = 'en', onChange }: {
groupLabel: string
current: SortOption
direction: SortDirection
customProperties?: string[]
locale?: AppLocale
onChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
}) {
const sortItems = useMemo(() => buildSortItems(customProperties), [customProperties])
const sortItems = useMemo(() => buildSortItems(locale, customProperties), [customProperties, locale])
const {
open,
setOpen,
@@ -263,6 +283,7 @@ export function SortDropdown({ groupLabel, current, direction, customProperties,
current={current}
groupLabel={groupLabel}
direction={direction}
locale={locale}
onToggle={() => setOpen((value) => !value)}
/>
<SortDropdownMenu
@@ -272,6 +293,7 @@ export function SortDropdown({ groupLabel, current, direction, customProperties,
direction={direction}
sortItems={sortItems}
sortButtonRefs={sortButtonRefs}
locale={locale}
onKeyDown={handleMenuKeyDown}
onSelect={handleSelect}
/>
@@ -279,7 +301,7 @@ export function SortDropdown({ groupLabel, current, direction, customProperties,
)
}
function SortRow({ index, groupLabel, value, label, current, direction, buttonRef, showSeparator, onSelect }: {
function SortRow({ index, groupLabel, value, label, current, direction, buttonRef, showSeparator, locale, onSelect }: {
index: number
groupLabel: string
value: SortOption
@@ -288,6 +310,7 @@ function SortRow({ index, groupLabel, value, label, current, direction, buttonRe
direction: SortDirection
buttonRef: (node: HTMLButtonElement | null) => void
showSeparator: boolean
locale: AppLocale
onSelect: (opt: SortOption, dir: SortDirection) => void
}) {
const isActive = value === current
@@ -329,6 +352,7 @@ function SortRow({ index, groupLabel, value, label, current, direction, buttonRe
onSelect={onSelect}
icon={<ArrowUp size={12} />}
itemData={itemData}
locale={locale}
/>
<SortDirectionButton
value={value}
@@ -338,6 +362,7 @@ function SortRow({ index, groupLabel, value, label, current, direction, buttonRe
onSelect={onSelect}
icon={<ArrowDown size={12} />}
itemData={itemData}
locale={locale}
/>
</span>
</div>
@@ -353,6 +378,7 @@ function SortDirectionButton({
onSelect,
icon,
itemData,
locale,
}: {
value: SortOption
direction: SortDirection
@@ -361,6 +387,7 @@ function SortDirectionButton({
onSelect: (opt: SortOption, dir: SortDirection) => void
icon: React.ReactNode
itemData: Record<string, string>
locale: AppLocale
}) {
return (
<button
@@ -371,7 +398,7 @@ function SortDirectionButton({
onSelect(value, direction)
}}
data-testid={`sort-dir-${direction}-${value}`}
title={direction === 'asc' ? 'Ascending' : 'Descending'}
title={translate(locale, direction === 'asc' ? 'noteList.sort.ascending' : 'noteList.sort.descending')}
{...itemData}
>
{icon}

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import type { ClaudeCodeStatus } from '../hooks/useClaudeCodeStatus'
import type { McpStatus } from '../hooks/useMcpStatus'
import type { ThemeMode } from '../lib/themeMode'
import type { AppLocale } from '../lib/i18n'
import type { GitRemoteStatus, SyncStatus } from '../types'
import { TooltipProvider } from '@/components/ui/tooltip'
import {
@@ -94,6 +95,7 @@ interface StatusBarProps {
onRestoreVaultAiGuidance?: () => void
claudeCodeStatus?: ClaudeCodeStatus
claudeCodeVersion?: string | null
locale?: AppLocale
}
interface StatusBarFooterProps extends StatusBarProps {
@@ -101,13 +103,11 @@ interface StatusBarFooterProps extends StatusBarProps {
stacked: boolean
}
function StatusBarFooter({
noteCount,
function StatusBarPrimaryFromFooter({
modifiedCount = 0,
vaultPath,
vaults,
onSwitchVault,
onOpenSettings,
onOpenLocalFolder,
onCreateEmptyVault,
onCloneVault,
@@ -125,11 +125,6 @@ function StatusBarFooter({
onTriggerSync,
onPullAndPush,
onOpenConflictResolver,
zoomLevel = 100,
themeMode = 'light',
onZoomReset,
onToggleThemeMode,
onOpenFeedback,
buildNumber,
onCheckForUpdates,
onRemoveVault,
@@ -142,9 +137,83 @@ function StatusBarFooter({
onRestoreVaultAiGuidance,
claudeCodeStatus,
claudeCodeVersion,
locale = 'en',
compact,
stacked,
}: StatusBarFooterProps) {
return (
<StatusBarPrimarySection
modifiedCount={modifiedCount}
vaultPath={vaultPath}
vaults={vaults}
onSwitchVault={onSwitchVault}
onOpenLocalFolder={onOpenLocalFolder}
onCreateEmptyVault={onCreateEmptyVault}
onCloneVault={onCloneVault}
onCloneGettingStarted={onCloneGettingStarted}
onClickPending={onClickPending}
onClickPulse={onClickPulse}
onCommitPush={onCommitPush}
onInitializeGit={onInitializeGit}
isOffline={isOffline}
isGitVault={isGitVault}
syncStatus={syncStatus}
lastSyncTime={lastSyncTime}
conflictCount={conflictCount}
remoteStatus={remoteStatus}
onTriggerSync={onTriggerSync}
onPullAndPush={onPullAndPush}
onOpenConflictResolver={onOpenConflictResolver}
buildNumber={buildNumber}
onCheckForUpdates={onCheckForUpdates}
onRemoveVault={onRemoveVault}
mcpStatus={mcpStatus}
onInstallMcp={onInstallMcp}
aiAgentsStatus={aiAgentsStatus}
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
defaultAiAgent={defaultAiAgent}
onSetDefaultAiAgent={onSetDefaultAiAgent}
onRestoreVaultAiGuidance={onRestoreVaultAiGuidance}
claudeCodeStatus={claudeCodeStatus}
claudeCodeVersion={claudeCodeVersion}
locale={locale}
stacked={stacked}
compact={compact}
/>
)
}
function StatusBarSecondaryFromFooter({
noteCount,
zoomLevel = 100,
themeMode = 'light',
onZoomReset,
onToggleThemeMode,
onOpenFeedback,
onOpenSettings,
locale = 'en',
compact,
stacked,
}: StatusBarFooterProps) {
return (
<StatusBarSecondarySection
noteCount={noteCount}
zoomLevel={zoomLevel}
themeMode={themeMode}
onZoomReset={onZoomReset}
onToggleThemeMode={onToggleThemeMode}
onOpenFeedback={onOpenFeedback}
onOpenSettings={onOpenSettings}
locale={locale}
stacked={stacked}
compact={compact}
/>
)
}
function StatusBarFooter(props: StatusBarFooterProps) {
const { compact, stacked } = props
return (
<footer
data-testid="status-bar"
@@ -167,54 +236,8 @@ function StatusBarFooter({
zIndex: 10,
}}
>
<StatusBarPrimarySection
modifiedCount={modifiedCount}
vaultPath={vaultPath}
vaults={vaults}
onSwitchVault={onSwitchVault}
onOpenLocalFolder={onOpenLocalFolder}
onCreateEmptyVault={onCreateEmptyVault}
onCloneVault={onCloneVault}
onCloneGettingStarted={onCloneGettingStarted}
onClickPending={onClickPending}
onClickPulse={onClickPulse}
onCommitPush={onCommitPush}
onInitializeGit={onInitializeGit}
isOffline={isOffline}
isGitVault={isGitVault}
syncStatus={syncStatus}
lastSyncTime={lastSyncTime}
conflictCount={conflictCount}
remoteStatus={remoteStatus}
onTriggerSync={onTriggerSync}
onPullAndPush={onPullAndPush}
onOpenConflictResolver={onOpenConflictResolver}
buildNumber={buildNumber}
onCheckForUpdates={onCheckForUpdates}
onRemoveVault={onRemoveVault}
mcpStatus={mcpStatus}
onInstallMcp={onInstallMcp}
aiAgentsStatus={aiAgentsStatus}
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
defaultAiAgent={defaultAiAgent}
onSetDefaultAiAgent={onSetDefaultAiAgent}
onRestoreVaultAiGuidance={onRestoreVaultAiGuidance}
claudeCodeStatus={claudeCodeStatus}
claudeCodeVersion={claudeCodeVersion}
stacked={stacked}
compact={compact}
/>
<StatusBarSecondarySection
noteCount={noteCount}
zoomLevel={zoomLevel}
themeMode={themeMode}
onZoomReset={onZoomReset}
onToggleThemeMode={onToggleThemeMode}
onOpenFeedback={onOpenFeedback}
onOpenSettings={onOpenSettings}
stacked={stacked}
compact={compact}
/>
<StatusBarPrimaryFromFooter {...props} />
<StatusBarSecondaryFromFooter {...props} />
</footer>
)
}

View File

@@ -15,11 +15,11 @@ import {
PROPERTY_PANEL_LABEL_ICON_SLOT_CLASS_NAME,
PROPERTY_PANEL_ROW_STYLE,
} from './propertyPanelLayout'
import { translate, type AppLocale } from '../lib/i18n'
const TYPE_NONE = '__none__'
const MIN_POPOVER_WIDTH = 220
const OPEN_COMBOBOX_KEYS = new Set(['ArrowDown', 'ArrowUp', 'Enter', ' '])
const MISSING_TYPE_TOOLTIP = 'Missing type'
interface TypeSelectorItemProps {
type: string
@@ -85,12 +85,14 @@ function TypeSelectorValue({
isA,
typeColorKeys,
typeIconKeys,
locale,
}: {
isA?: string | null
typeColorKeys: Record<string, string | null>
typeIconKeys: Record<string, string | null>
locale: AppLocale
}) {
if (!isA) return <span className="truncate text-muted-foreground">None</span>
if (!isA) return <span className="truncate text-muted-foreground">{translate(locale, 'inspector.properties.none')}</span>
return (
<span className="flex min-w-0 items-center gap-1">
@@ -115,9 +117,11 @@ function TypeRowLabel() {
function MissingTypeWarning({
missingTypeName,
locale,
onCreateMissingType,
}: {
missingTypeName: string
locale: AppLocale
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
}) {
const [dialogOpen, setDialogOpen] = useState(false)
@@ -136,13 +140,13 @@ function MissingTypeWarning({
!canCreateMissingType && 'cursor-default',
)}
data-testid="missing-type-warning"
aria-label={`Missing type ${missingTypeName}. Click to create this type.`}
aria-label={translate(locale, 'inspector.properties.missingTypeAria', { type: missingTypeName })}
onClick={canCreateMissingType ? () => setDialogOpen(true) : undefined}
>
<WarningCircle size={14} weight="fill" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>{MISSING_TYPE_TOOLTIP}</TooltipContent>
<TooltipContent>{translate(locale, 'inspector.properties.missingType')}</TooltipContent>
</Tooltip>
{canCreateMissingType && (
<CreateTypeDialog
@@ -159,10 +163,12 @@ function MissingTypeWarning({
function TypeRowValue({
children,
missingTypeName,
locale,
onCreateMissingType,
}: {
children: ReactNode
missingTypeName?: string | null
locale: AppLocale
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
}) {
return (
@@ -171,6 +177,7 @@ function TypeRowValue({
{missingTypeName && (
<MissingTypeWarning
missingTypeName={missingTypeName}
locale={locale}
onCreateMissingType={onCreateMissingType}
/>
)}
@@ -183,12 +190,14 @@ function ReadOnlyType({
customColorKey,
onNavigate,
missingTypeName,
locale,
onCreateMissingType,
}: {
isA?: string | null
customColorKey?: string | null
onNavigate?: (target: string) => void
missingTypeName?: string | null
locale: AppLocale
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
}) {
if (!isA) return null
@@ -198,7 +207,7 @@ function ReadOnlyType({
style={PROPERTY_PANEL_ROW_STYLE}
>
<TypeRowLabel />
<TypeRowValue missingTypeName={missingTypeName} onCreateMissingType={onCreateMissingType}>
<TypeRowValue missingTypeName={missingTypeName} locale={locale} onCreateMissingType={onCreateMissingType}>
{onNavigate ? (
<button
className="min-w-0 max-w-full truncate border-none cursor-pointer ring-inset hover:ring-1 hover:ring-current"
@@ -229,6 +238,7 @@ interface TypeSelectorProps {
onNavigate?: (target: string) => void
missingTypeName?: string | null
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
locale?: AppLocale
}
export function TypeSelector({ onUpdateProperty, ...props }: TypeSelectorProps) {
@@ -239,6 +249,7 @@ export function TypeSelector({ onUpdateProperty, ...props }: TypeSelectorProps)
customColorKey={props.customColorKey}
onNavigate={props.onNavigate}
missingTypeName={props.missingTypeName}
locale={props.locale ?? 'en'}
onCreateMissingType={props.onCreateMissingType}
/>
)
@@ -254,6 +265,7 @@ function EditableTypeSelector({
typeColorKeys,
typeIconKeys,
missingTypeName,
locale = 'en',
onCreateMissingType,
onUpdateProperty,
}: Omit<TypeSelectorProps, 'onUpdateProperty'> & {
@@ -380,7 +392,7 @@ function EditableTypeSelector({
data-testid="type-selector"
>
<TypeRowLabel />
<TypeRowValue missingTypeName={missingTypeName} onCreateMissingType={onCreateMissingType}>
<TypeRowValue missingTypeName={missingTypeName} locale={locale} onCreateMissingType={onCreateMissingType}>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverAnchor asChild>
<div ref={rootRef} className="min-w-0">
@@ -405,7 +417,7 @@ function EditableTypeSelector({
onKeyDown={handleTriggerKeyDown}
>
<span className="flex min-w-0 items-center gap-1 truncate">
<TypeSelectorValue isA={isA} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} />
<TypeSelectorValue isA={isA} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} locale={locale} />
</span>
<CaretUpDown size={14} aria-hidden="true" />
</Button>
@@ -424,9 +436,9 @@ function EditableTypeSelector({
<Input
ref={inputRef}
value={query}
placeholder="Search types..."
placeholder={translate(locale, 'inspector.properties.searchTypes')}
autoComplete="off"
aria-label="Search types"
aria-label={translate(locale, 'inspector.properties.searchTypes')}
className="h-8 text-sm"
data-testid="type-selector-search-input"
onChange={(event) => handleSearchChange(event.target.value)}
@@ -436,7 +448,7 @@ function EditableTypeSelector({
<div ref={listRef} className="max-h-60 overflow-y-auto p-1">
{options.length === 0 ? (
<div className="px-2 py-6 text-center text-sm text-muted-foreground">
No matching types
{translate(locale, 'inspector.properties.noMatchingTypes')}
</div>
) : (
<div id={listboxId} role="listbox">
@@ -460,7 +472,7 @@ function EditableTypeSelector({
onClick={() => selectType(type)}
>
{type === TYPE_NONE ? (
<span className="truncate text-muted-foreground">None</span>
<span className="truncate text-muted-foreground">{translate(locale, 'inspector.properties.none')}</span>
) : (
<span className="flex min-w-0 items-center gap-2 truncate">
<TypeSelectorItem type={type} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} />

View File

@@ -50,8 +50,8 @@ function makeReadyStatus(overrides?: Partial<Extract<UpdateStatus, { state: 'rea
}
}
function renderBanner(status: UpdateStatus, actions = makeActions()) {
const view = render(<UpdateBanner status={status} actions={actions} />)
function renderBanner(status: UpdateStatus, actions = makeActions(), locale: 'en' | 'zh-Hans' = 'en') {
const view = render(<UpdateBanner status={status} actions={actions} locale={locale} />)
return { ...view, actions }
}
@@ -83,6 +83,19 @@ describe('UpdateBanner', () => {
expect(screen.getByTestId('update-dismiss')).toBeTruthy()
})
it('localizes available update copy', () => {
renderBanner(makeAvailableStatus({
version: '2026.4.16-alpha.3',
displayVersion: 'Alpha 2026.4.16.3',
}), makeActions(), 'zh-Hans')
expect(screen.getByText(/Tolaria Alpha 2026\.4\.16\.3/)).toBeTruthy()
expect(screen.getByText(/可用/)).toBeTruthy()
expect(screen.getByTestId('update-release-notes')).toHaveTextContent('发行说明')
expect(screen.getByTestId('update-now-btn')).toHaveTextContent('立即更新')
expect(screen.getByTestId('update-dismiss')).toHaveAttribute('aria-label', '关闭')
})
it('"Update Now" calls startDownload', () => {
const { actions } = renderBanner(makeAvailableStatus())

View File

@@ -3,10 +3,12 @@ import { Download, ExternalLink, RefreshCw, X } from 'lucide-react'
import type { UpdateStatus, UpdateActions } from '../hooks/useUpdater'
import { restartApp } from '../hooks/useUpdater'
import { Button } from './ui/button'
import { translate, type AppLocale } from '../lib/i18n'
interface UpdateBannerProps {
status: UpdateStatus
actions: UpdateActions
locale?: AppLocale
}
type VisibleUpdateStatus = Exclude<UpdateStatus, { state: 'idle' } | { state: 'error' }>
@@ -62,12 +64,12 @@ const readyIconStyle = {
flexShrink: 0,
} satisfies CSSProperties
function renderAvailableContent(status: Extract<VisibleUpdateStatus, { state: 'available' }>, actions: UpdateActions) {
function renderAvailableContent(status: Extract<VisibleUpdateStatus, { state: 'available' }>, actions: UpdateActions, locale: AppLocale) {
return (
<>
<Download size={14} style={iconStyle} />
<span>
<strong>Tolaria {status.displayVersion}</strong> is available
<strong>Tolaria {status.displayVersion}</strong> {translate(locale, 'update.available')}
</span>
<Button
type="button"
@@ -77,7 +79,7 @@ function renderAvailableContent(status: Extract<VisibleUpdateStatus, { state: 'a
onClick={actions.openReleaseNotes}
style={{ color: 'var(--text-inverse)', padding: 0, height: 'auto' }}
>
Release Notes <ExternalLink size={11} />
{translate(locale, 'update.releaseNotes')} <ExternalLink size={11} />
</Button>
<Button
type="button"
@@ -86,7 +88,7 @@ function renderAvailableContent(status: Extract<VisibleUpdateStatus, { state: 'a
onClick={actions.startDownload}
style={primaryActionStyle}
>
Update Now
{translate(locale, 'update.updateNow')}
</Button>
<Button
type="button"
@@ -95,7 +97,7 @@ function renderAvailableContent(status: Extract<VisibleUpdateStatus, { state: 'a
data-testid="update-dismiss"
onClick={actions.dismiss}
style={dismissButtonStyle}
aria-label="Dismiss"
aria-label={translate(locale, 'update.dismiss')}
>
<X size={14} />
</Button>
@@ -103,11 +105,11 @@ function renderAvailableContent(status: Extract<VisibleUpdateStatus, { state: 'a
)
}
function renderDownloadingContent(status: Extract<VisibleUpdateStatus, { state: 'downloading' }>) {
function renderDownloadingContent(status: Extract<VisibleUpdateStatus, { state: 'downloading' }>, locale: AppLocale) {
return (
<>
<RefreshCw size={14} style={{ ...iconStyle, animation: 'spin 1s linear infinite' }} />
<span>Downloading Tolaria {status.displayVersion}...</span>
<span>{translate(locale, 'update.downloading', { version: status.displayVersion })}</span>
<div style={progressTrackStyle}>
<div
data-testid="update-progress"
@@ -125,12 +127,12 @@ function renderDownloadingContent(status: Extract<VisibleUpdateStatus, { state:
)
}
function renderReadyContent(status: Extract<VisibleUpdateStatus, { state: 'ready' }>) {
function renderReadyContent(status: Extract<VisibleUpdateStatus, { state: 'ready' }>, locale: AppLocale) {
return (
<>
<RefreshCw size={14} style={readyIconStyle} />
<span>
<strong>Tolaria {status.displayVersion}</strong> is ready - restart to apply
<strong>Tolaria {status.displayVersion}</strong> {translate(locale, 'update.readyRestart')}
</span>
<Button
type="button"
@@ -143,25 +145,25 @@ function renderReadyContent(status: Extract<VisibleUpdateStatus, { state: 'ready
color: 'var(--text-inverse)',
}}
>
Restart Now
{translate(locale, 'update.restartNow')}
</Button>
</>
)
}
function renderBannerContent(status: VisibleUpdateStatus, actions: UpdateActions) {
function renderBannerContent(status: VisibleUpdateStatus, actions: UpdateActions, locale: AppLocale) {
switch (status.state) {
case 'available':
return renderAvailableContent(status, actions)
return renderAvailableContent(status, actions, locale)
case 'downloading':
return renderDownloadingContent(status)
return renderDownloadingContent(status, locale)
case 'ready':
return renderReadyContent(status)
return renderReadyContent(status, locale)
}
}
export function UpdateBanner({ status, actions }: UpdateBannerProps) {
export function UpdateBanner({ status, actions, locale = 'en' }: UpdateBannerProps) {
if (status.state === 'idle' || status.state === 'error') return null
return <div data-testid="update-banner" style={bannerStyle}>{renderBannerContent(status, actions)}</div>
return <div data-testid="update-banner" style={bannerStyle}>{renderBannerContent(status, actions, locale)}</div>
}

View File

@@ -1,5 +1,6 @@
import type React from 'react'
import { cn } from '@/lib/utils'
import { translate, type AppLocale } from '../../lib/i18n'
import { DiffView } from '../DiffView'
import { BreadcrumbBar } from '../BreadcrumbBar'
import { ArchivedNoteBanner } from '../ArchivedNoteBanner'
@@ -45,16 +46,18 @@ function EditorLoadingSkeleton() {
)
}
function DiffModeView({ diffContent, onToggleDiff }: { diffContent: string | null; onToggleDiff: () => void }) {
function DiffModeView({ diffContent, locale = 'en', onToggleDiff }: { diffContent: string | null; locale?: AppLocale; onToggleDiff: () => void }) {
const label = translate(locale, 'editor.toolbar.rawReturn')
return (
<div className="flex-1 overflow-auto">
<button
className="flex items-center gap-1.5 px-4 py-2 text-xs text-primary bg-muted border-b border-border cursor-pointer hover:bg-accent transition-colors w-full border-t-0 border-l-0 border-r-0"
onClick={onToggleDiff}
title="Back to editor"
title={label}
>
<span style={{ fontSize: 14, lineHeight: 1 }}>&larr;</span>
Back to editor
{label}
</button>
<DiffView diff={diffContent ?? ''} />
</div>
@@ -70,11 +73,13 @@ function RawModeEditorSection({
onSave,
rawLatestContentRef,
vaultPath,
locale,
}: Pick<
EditorContentModel,
'activeTab' | 'entries' | 'onRawContentChange' | 'onSave' | 'rawLatestContentRef' | 'rawModeContent' | 'vaultPath'
> & {
rawMode: boolean
locale?: AppLocale
}) {
if (!rawMode || !activeTab) return null
@@ -88,6 +93,7 @@ function RawModeEditorSection({
onSave={onSave ?? (() => {})}
latestContentRef={rawLatestContentRef}
vaultPath={vaultPath}
locale={locale}
/>
)
}
@@ -102,12 +108,14 @@ function ActiveTabBreadcrumb({
wordCount,
path,
actions,
locale,
}: {
activeTab: NonNullable<EditorContentModel['activeTab']>
barRef: React.RefObject<HTMLDivElement | null>
wordCount: number
path: string
actions: BreadcrumbActions
locale?: AppLocale
}) {
return (
<BreadcrumbBar
@@ -133,6 +141,7 @@ function ActiveTabBreadcrumb({
onRenameFilename={actions.onRenameFilename}
noteLayout={actions.noteLayout}
onToggleNoteLayout={actions.onToggleNoteLayout}
locale={locale}
/>
)
}
@@ -147,22 +156,24 @@ function EditorChrome({
diffMode,
diffContent,
onToggleDiff,
locale,
}: Pick<
EditorContentModel,
'isArchived' | 'onUnarchiveNote' | 'path' | 'isConflicted' | 'onKeepMine' | 'onKeepTheirs' | 'diffMode' | 'diffContent' | 'onToggleDiff'
'isArchived' | 'onUnarchiveNote' | 'path' | 'isConflicted' | 'onKeepMine' | 'onKeepTheirs' | 'diffMode' | 'diffContent' | 'onToggleDiff' | 'locale'
>) {
return (
<>
{isArchived && onUnarchiveNote && (
<ArchivedNoteBanner onUnarchive={() => onUnarchiveNote(path)} />
<ArchivedNoteBanner onUnarchive={() => onUnarchiveNote(path)} locale={locale} />
)}
{isConflicted && (
<ConflictNoteBanner
onKeepMine={() => onKeepMine?.(path)}
onKeepTheirs={() => onKeepTheirs?.(path)}
locale={locale}
/>
)}
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
{diffMode && <DiffModeView diffContent={diffContent} locale={locale} onToggleDiff={onToggleDiff} />}
</>
)
}
@@ -234,6 +245,7 @@ export function EditorContentLayout(model: EditorContentModel) {
rawLatestContentRef,
rawModeContent,
noteLayout,
locale,
} = model
const rootClassName = cn(
'flex flex-1 flex-col min-w-0 min-h-0',
@@ -255,6 +267,7 @@ export function EditorContentLayout(model: EditorContentModel) {
barRef={breadcrumbBarRef}
wordCount={wordCount}
path={path}
locale={locale}
actions={{
diffMode: model.diffMode,
diffLoading: model.diffLoading,
@@ -287,6 +300,7 @@ export function EditorContentLayout(model: EditorContentModel) {
diffMode={diffMode}
diffContent={diffContent}
onToggleDiff={onToggleDiff}
locale={locale}
/>
<RawModeEditorSection
activeTab={activeTab}
@@ -297,6 +311,7 @@ export function EditorContentLayout(model: EditorContentModel) {
onSave={onSave}
rawLatestContentRef={rawLatestContentRef}
vaultPath={vaultPath}
locale={locale}
/>
<EditorCanvas
showEditor={showEditor}

View File

@@ -1,6 +1,7 @@
import type React from 'react'
import { useRef } from 'react'
import type { useCreateBlockNote } from '@blocknote/react'
import type { AppLocale } from '../../lib/i18n'
import type { NoteLayout, NoteStatus, VaultEntry } from '../../types'
import { useEditorTheme } from '../../hooks/useTheme'
import { deriveEditorContentState } from './editorContentState'
@@ -45,6 +46,7 @@ export interface EditorContentProps {
isConflicted?: boolean
onKeepMine?: (path: string) => void
onKeepTheirs?: (path: string) => void
locale?: AppLocale
}
export function useEditorContentModel(props: EditorContentProps) {

View File

@@ -1,6 +1,7 @@
import type { RefObject } from 'react'
import { PencilSimple, Trash } from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import { translate, type AppLocale } from '../../lib/i18n'
export interface FolderContextMenuState {
path: string
@@ -13,6 +14,7 @@ interface FolderContextMenuProps {
menuRef: RefObject<HTMLDivElement | null>
onDelete?: (folderPath: string) => void
onRename: (folderPath: string) => void
locale?: AppLocale
}
export function FolderContextMenu({
@@ -20,6 +22,7 @@ export function FolderContextMenu({
menuRef,
onDelete,
onRename,
locale = 'en',
}: FolderContextMenuProps) {
if (!menu) return null
@@ -37,7 +40,7 @@ export function FolderContextMenu({
onClick={() => onRename(menu.path)}
>
<PencilSimple size={14} />
Rename folder
{translate(locale, 'sidebar.action.renameFolderMenu')}
</Button>
<Button
type="button"
@@ -47,7 +50,7 @@ export function FolderContextMenu({
data-testid="delete-folder-menu-item"
>
<Trash size={14} />
Delete folder
{translate(locale, 'sidebar.action.deleteFolderMenu')}
</Button>
</div>
)

View File

@@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import type { FolderNode } from '../../types'
import { useFolderRowInteractions } from './useFolderRowInteractions'
import { translate, type AppLocale } from '../../lib/i18n'
interface FolderItemRowProps {
contentInset: number
@@ -23,6 +24,7 @@ interface FolderItemRowProps {
onSelect: () => void
onStartRenameFolder?: (folderPath: string) => void
onToggle: (path: string) => void
locale?: AppLocale
}
export function FolderItemRow({
@@ -36,9 +38,10 @@ export function FolderItemRow({
onSelect,
onStartRenameFolder,
onToggle,
locale = 'en',
}: FolderItemRowProps) {
const hasChildren = node.children.length > 0
const expandLabel = isExpanded ? `Collapse ${node.name}` : `Expand ${node.name}`
const expandLabel = translate(locale, isExpanded ? 'sidebar.folder.collapse' : 'sidebar.folder.expand', { name: node.name })
const hasActions = !!onStartRenameFolder || !!onDeleteFolder
const { handleRenameDoubleClick, handleSelectClick } = useFolderRowInteractions({
hasChildren,
@@ -81,9 +84,9 @@ export function FolderItemRow({
<div className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
{onStartRenameFolder && (
<FolderActionButton
ariaLabel={`Rename ${node.name}`}
ariaLabel={translate(locale, 'sidebar.action.renameFolder')}
testId={`rename-folder-btn:${node.path}`}
title="Rename folder"
title={translate(locale, 'sidebar.action.renameFolder')}
onClick={() => {
onSelect()
onStartRenameFolder(node.path)
@@ -94,9 +97,9 @@ export function FolderItemRow({
)}
{onDeleteFolder && (
<FolderActionButton
ariaLabel={`Delete ${node.name}`}
ariaLabel={translate(locale, 'sidebar.action.deleteFolder')}
testId={`delete-folder-btn:${node.path}`}
title="Delete folder"
title={translate(locale, 'sidebar.action.deleteFolder')}
destructive
onClick={() => {
onSelect()

View File

@@ -4,6 +4,7 @@ import { NoteDropTarget } from '../note-retargeting/NoteDropTarget'
import { useNoteRetargetingContext } from '../note-retargeting/noteRetargetingContext'
import { FolderNameInput } from './FolderNameInput'
import { FolderItemRow } from './FolderItemRow'
import { translate, type AppLocale } from '../../lib/i18n'
interface FolderTreeRowProps {
depth: number
@@ -16,6 +17,7 @@ interface FolderTreeRowProps {
onStartRenameFolder?: (folderPath: string) => void
onToggle: (path: string) => void
onCancelRenameFolder?: () => void
locale?: AppLocale
renamingFolderPath?: string | null
selection: SidebarSelection
}
@@ -24,21 +26,23 @@ function FolderRenameRow({
contentInset,
depthIndent,
node,
locale,
onCancelRenameFolder,
onRenameFolder,
}: {
contentInset: number
depthIndent: number
node: FolderNode
locale: AppLocale
onCancelRenameFolder: () => void
onRenameFolder: (folderPath: string, nextName: string) => Promise<boolean> | boolean
}) {
return (
<div style={{ paddingLeft: depthIndent }}>
<FolderNameInput
ariaLabel="Folder name"
ariaLabel={translate(locale, 'sidebar.folder.name')}
initialValue={node.name}
placeholder="Folder name"
placeholder={translate(locale, 'sidebar.folder.name')}
leftInset={contentInset}
selectTextOnFocus={true}
testId="rename-folder-input"
@@ -60,6 +64,7 @@ function FolderChildren({
onStartRenameFolder,
onToggle,
onCancelRenameFolder,
locale,
renamingFolderPath,
selection,
}: FolderTreeRowProps) {
@@ -86,6 +91,7 @@ function FolderChildren({
onStartRenameFolder={onStartRenameFolder}
onToggle={onToggle}
onCancelRenameFolder={onCancelRenameFolder}
locale={locale}
renamingFolderPath={renamingFolderPath}
selection={selection}
/>
@@ -105,6 +111,7 @@ export const FolderTreeRow = memo(function FolderTreeRow({
onStartRenameFolder,
onToggle,
onCancelRenameFolder,
locale = 'en',
renamingFolderPath,
selection,
}: FolderTreeRowProps) {
@@ -129,6 +136,7 @@ export const FolderTreeRow = memo(function FolderTreeRow({
onSelect={selectFolder}
onStartRenameFolder={onStartRenameFolder}
onToggle={onToggle}
locale={locale}
/>
)
@@ -139,6 +147,7 @@ export const FolderTreeRow = memo(function FolderTreeRow({
contentInset={contentInset}
depthIndent={depthIndent}
node={node}
locale={locale}
onCancelRenameFolder={onCancelRenameFolder}
onRenameFolder={onRenameFolder}
/>
@@ -163,6 +172,7 @@ export const FolderTreeRow = memo(function FolderTreeRow({
onStartRenameFolder={onStartRenameFolder}
onToggle={onToggle}
onCancelRenameFolder={onCancelRenameFolder}
locale={locale}
renamingFolderPath={renamingFolderPath}
selection={selection}
/>

View File

@@ -1,8 +1,10 @@
import { SlidersHorizontal, X, Sparkle, WarningCircle, PencilSimple } from '@phosphor-icons/react'
import { useDragRegion } from '../../hooks/useDragRegion'
import { translate, type AppLocale } from '../../lib/i18n'
export function InspectorHeader({ collapsed, onToggle }: { collapsed: boolean; onToggle: () => void }) {
export function InspectorHeader({ collapsed, locale = 'en', onToggle }: { collapsed: boolean; locale?: AppLocale; onToggle: () => void }) {
const { onMouseDown } = useDragRegion()
const propertiesTitle = translate(locale, 'inspector.title.properties')
return (
<div
@@ -14,18 +16,18 @@ export function InspectorHeader({ collapsed, onToggle }: { collapsed: boolean; o
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
onClick={onToggle}
title="Properties (⌘⇧I)"
title={translate(locale, 'inspector.title.propertiesShortcut')}
>
<SlidersHorizontal size={16} />
</button>
) : (
<>
<SlidersHorizontal size={16} className="shrink-0 text-muted-foreground" />
<span className="flex-1 text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>Properties</span>
<span className="flex-1 text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>{propertiesTitle}</span>
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
onClick={onToggle}
title="Close Properties (⌘⇧I)"
title={translate(locale, 'inspector.title.closePropertiesShortcut')}
>
<X size={16} />
</button>
@@ -35,36 +37,36 @@ export function InspectorHeader({ collapsed, onToggle }: { collapsed: boolean; o
)
}
export function EmptyInspector() {
return <div><p className="m-0 text-[13px] text-muted-foreground">No note selected</p></div>
export function EmptyInspector({ locale = 'en' }: { locale?: AppLocale }) {
return <div><p className="m-0 text-[13px] text-muted-foreground">{translate(locale, 'inspector.empty.noNoteSelected')}</p></div>
}
export function InitializePropertiesPrompt({ onClick }: { onClick: () => void }) {
export function InitializePropertiesPrompt({ locale = 'en', onClick }: { locale?: AppLocale; onClick: () => void }) {
return (
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-border px-4 py-6">
<Sparkle size={24} className="text-muted-foreground" />
<p className="m-0 text-center text-[13px] text-muted-foreground">This note has no properties yet</p>
<p className="m-0 text-center text-[13px] text-muted-foreground">{translate(locale, 'inspector.empty.noProperties')}</p>
<button
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-[13px] font-medium text-foreground transition-colors hover:bg-muted"
onClick={onClick}
>
Initialize properties
{translate(locale, 'inspector.empty.initializeProperties')}
</button>
</div>
)
}
export function InvalidFrontmatterNotice({ onFix }: { onFix: () => void }) {
export function InvalidFrontmatterNotice({ locale = 'en', onFix }: { locale?: AppLocale; onFix: () => void }) {
return (
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 py-6">
<WarningCircle size={24} className="text-destructive" />
<p className="m-0 text-center text-[13px] text-muted-foreground">Invalid properties</p>
<p className="m-0 text-center text-[13px] text-muted-foreground">{translate(locale, 'inspector.empty.invalidProperties')}</p>
<button
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-[13px] font-medium text-foreground transition-colors hover:bg-muted"
onClick={onFix}
>
<PencilSimple size={14} />
Fix in editor
{translate(locale, 'inspector.empty.fixInEditor')}
</button>
</div>
)

View File

@@ -1,11 +1,16 @@
import type { VaultEntry } from '../../types'
import { Info } from '@phosphor-icons/react'
import { countWords } from '../../utils/wikilinks'
import { translate, type AppLocale } from '../../lib/i18n'
function formatDate(timestamp: number | null): string {
function dateLocale(locale: AppLocale): string {
return locale === 'zh-Hans' ? 'zh-CN' : 'en-US'
}
function formatDate(timestamp: number | null, locale: AppLocale): string {
if (!timestamp) return '\u2014'
const d = new Date(timestamp * 1000)
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
return d.toLocaleDateString(dateLocale(locale), { year: 'numeric', month: 'short', day: 'numeric' })
}
function formatFileSize(bytes: number): string {
@@ -25,19 +30,19 @@ function InfoRow({ label, value }: { label: string; value: string }) {
)
}
export function NoteInfoPanel({ entry, content }: { entry: VaultEntry; content: string | null }) {
export function NoteInfoPanel({ entry, content, locale = 'en' }: { entry: VaultEntry; content: string | null; locale?: AppLocale }) {
const wordCount = countWords(content ?? '')
return (
<div>
<h4 className="font-mono-overline mb-2 flex items-center gap-1 text-muted-foreground">
<Info size={12} className="shrink-0" />
Info
{translate(locale, 'inspector.info.title')}
</h4>
<div className="flex flex-col gap-1.5">
<InfoRow label="Modified" value={formatDate(entry.modifiedAt)} />
<InfoRow label="Created" value={formatDate(entry.createdAt)} />
<InfoRow label="Words" value={String(wordCount)} />
<InfoRow label="Size" value={formatFileSize(entry.fileSize)} />
<InfoRow label={translate(locale, 'inspector.info.modified')} value={formatDate(entry.modifiedAt, locale)} />
<InfoRow label={translate(locale, 'inspector.info.created')} value={formatDate(entry.createdAt, locale)} />
<InfoRow label={translate(locale, 'inspector.info.words')} value={String(wordCount)} />
<InfoRow label={translate(locale, 'inspector.info.size')} value={formatFileSize(entry.fileSize)} />
</div>
</div>
)

View File

@@ -19,6 +19,7 @@ import {
PROPERTY_PANEL_LABEL_CLASS_NAME,
} from '../propertyPanelLayout'
import { humanizePropertyKey } from '../../utils/propertyLabels'
import { translate, type AppLocale } from '../../lib/i18n'
const RELATIONSHIP_SECTION_ROW_CLASS_NAME = 'flex min-w-0 flex-col gap-1 px-1.5'
const RELATIONSHIPS_PANEL_GRID_CLASS_NAME = 'grid min-w-0 gap-x-2 gap-y-3'
@@ -68,6 +69,7 @@ function RelationshipSectionRow({ label, children, dataTestId }: {
label: string
children: ReactNode
dataTestId?: string
locale: AppLocale
}) {
return (
<div className={RELATIONSHIP_SECTION_ROW_CLASS_NAME} style={{ gridColumn: '1 / -1' }} data-testid={dataTestId}>
@@ -212,9 +214,10 @@ function useCreateOption(
return { showCreate, createIndex: resultCount, totalItems: resultCount + (showCreate ? 1 : 0) }
}
function CreateAndOpenOption({ title, selected, onClick, onHover }: {
function CreateAndOpenOption({ title, selected, locale, onClick, onHover }: {
title: string
selected: boolean
locale: AppLocale
onClick: () => void
onHover: () => void
}) {
@@ -228,18 +231,19 @@ function CreateAndOpenOption({ title, selected, onClick, onHover }: {
>
<Plus size={14} className="shrink-0 text-muted-foreground" />
<span className="truncate text-foreground">
Create &amp; open <strong>{title}</strong>
{translate(locale, 'inspector.relationship.createAndOpen')} <strong>{title}</strong>
</span>
</div>
)
}
function SearchDropdownWithCreate({ search, onSelect, query, entries, onCreateAndOpen }: {
function SearchDropdownWithCreate({ search, onSelect, query, entries, locale, onCreateAndOpen }: {
search: ReturnType<typeof useNoteSearch>
onSelect: (entry: VaultEntry) => void
query: string
entries: VaultEntry[]
onCreateAndOpen?: (title: string) => void
locale: AppLocale
}) {
const trimmed = query.trim()
const { showCreate, createIndex } = useCreateOption({
@@ -268,6 +272,7 @@ function SearchDropdownWithCreate({ search, onSelect, query, entries, onCreateAn
<CreateAndOpenOption
title={trimmed}
selected={search.selectedIndex === createIndex}
locale={locale}
onClick={() => onCreateAndOpen(trimmed)}
onHover={() => search.setSelectedIndex(createIndex)}
/>
@@ -358,11 +363,12 @@ function useInlineAddNoteState(
}
}
function InlineAddNote({ entries, vaultPath, onAdd, onCreateAndOpenNote }: {
function InlineAddNote({ entries, vaultPath, locale, onAdd, onCreateAndOpenNote }: {
entries: VaultEntry[]
vaultPath: string
onAdd: (ref: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
locale: AppLocale
}) {
const {
active,
@@ -386,7 +392,7 @@ function InlineAddNote({ entries, vaultPath, onAdd, onCreateAndOpenNote }: {
onClick={() => setActive(true)}
data-testid="add-relation-ref"
>
Add
{translate(locale, 'inspector.relationship.add')}
</button>
)
}
@@ -399,7 +405,7 @@ function InlineAddNote({ entries, vaultPath, onAdd, onCreateAndOpenNote }: {
autoFocus
className="w-full border border-border bg-transparent text-foreground"
style={{ borderRadius: 6, outline: 'none', minWidth: 0, padding: '6px 10px', fontSize: 12 }}
placeholder="Note title"
placeholder={translate(locale, 'inspector.relationship.noteTitle')}
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
@@ -419,22 +425,24 @@ function InlineAddNote({ entries, vaultPath, onAdd, onCreateAndOpenNote }: {
query={query}
entries={entries}
onCreateAndOpen={onCreateAndOpenNote ? (title) => { handleCreateAndOpen(title) } : undefined}
locale={locale}
/>
)}
</div>
)
}
function RelationshipGroup({ label, refs, entries, typeEntryMap, vaultPath, onNavigate, onRemoveRef, onAddRef, onCreateAndOpenNote }: {
function RelationshipGroup({ label, refs, entries, typeEntryMap, vaultPath, locale, onNavigate, onRemoveRef, onAddRef, onCreateAndOpenNote }: {
label: string; refs: string[]; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>; vaultPath: string
onNavigate: (target: string) => void
onRemoveRef?: (ref: string) => void
onAddRef?: (ref: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
locale: AppLocale
}) {
if (refs.length === 0) return null
return (
<RelationshipSectionRow label={label}>
<RelationshipSectionRow label={label} locale={locale}>
<div className="flex flex-col gap-1">
{refs.map((ref, idx) => {
const props = resolveRefProps(ref, entries, typeEntryMap)
@@ -454,6 +462,7 @@ function RelationshipGroup({ label, refs, entries, typeEntryMap, vaultPath, onNa
vaultPath={vaultPath}
onAdd={onAddRef}
onCreateAndOpenNote={onCreateAndOpenNote}
locale={locale}
/>
)}
</RelationshipSectionRow>
@@ -472,7 +481,7 @@ function extractRelationshipRefs(frontmatter: ParsedFrontmatter): { key: string;
.filter(({ refs }) => refs.length > 0)
}
function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel, onCreateAndOpenNote, onSubmitWithCreate }: {
function NoteTargetInput({ entries, value, locale, onChange, onSubmit, onCancel, onCreateAndOpenNote, onSubmitWithCreate }: {
entries: VaultEntry[]
value: string
onChange: (v: string) => void
@@ -480,6 +489,7 @@ function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel, onCreat
onCancel?: () => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
onSubmitWithCreate?: (title: string) => void
locale: AppLocale
}) {
const [focused, setFocused] = useState(false)
const search = useNoteSearch(entries, value, 8)
@@ -527,7 +537,7 @@ function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel, onCreat
<input
className="w-full border border-border bg-transparent px-2 py-1 text-xs text-foreground"
style={{ borderRadius: 4, outline: 'none' }}
placeholder="Note title"
placeholder={translate(locale, 'inspector.relationship.noteTitle')}
value={value}
onChange={e => onChange(e.target.value)}
onFocus={() => setFocused(true)}
@@ -541,6 +551,7 @@ function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel, onCreat
query={value}
entries={entries}
onCreateAndOpen={onCreateAndOpenNote ? (title) => onSubmitWithCreate?.(title) : undefined}
locale={locale}
/>
)}
</div>
@@ -621,11 +632,12 @@ function useRelationshipPanelState({
}
}
function AddRelationshipForm({ entries, vaultPath, onAddProperty, onCreateAndOpenNote }: {
function AddRelationshipForm({ entries, vaultPath, locale, onAddProperty, onCreateAndOpenNote }: {
entries: VaultEntry[]
vaultPath: string
onAddProperty: (key: string, value: FrontmatterValue) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
locale: AppLocale
}) {
const [relKey, setRelKey] = useState('')
const [relTarget, setRelTarget] = useState('')
@@ -653,7 +665,7 @@ function AddRelationshipForm({ entries, vaultPath, onAddProperty, onCreateAndOpe
if (!showForm) {
return (
<button className="mt-2 w-full border border-border bg-transparent text-center text-muted-foreground" style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12, cursor: 'pointer' }} onClick={() => { setShowForm(true); setTimeout(() => keyInputRef.current?.focus(), 0) }}>+ Add relationship</button>
<button className="mt-2 w-full border border-border bg-transparent text-center text-muted-foreground" style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12, cursor: 'pointer' }} onClick={() => { setShowForm(true); setTimeout(() => keyInputRef.current?.focus(), 0) }}>{translate(locale, 'inspector.relationship.addRelationship')}</button>
)
}
@@ -664,7 +676,7 @@ function AddRelationshipForm({ entries, vaultPath, onAddProperty, onCreateAndOpe
autoFocus
className="w-full border border-border bg-transparent px-2 py-1 text-xs text-foreground"
style={{ borderRadius: 4, outline: 'none' }}
placeholder="Relationship name"
placeholder={translate(locale, 'inspector.relationship.name')}
value={relKey}
onChange={e => setRelKey(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') submitForm(); else if (e.key === 'Escape') resetForm() }}
@@ -677,10 +689,11 @@ function AddRelationshipForm({ entries, vaultPath, onAddProperty, onCreateAndOpe
onCancel={resetForm}
onCreateAndOpenNote={onCreateAndOpenNote}
onSubmitWithCreate={handleCreateAndSubmit}
locale={locale}
/>
<div className="flex gap-1.5">
<button className="flex-1 border border-border bg-transparent text-xs text-foreground" style={{ borderRadius: 4, padding: '4px 0' }} onClick={() => submitForm()} disabled={!relKey.trim() || !relTarget.trim()} data-testid="submit-add-relationship">Add</button>
<button className="border border-border bg-transparent text-xs text-muted-foreground" style={{ borderRadius: 4, padding: '4px 8px' }} onClick={resetForm}>Cancel</button>
<button className="flex-1 border border-border bg-transparent text-xs text-foreground" style={{ borderRadius: 4, padding: '4px 0' }} onClick={() => submitForm()} disabled={!relKey.trim() || !relTarget.trim()} data-testid="submit-add-relationship">{translate(locale, 'inspector.relationship.add')}</button>
<button className="border border-border bg-transparent text-xs text-muted-foreground" style={{ borderRadius: 4, padding: '4px 8px' }} onClick={resetForm}>{translate(locale, 'common.cancel')}</button>
</div>
</div>
)
@@ -698,38 +711,41 @@ function updateRefsForAddition(refs: string[], refToAdd: string): FrontmatterVal
return updated.length === 1 ? updated[0] : updated
}
function DisabledLinkButton() {
function DisabledLinkButton({ locale }: { locale: AppLocale }) {
return (
<button className="mt-2 w-full border border-border bg-transparent text-center text-muted-foreground" style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12, opacity: 0.5, cursor: 'not-allowed' }} disabled>+ Add relationship</button>
<button className="mt-2 w-full border border-border bg-transparent text-center text-muted-foreground" style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12, opacity: 0.5, cursor: 'not-allowed' }} disabled>{translate(locale, 'inspector.relationship.addRelationship')}</button>
)
}
function SuggestedRelationshipSlot({ label, entries, vaultPath, onAdd, onCreateAndOpenNote }: {
function SuggestedRelationshipSlot({ label, entries, vaultPath, locale, onAdd, onCreateAndOpenNote }: {
label: string
entries: VaultEntry[]
vaultPath: string
onAdd: (ref: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
locale: AppLocale
}) {
return (
<RelationshipSectionRow label={label} dataTestId="suggested-relationship">
<RelationshipSectionRow label={label} dataTestId="suggested-relationship" locale={locale}>
<InlineAddNote
entries={entries}
vaultPath={vaultPath}
onAdd={onAdd}
onCreateAndOpenNote={onCreateAndOpenNote}
locale={locale}
/>
</RelationshipSectionRow>
)
}
export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, vaultPath, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty, onCreateAndOpenNote }: {
export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, vaultPath, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty, onCreateAndOpenNote, locale = 'en' }: {
frontmatter: ParsedFrontmatter; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>; vaultPath?: string
onNavigate: (target: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
onDeleteProperty?: (key: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
locale?: AppLocale
}) {
const {
relationshipEntries,
@@ -755,6 +771,7 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
onRemoveRef={canEdit ? (ref) => handleRemoveRef(key, ref) : undefined}
onAddRef={canEdit ? (ref) => handleAddRef(key, ref) : undefined}
onCreateAndOpenNote={canEdit ? onCreateAndOpenNote : undefined}
locale={locale}
/>
))}
{missingSuggestedRels.map(label => (
@@ -765,12 +782,13 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
vaultPath={resolvedVaultPath}
onAdd={(ref) => onAddProperty!(label, ref)}
onCreateAndOpenNote={onCreateAndOpenNote}
locale={locale}
/>
))}
<RelationshipActionRow>
{onAddProperty
? <AddRelationshipForm entries={entries} vaultPath={resolvedVaultPath} onAddProperty={onAddProperty} onCreateAndOpenNote={onCreateAndOpenNote} />
: <DisabledLinkButton />
? <AddRelationshipForm entries={entries} vaultPath={resolvedVaultPath} onAddProperty={onAddProperty} onCreateAndOpenNote={onCreateAndOpenNote} locale={locale} />
: <DisabledLinkButton locale={locale} />
}
</RelationshipActionRow>
</div>

View File

@@ -1,4 +1,5 @@
import { memo } from 'react'
import { translate, type AppLocale, type TranslationKey } from '../../lib/i18n'
import type { NoteListFilter } from '../../utils/noteListHelpers'
interface FilterPillsProps {
@@ -6,16 +7,17 @@ interface FilterPillsProps {
counts: Record<NoteListFilter, number>
onChange: (filter: NoteListFilter) => void
position?: 'top' | 'bottom'
locale?: AppLocale
}
const PILLS: { value: NoteListFilter; label: string }[] = [
{ value: 'open', label: 'Open' },
{ value: 'archived', label: 'Archived' },
const PILLS: { value: NoteListFilter; labelKey: TranslationKey }[] = [
{ value: 'open', labelKey: 'noteList.filter.open' },
{ value: 'archived', labelKey: 'noteList.filter.archived' },
]
const BOTTOM_GRADIENT = 'linear-gradient(to bottom, transparent 0%, var(--card) 30%, var(--card) 100%)'
function FilterPillsInner({ active, counts, onChange, position = 'top' }: FilterPillsProps) {
function FilterPillsInner({ active, counts, onChange, position = 'top', locale = 'en' }: FilterPillsProps) {
const isBottom = position === 'bottom'
return (
<div
@@ -25,7 +27,7 @@ function FilterPillsInner({ active, counts, onChange, position = 'top' }: Filter
style={isBottom ? { background: BOTTOM_GRADIENT } : undefined}
data-testid="filter-pills"
>
{PILLS.map(({ value, label }) => (
{PILLS.map(({ value, labelKey }) => (
<button
key={value}
type="button"
@@ -39,7 +41,7 @@ function FilterPillsInner({ active, counts, onChange, position = 'top' }: Filter
onClick={() => onChange(value)}
data-testid={`filter-pill-${value}`}
>
{label}
{translate(locale, labelKey)}
<span className={`text-[10px] tabular-nums ${active === value ? 'text-foreground/70' : 'text-muted-foreground/70'}`}>
{counts[value]}
</span>

View File

@@ -1,4 +1,5 @@
import { memo } from 'react'
import { translate, type AppLocale, type TranslationKey } from '../../lib/i18n'
import type { InboxPeriod } from '../../types'
interface InboxFilterPillsProps {
@@ -6,17 +7,18 @@ interface InboxFilterPillsProps {
counts: Record<InboxPeriod, number>
onChange: (period: InboxPeriod) => void
position?: 'top' | 'bottom'
locale?: AppLocale
}
const PILLS: { value: InboxPeriod; label: string }[] = [
{ value: 'week', label: 'Week' },
{ value: 'month', label: 'Month' },
{ value: 'all', label: 'All' },
const PILLS: { value: InboxPeriod; labelKey: TranslationKey }[] = [
{ value: 'week', labelKey: 'noteList.filter.week' },
{ value: 'month', labelKey: 'noteList.filter.month' },
{ value: 'all', labelKey: 'noteList.filter.all' },
]
const BOTTOM_GRADIENT = 'linear-gradient(to bottom, transparent 0%, var(--card) 30%, var(--card) 100%)'
function InboxFilterPillsInner({ active, counts, onChange, position = 'top' }: InboxFilterPillsProps) {
function InboxFilterPillsInner({ active, counts, onChange, position = 'top', locale = 'en' }: InboxFilterPillsProps) {
const isBottom = position === 'bottom'
return (
<div
@@ -26,7 +28,7 @@ function InboxFilterPillsInner({ active, counts, onChange, position = 'top' }: I
style={isBottom ? { background: BOTTOM_GRADIENT } : undefined}
data-testid="inbox-filter-pills"
>
{PILLS.map(({ value, label }) => (
{PILLS.map(({ value, labelKey }) => (
<button
key={value}
type="button"
@@ -40,7 +42,7 @@ function InboxFilterPillsInner({ active, counts, onChange, position = 'top' }: I
onClick={() => onChange(value)}
data-testid={`inbox-pill-${value}`}
>
{label}
{translate(locale, labelKey)}
<span className={`text-[10px] tabular-nums ${active === value ? 'text-foreground/70' : 'text-muted-foreground/70'}`}>
{counts[value]}
</span>

View File

@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { translate, type AppLocale } from '../../lib/i18n'
import {
OPEN_NOTE_LIST_PROPERTIES_EVENT,
type NoteListPropertiesScope,
@@ -29,6 +30,7 @@ export interface ListPropertiesPopoverProps {
onSave: (value: NoteListPropertyKey[] | null) => void
triggerTitle: string
triggerClassName?: string
locale?: AppLocale
}
function propertyInputId(id: NoteListPropertyKey): string {
@@ -76,7 +78,7 @@ function reorderDisplayProperties(event: DragEndEvent, currentDisplay: NoteListP
return arrayMove(selected, oldIndex, newIndex)
}
function SortablePropertyItem({ id, checked, onToggle }: { id: NoteListPropertyKey; checked: boolean; onToggle: (key: NoteListPropertyKey) => void }) {
function SortablePropertyItem({ id, checked, locale = 'en', onToggle }: { id: NoteListPropertyKey; checked: boolean; locale?: AppLocale; onToggle: (key: NoteListPropertyKey) => void }) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id })
const style = { transform: CSS.Transform.toString(transform), transition }
const inputId = propertyInputId(id)
@@ -110,7 +112,7 @@ function SortablePropertyItem({ id, checked, onToggle }: { id: NoteListPropertyK
variant="ghost"
size="icon-xs"
className="shrink-0 cursor-grab text-muted-foreground active:cursor-grabbing"
aria-label={`Reorder ${id}`}
aria-label={translate(locale, 'noteList.properties.reorder', { name: id })}
{...dragAttributes}
{...listeners}
>
@@ -125,6 +127,7 @@ function ListPropertiesSearchInput({
query,
open,
listboxId,
locale = 'en',
onQueryChange,
onKeyDown,
}: {
@@ -132,6 +135,7 @@ function ListPropertiesSearchInput({
query: string
open: boolean
listboxId: string
locale?: AppLocale
onQueryChange: (value: string) => void
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void
}) {
@@ -142,12 +146,12 @@ function ListPropertiesSearchInput({
value={query}
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={onKeyDown}
placeholder="Search properties..."
placeholder={translate(locale, 'noteList.properties.searchPlaceholder')}
role="combobox"
aria-autocomplete="list"
aria-controls={listboxId}
aria-expanded={open}
aria-label="Search note-list properties"
aria-label={translate(locale, 'noteList.properties.searchLabel')}
className="h-8 text-[13px]"
data-testid="list-properties-combobox-input"
/>
@@ -160,6 +164,7 @@ function ListPropertiesOptionsList({
filteredItems,
selectedSet,
sensors,
locale = 'en',
onDragEnd,
onToggle,
}: {
@@ -167,6 +172,7 @@ function ListPropertiesOptionsList({
filteredItems: NoteListPropertyKey[]
selectedSet: Set<NoteListPropertyKey>
sensors: ReturnType<typeof useSensors>
locale?: AppLocale
onDragEnd: (event: DragEndEvent) => void
onToggle: (key: string) => void
}) {
@@ -174,7 +180,7 @@ function ListPropertiesOptionsList({
<div className="max-h-60 overflow-y-auto" data-testid="list-properties-scroll-area">
{filteredItems.length === 0 ? (
<div className="px-1 py-2 text-[12px] text-muted-foreground">
No properties match this search.
{translate(locale, 'noteList.properties.noMatches')}
</div>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
@@ -185,6 +191,7 @@ function ListPropertiesOptionsList({
key={key}
id={key}
checked={selectedSet.has(key)}
locale={locale}
onToggle={onToggle}
/>
))}
@@ -204,6 +211,7 @@ function ListPropertiesPopoverPanel({
filteredItems,
selectedSet,
sensors,
locale = 'en',
onQueryChange,
onSearchKeyDown,
onPanelKeyDown,
@@ -217,6 +225,7 @@ function ListPropertiesPopoverPanel({
filteredItems: NoteListPropertyKey[]
selectedSet: Set<NoteListPropertyKey>
sensors: ReturnType<typeof useSensors>
locale?: AppLocale
onQueryChange: (value: string) => void
onSearchKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void
onPanelKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void
@@ -232,13 +241,14 @@ function ListPropertiesPopoverPanel({
>
<div onKeyDownCapture={onPanelKeyDown}>
<div className="mb-2 px-1 text-[11px] font-medium text-muted-foreground">
Show in note list
{translate(locale, 'noteList.properties.showInNoteList')}
</div>
<ListPropertiesSearchInput
inputRef={inputRef}
query={query}
open={open}
listboxId={listboxId}
locale={locale}
onQueryChange={onQueryChange}
onKeyDown={onSearchKeyDown}
/>
@@ -247,6 +257,7 @@ function ListPropertiesPopoverPanel({
filteredItems={filteredItems}
selectedSet={selectedSet}
sensors={sensors}
locale={locale}
onDragEnd={onDragEnd}
onToggle={onToggle}
/>
@@ -355,6 +366,7 @@ export function ListPropertiesPopover({
onSave,
triggerTitle,
triggerClassName,
locale = 'en',
}: ListPropertiesPopoverProps) {
const {
open,
@@ -395,6 +407,7 @@ export function ListPropertiesPopover({
filteredItems={filteredItems}
selectedSet={selectedSet}
sensors={sensors}
locale={locale}
onQueryChange={setQuery}
onSearchKeyDown={handleSearchKeyDown}
onPanelKeyDown={handlePanelKeyDown}

View File

@@ -6,45 +6,57 @@ import {
type MouseEvent as ReactMouseEvent,
} from 'react'
import type { ModifiedFile, VaultEntry } from '../../types'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import type { AppLocale } from '../../lib/i18n'
import { ChangeConfirmDialog, ChangesContextMenuNode } from './NoteListChangesMenuView'
interface ChangesContextMenuParams {
isChangesView: boolean
onDiscardFile?: (relativePath: string) => Promise<void>
modifiedFiles?: ModifiedFile[]
locale?: AppLocale
}
type ChangeAction = 'discard' | 'restore'
export type ChangeActionTarget = {
entry: VaultEntry
action: ChangeAction
relativePath: string
}
export type ChangesContextMenuState = {
x: number
y: number
entry: VaultEntry
}
function resolveChangeActionTarget(
entry: VaultEntry,
modifiedFiles?: ModifiedFile[],
): ChangeActionTarget | null {
const file = modifiedFiles?.find(
(modified) => modified.path === entry.path || entry.path.endsWith('/' + modified.relativePath),
)
if (!file) return null
return {
entry,
action: file.status === 'deleted' ? 'restore' : 'discard',
relativePath: file.relativePath,
}
}
export function useChangesContextMenu({
isChangesView,
onDiscardFile,
modifiedFiles,
locale = 'en',
}: ChangesContextMenuParams) {
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; entry: VaultEntry } | null>(null)
const [actionTarget, setActionTarget] = useState<{
entry: VaultEntry
action: 'discard' | 'restore'
relativePath: string
} | null>(null)
const [ctxMenu, setCtxMenu] = useState<ChangesContextMenuState | null>(null)
const [actionTarget, setActionTarget] = useState<ChangeActionTarget | null>(null)
const ctxMenuRef = useRef<HTMLDivElement>(null)
const resolveActionTarget = useCallback((entry: VaultEntry) => {
const file = modifiedFiles?.find(
(modified) => modified.path === entry.path || entry.path.endsWith('/' + modified.relativePath),
)
if (!file) return null
return {
entry,
action: file.status === 'deleted' ? 'restore' as const : 'discard' as const,
relativePath: file.relativePath,
}
return resolveChangeActionTarget(entry, modifiedFiles)
}, [modifiedFiles])
const openContextMenuForEntry = useCallback((entry: VaultEntry, point: { x: number; y: number }) => {
@@ -76,56 +88,30 @@ export function useChangesContextMenu({
}, [actionTarget, onDiscardFile])
const menuActionTarget = ctxMenu ? resolveActionTarget(ctxMenu.entry) : null
const menuActionLabel = menuActionTarget?.action === 'restore' ? 'Restore note' : 'Discard changes'
const contextMenuNode = ctxMenu ? (
<div
ref={ctxMenuRef}
className="fixed z-50 rounded-md border bg-popover p-1 shadow-md"
style={{ left: ctxMenu.x, top: ctxMenu.y, minWidth: 180 }}
data-testid="changes-context-menu"
>
<button
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-default hover:bg-accent hover:text-accent-foreground transition-colors border-none bg-transparent text-left text-destructive"
onClick={() => {
if (!menuActionTarget) return
setActionTarget(menuActionTarget)
closeCtxMenu()
}}
data-testid={menuActionTarget?.action === 'restore' ? 'restore-note-button' : 'discard-changes-button'}
>
{menuActionLabel}
</button>
</div>
) : null
const selectMenuAction = () => {
if (!menuActionTarget) return
setActionTarget(menuActionTarget)
closeCtxMenu()
}
const contextMenuNode = (
<ChangesContextMenuNode
ctxMenu={ctxMenu}
ctxMenuRef={ctxMenuRef}
actionTarget={menuActionTarget}
locale={locale}
onSelect={selectMenuAction}
/>
)
const dialogNode = (
<Dialog open={!!actionTarget} onOpenChange={(open) => { if (!open) setActionTarget(null) }}>
<DialogContent
showCloseButton={false}
data-testid={actionTarget?.action === 'restore' ? 'restore-confirm-dialog' : 'discard-confirm-dialog'}
>
<DialogHeader>
<DialogTitle>{actionTarget?.action === 'restore' ? 'Restore note' : 'Discard changes'}</DialogTitle>
<DialogDescription>
{actionTarget?.action === 'restore'
? <>Restore <strong>{actionTarget?.entry.filename ?? 'this file'}</strong> from Git?</>
: <>Discard changes to <strong>{actionTarget?.entry.title ?? 'this file'}</strong>? This cannot be undone.</>
}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setActionTarget(null)}>Cancel</Button>
<Button
variant={actionTarget?.action === 'restore' ? 'default' : 'destructive'}
onClick={handleChangeConfirm}
data-testid={actionTarget?.action === 'restore' ? 'restore-confirm-button' : 'discard-confirm-button'}
>
{actionTarget?.action === 'restore' ? 'Restore' : 'Discard'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ChangeConfirmDialog
actionTarget={actionTarget}
locale={locale}
onCancel={() => setActionTarget(null)}
onConfirm={handleChangeConfirm}
/>
)
return {

View File

@@ -0,0 +1,105 @@
import type { RefObject } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { translate, type AppLocale } from '../../lib/i18n'
import type { ChangeActionTarget, ChangesContextMenuState } from './NoteListChangesMenu'
function changeActionLabel(locale: AppLocale, action: ChangeActionTarget['action']): string {
return translate(locale, action === 'restore' ? 'noteList.changes.restoreNote' : 'noteList.changes.discardChanges')
}
function changeConfirmLabel(locale: AppLocale, action: ChangeActionTarget['action']): string {
return translate(locale, action === 'restore' ? 'noteList.changes.restore' : 'noteList.changes.discard')
}
function changeDialogDescription(locale: AppLocale, target: ChangeActionTarget): string {
const file = target.action === 'restore'
? target.entry.filename
: target.entry.title
const key = target.action === 'restore'
? 'noteList.changes.restoreDescription'
: 'noteList.changes.discardDescription'
return translate(locale, key, { file: file ?? translate(locale, 'noteList.changes.thisFile') })
}
export function ChangesContextMenuNode({
ctxMenu,
ctxMenuRef,
actionTarget,
locale,
onSelect,
}: {
ctxMenu: ChangesContextMenuState | null
ctxMenuRef: RefObject<HTMLDivElement | null>
actionTarget: ChangeActionTarget | null
locale: AppLocale
onSelect: () => void
}) {
if (!ctxMenu) return null
return (
<div
ref={ctxMenuRef}
className="fixed z-50 rounded-md border bg-popover p-1 shadow-md"
style={{ left: ctxMenu.x, top: ctxMenu.y, minWidth: 180 }}
data-testid="changes-context-menu"
>
<Button
type="button"
variant="ghost"
className="flex h-auto w-full cursor-default items-center justify-start gap-2 rounded-sm px-2 py-1.5 text-left text-sm text-destructive transition-colors hover:bg-accent hover:text-accent-foreground"
onClick={onSelect}
data-testid={actionTarget?.action === 'restore' ? 'restore-note-button' : 'discard-changes-button'}
>
{changeActionLabel(locale, actionTarget?.action ?? 'discard')}
</Button>
</div>
)
}
export function ChangeConfirmDialog({
actionTarget,
locale,
onCancel,
onConfirm,
}: {
actionTarget: ChangeActionTarget | null
locale: AppLocale
onCancel: () => void
onConfirm: () => void
}) {
return (
<Dialog open={!!actionTarget} onOpenChange={(open) => { if (!open) onCancel() }}>
<DialogContent
showCloseButton={false}
data-testid={actionTarget?.action === 'restore' ? 'restore-confirm-dialog' : 'discard-confirm-dialog'}
>
{actionTarget && (
<>
<DialogHeader>
<DialogTitle>{changeActionLabel(locale, actionTarget.action)}</DialogTitle>
<DialogDescription>{changeDialogDescription(locale, actionTarget)}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>{translate(locale, 'noteList.changes.cancel')}</Button>
<Button
variant={actionTarget.action === 'restore' ? 'default' : 'destructive'}
onClick={onConfirm}
data-testid={actionTarget.action === 'restore' ? 'restore-confirm-button' : 'discard-confirm-button'}
>
{changeConfirmLabel(locale, actionTarget.action)}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -11,6 +11,17 @@ 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'
function localizePropertiesTriggerTitle(triggerTitle: string, locale: AppLocale): string {
if (triggerTitle === 'Customize columns') return translate(locale, 'noteList.properties.customizeColumns')
if (triggerTitle === 'Customize All Notes columns') return translate(locale, 'noteList.properties.customizeAllColumns')
if (triggerTitle === 'Customize Inbox columns') return translate(locale, 'noteList.properties.customizeInboxColumns')
const viewMatch = triggerTitle.match(/^Customize (.+) columns$/)
return viewMatch
? translate(locale, 'noteList.properties.customizeViewColumns', { name: viewMatch[1] })
: triggerTitle
}
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
@@ -45,11 +56,18 @@ export function NoteListHeader({ title, typeDocument, isEntityView, listSort, li
{title}
</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} />}
{!isEntityView && <SortDropdown groupLabel="__list__" current={listSort} direction={listDirection} customProperties={customProperties} locale={locale} onChange={onSortChange} />}
<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} />}
{propertyPicker && (
<ListPropertiesPopover
{...propertyPicker}
triggerTitle={localizePropertiesTriggerTitle(propertyPicker.triggerTitle, locale)}
triggerClassName={NOTE_LIST_ACTION_BUTTON_CLASSNAME}
locale={locale}
/>
)}
<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>

View File

@@ -183,6 +183,7 @@ function NoteListBody({
counts={filterCounts}
onChange={onNoteListFilterChange}
position="bottom"
locale={locale}
/>
)}
</div>

View File

@@ -41,7 +41,7 @@ export function EntityView({ entity, groups, query, collapsedGroups, sortPrefs,
{groups.length === 0
? <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} />
<RelationshipGroupSection key={group.label} group={group} isCollapsed={collapsedGroups.has(group.label)} sortPrefs={sortPrefs} locale={locale} onToggle={() => onToggleGroup(group.label)} handleSortChange={onSortChange} renderItem={renderItem} />
))
}
</div>

View File

@@ -8,11 +8,13 @@ import {
import { humanizePropertyKey } from '../../utils/propertyLabels'
import { SortDropdown } from '../SortDropdown'
import { Button } from '../ui/button'
import type { AppLocale } from '../../lib/i18n'
export function RelationshipGroupSection({ group, isCollapsed, sortPrefs, onToggle, handleSortChange, renderItem }: {
export function RelationshipGroupSection({ group, isCollapsed, sortPrefs, locale = 'en', onToggle, handleSortChange, renderItem }: {
group: RelationshipGroup
isCollapsed: boolean
sortPrefs: Record<string, SortConfig>
locale?: AppLocale
onToggle: () => void
handleSortChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
renderItem: (entry: VaultEntry, options?: { forceSelected?: boolean }) => React.ReactNode
@@ -38,7 +40,7 @@ export function RelationshipGroupSection({ group, isCollapsed, sortPrefs, onTogg
<span className="font-mono-label text-muted-foreground" style={{ fontWeight: 400 }}>{group.entries.length}</span>
</Button>
<span className="flex items-center gap-1.5">
<SortDropdown groupLabel={group.label} current={groupConfig.option} direction={groupConfig.direction} customProperties={customProperties} onChange={handleSortChange} />
<SortDropdown groupLabel={group.label} current={groupConfig.option} direction={groupConfig.direction} customProperties={customProperties} locale={locale} onChange={handleSortChange} />
</span>
</div>
{!isCollapsed && (

View File

@@ -244,6 +244,7 @@ interface UseNoteListInteractionStateParams {
onCreateNote: (type?: string) => void
onBulkArchive?: (paths: string[]) => void
onBulkDeletePermanently?: (paths: string[]) => void
locale: AppLocale
}
function useNoteListInteractionState({
@@ -267,8 +268,9 @@ function useNoteListInteractionState({
onCreateNote,
onBulkArchive,
onBulkDeletePermanently,
locale,
}: UseNoteListInteractionStateParams) {
const changesContextMenu = useChangesContextMenu({ isChangesView, onDiscardFile, modifiedFiles })
const changesContextMenu = useChangesContextMenu({ isChangesView, onDiscardFile, modifiedFiles, locale })
const {
collapsedGroups,
handleClickNote,
@@ -581,6 +583,7 @@ export function useNoteListModel({
onCreateNote,
onBulkArchive,
onBulkDeletePermanently,
locale,
})
const renderItem = useRenderItem({
entries,

View File

@@ -12,6 +12,7 @@ import { NoteTitleIcon } from '../NoteTitleIcon'
import { isSelectionActive } from '../SidebarParts'
import { SidebarGroupHeader } from './SidebarGroupHeader'
import { SIDEBAR_ITEM_PADDING } from './sidebarStyles'
import { translate, type AppLocale } from '../../lib/i18n'
const FAVORITE_TYPE_ICON_MAP: Record<string, string> = {
Project: 'wrench',
@@ -99,6 +100,7 @@ interface FavoritesSectionProps {
onSelectNote?: (entry: VaultEntry) => void
onReorder?: (orderedPaths: string[]) => void
collapsed: boolean
locale?: AppLocale
onToggle: () => void
}
@@ -109,6 +111,7 @@ export function FavoritesSection({
onSelectNote,
onReorder,
collapsed,
locale = 'en',
onToggle,
}: FavoritesSectionProps) {
const favorites = useMemo(() => sortFavorites(entries), [entries])
@@ -134,7 +137,7 @@ export function FavoritesSection({
return (
<div style={{ padding: '0 6px' }}>
<SidebarGroupHeader label="FAVORITES" collapsed={collapsed} onToggle={onToggle} count={favorites.length} />
<SidebarGroupHeader label={translate(locale, 'sidebar.group.favorites')} collapsed={collapsed} onToggle={onToggle} count={favorites.length} />
{!collapsed && (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={favoriteIds} strategy={verticalListSortingStrategy}>

View File

@@ -24,6 +24,7 @@ import { useNoteRetargetingContext } from '../note-retargeting/noteRetargetingCo
import { SidebarGroupHeader } from './SidebarGroupHeader'
import { SidebarViewItem } from './SidebarViewItem'
import { countByFilter } from '../../utils/noteListHelpers'
import { translate, type AppLocale } from '../../lib/i18n'
export { SidebarTopNav } from './SidebarTopNav'
export { FavoritesSection } from './FavoritesSection'
@@ -37,6 +38,7 @@ export interface SidebarSectionProps {
renameInitialValue: string
onRenameSubmit: (value: string) => void
onRenameCancel: () => void
locale?: AppLocale
}
export function ViewsSection({
@@ -49,6 +51,7 @@ export function ViewsSection({
onEditView,
onDeleteView,
entries,
locale = 'en',
}: {
views: ViewFile[]
selection: SidebarSelection
@@ -59,14 +62,17 @@ export function ViewsSection({
onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void
entries: VaultEntry[]
locale?: AppLocale
}) {
return (
<div className="border-b border-border" style={{ padding: '0 6px' }}>
<SidebarGroupHeader label="VIEWS" collapsed={collapsed} onToggle={onToggle}>
<SidebarGroupHeader label={translate(locale, 'sidebar.group.views')} collapsed={collapsed} onToggle={onToggle}>
{onCreateView && (
<Plus
size={12}
className="text-muted-foreground hover:text-foreground"
aria-label={translate(locale, 'sidebar.action.createView')}
title={translate(locale, 'sidebar.action.createView')}
onClick={(event) => { event.stopPropagation(); onCreateView() }}
/>
)}
@@ -82,6 +88,7 @@ export function ViewsSection({
onEditView={onEditView}
onDeleteView={onDeleteView}
entries={entries}
locale={locale}
/>
))}
</div>
@@ -113,6 +120,7 @@ function SortableSection({
renameInitialValue={isRenaming ? sectionProps.renameInitialValue : undefined}
onRenameSubmit={sectionProps.onRenameSubmit}
onRenameCancel={sectionProps.onRenameCancel}
locale={sectionProps.locale}
/>
)
@@ -154,6 +162,7 @@ export function TypesSection({
toggleVisibility,
onCreateNewType,
customizeRef,
locale = 'en',
}: {
visibleSections: SectionGroup[]
allSectionGroups: SectionGroup[]
@@ -169,18 +178,19 @@ export function TypesSection({
toggleVisibility: (type: string) => void
onCreateNewType?: () => void
customizeRef: RefObject<HTMLDivElement | null>
locale?: AppLocale
}) {
return (
<div className="border-b border-border">
<div ref={customizeRef} style={{ position: 'relative', padding: '0 6px' }}>
<SidebarGroupHeader label="TYPES" collapsed={collapsed} onToggle={onToggle}>
<SidebarGroupHeader label={translate(locale, 'sidebar.group.types')} collapsed={collapsed} onToggle={onToggle}>
<div className="flex items-center gap-1.5">
<Button
type="button"
variant="ghost"
size="icon-xs"
title="Customize sections"
aria-label="Customize sections"
title={translate(locale, 'sidebar.action.customizeSections')}
aria-label={translate(locale, 'sidebar.action.customizeSections')}
className="h-auto w-auto min-w-0 rounded-none p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
onClick={(event) => { event.stopPropagation(); setShowCustomize((value) => !value) }}
>
@@ -193,8 +203,8 @@ export function TypesSection({
size="icon-xs"
className="h-auto w-auto min-w-0 rounded-none p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
data-testid="create-type-btn"
title="Create new type"
aria-label="Create new type"
title={translate(locale, 'sidebar.action.createType')}
aria-label={translate(locale, 'sidebar.action.createType')}
onClick={(event) => { event.stopPropagation(); onCreateNewType() }}
>
<Plus size={12} className="text-muted-foreground hover:text-foreground" />
@@ -207,6 +217,7 @@ export function TypesSection({
sections={allSectionGroups}
isSectionVisible={isSectionVisible}
onToggle={toggleVisibility}
locale={locale}
/>
)}
</div>
@@ -223,7 +234,7 @@ export function TypesSection({
)
}
export function SidebarTitleBar({ onCollapse }: { onCollapse?: () => void }) {
export function SidebarTitleBar({ locale = 'en', onCollapse }: { locale?: AppLocale; onCollapse?: () => void }) {
const { onMouseDown } = useDragRegion()
return (
@@ -237,8 +248,8 @@ export function SidebarTitleBar({ onCollapse }: { onCollapse?: () => void }) {
className="flex shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
style={{ width: 24, height: 24 }}
onClick={onCollapse}
aria-label="Collapse sidebar"
title="Collapse sidebar"
aria-label={translate(locale, 'sidebar.action.collapse')}
title={translate(locale, 'sidebar.action.collapse')}
>
<CaretLeft size={14} weight="bold" />
</button>
@@ -253,12 +264,14 @@ export function ContextMenuOverlay({
innerRef,
onOpenCustomize,
onStartRename,
locale = 'en',
}: {
pos: { x: number; y: number } | null
type: string | null
innerRef: Ref<HTMLDivElement>
onOpenCustomize: (type: string) => void
onStartRename: (type: string) => void
locale?: AppLocale
}) {
if (!pos || !type) return null
@@ -271,10 +284,10 @@ export function ContextMenuOverlay({
style={{ left: pos.x, top: pos.y, minWidth: 180 }}
>
<button className={buttonClass} onClick={() => onStartRename(type)}>
Rename section
{translate(locale, 'sidebar.action.renameSection')}
</button>
<button className={buttonClass} onClick={() => onOpenCustomize(type)}>
Customize icon &amp; color
{translate(locale, 'sidebar.action.customizeIconColor')}
</button>
</div>
)

View File

@@ -1,6 +1,7 @@
import { Archive, FileText, Tray } from '@phosphor-icons/react'
import type { SidebarSelection } from '../../types'
import { isSelectionActive, NavItem } from '../SidebarParts'
import { translate, type AppLocale } from '../../lib/i18n'
interface SidebarTopNavProps {
selection: SidebarSelection
@@ -9,6 +10,7 @@ interface SidebarTopNavProps {
inboxCount: number
activeCount: number
archivedCount: number
locale?: AppLocale
}
export function SidebarTopNav({
@@ -18,13 +20,14 @@ export function SidebarTopNav({
inboxCount,
activeCount,
archivedCount,
locale = 'en',
}: SidebarTopNavProps) {
return (
<div className="border-b border-border" data-testid="sidebar-top-nav" style={{ padding: '4px 6px' }}>
{showInbox && (
<NavItem
icon={Tray}
label="Inbox"
label={translate(locale, 'sidebar.nav.inbox')}
count={inboxCount}
isActive={isSelectionActive(selection, { kind: 'filter', filter: 'inbox' })}
badgeClassName="text-muted-foreground"
@@ -35,7 +38,7 @@ export function SidebarTopNav({
)}
<NavItem
icon={FileText}
label="All Notes"
label={translate(locale, 'sidebar.nav.allNotes')}
count={activeCount}
isActive={isSelectionActive(selection, { kind: 'filter', filter: 'all' })}
badgeClassName="text-muted-foreground"
@@ -45,7 +48,7 @@ export function SidebarTopNav({
/>
<NavItem
icon={Archive}
label="Archive"
label={translate(locale, 'sidebar.nav.archive')}
count={archivedCount}
isActive={isSelectionActive(selection, { kind: 'filter', filter: 'archived' })}
badgeClassName="text-muted-foreground"

View File

@@ -5,6 +5,7 @@ import { Funnel, PencilSimple, Trash } from '@phosphor-icons/react'
import { NoteTitleIcon } from '../NoteTitleIcon'
import { SidebarCountPill } from '../SidebarParts'
import { SIDEBAR_ITEM_PADDING } from './sidebarStyles'
import { translate, type AppLocale } from '../../lib/i18n'
interface SidebarViewItemProps {
view: ViewFile
@@ -13,6 +14,7 @@ interface SidebarViewItemProps {
onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void
entries: VaultEntry[]
locale?: AppLocale
}
export function SidebarViewItem({
@@ -22,6 +24,7 @@ export function SidebarViewItem({
onEditView,
onDeleteView,
entries,
locale = 'en',
}: SidebarViewItemProps) {
const count = useMemo(() => evaluateView(view.definition, entries).length, [view.definition, entries])
const showCount = count > 0
@@ -52,7 +55,8 @@ export function SidebarViewItem({
<button
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
onClick={(event) => { event.stopPropagation(); onEditView(view.filename) }}
title="Edit view"
title={translate(locale, 'sidebar.action.editView')}
aria-label={translate(locale, 'sidebar.action.editView')}
>
<PencilSimple size={12} />
</button>
@@ -61,7 +65,8 @@ export function SidebarViewItem({
<button
className="rounded p-0.5 text-muted-foreground hover:text-destructive"
onClick={(event) => { event.stopPropagation(); onDeleteView(view.filename) }}
title="Delete view"
title={translate(locale, 'sidebar.action.deleteView')}
aria-label={translate(locale, 'sidebar.action.deleteView')}
>
<Trash size={12} />
</button>

View File

@@ -19,6 +19,7 @@ import {
vaultAiGuidanceUsesCustomFiles,
type VaultAiGuidanceStatus,
} from '../../lib/vaultAiGuidance'
import { translate, type AppLocale } from '../../lib/i18n'
import { openExternalUrl } from '../../utils/url'
import {
DropdownMenu,
@@ -39,9 +40,11 @@ interface AiAgentsBadgeProps {
onSetDefaultAgent?: (agent: AiAgentId) => void
onRestoreGuidance?: () => void
compact?: boolean
locale?: AppLocale
}
function badgeTooltip(
locale: AppLocale,
statuses: AiAgentsStatus,
defaultAgent: AiAgentId,
guidanceStatus?: VaultAiGuidanceStatus,
@@ -49,19 +52,19 @@ function badgeTooltip(
const guidanceSummary = guidanceStatus && !isVaultAiGuidanceStatusChecking(guidanceStatus)
? getVaultAiGuidanceSummary(guidanceStatus)
: null
if (!hasAnyInstalledAiAgent(statuses)) return 'No AI agents detected — click for setup details'
if (!hasAnyInstalledAiAgent(statuses)) return translate(locale, 'status.ai.noAgentsTooltip')
const definition = getAiAgentDefinition(defaultAgent)
if (!isAiAgentInstalled(statuses, defaultAgent)) {
return `${definition.label} is selected but not installed — click for setup details`
return translate(locale, 'status.ai.selectedMissing', { agent: definition.label })
}
const version = statuses[defaultAgent].version
const base = `Default AI agent: ${definition.label}${version ? ` ${version}` : ''}`
const base = translate(locale, 'status.ai.defaultAgent', { agent: definition.label, version: version ? ` ${version}` : '' })
if (!guidanceSummary) return base
if (vaultAiGuidanceNeedsRestore(guidanceStatus!)) {
return `${base}. ${guidanceSummary} — click for restore details`
return translate(locale, 'status.ai.restoreDetails', { base, summary: guidanceSummary })
}
if (vaultAiGuidanceUsesCustomFiles(guidanceStatus!)) {
return `${base}. ${guidanceSummary}`
return translate(locale, 'status.ai.withGuidance', { base, summary: guidanceSummary })
}
return base
}
@@ -78,10 +81,11 @@ function triggerLabel(defaultAgent: AiAgentId): string {
return getAiAgentDefinition(defaultAgent).shortLabel
}
function menuHeading(defaultAgent: AiAgentId, selectedAgentReady: boolean): string {
function menuHeading(locale: AppLocale, defaultAgent: AiAgentId, selectedAgentReady: boolean): string {
const agent = getAiAgentDefinition(defaultAgent).label
return selectedAgentReady
? `Active AI agent: ${getAiAgentDefinition(defaultAgent).label}`
: `Selected AI agent unavailable: ${getAiAgentDefinition(defaultAgent).label}`
? translate(locale, 'status.ai.active', { agent })
: translate(locale, 'status.ai.unavailable', { agent })
}
function statusText(statuses: AiAgentsStatus, definition: AiAgentDefinition): string {
@@ -140,14 +144,15 @@ function TriggerStateIcon({
function GuidanceMenuSection({
guidanceStatus,
locale = 'en',
onRestoreGuidance,
}: Pick<AiAgentsBadgeProps, 'guidanceStatus' | 'onRestoreGuidance'>) {
}: Pick<AiAgentsBadgeProps, 'guidanceStatus' | 'locale' | 'onRestoreGuidance'>) {
if (!guidanceStatus || isVaultAiGuidanceStatusChecking(guidanceStatus)) return null
return (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>Vault guidance</DropdownMenuLabel>
<DropdownMenuLabel>{translate(locale, 'status.ai.vaultGuidance')}</DropdownMenuLabel>
<DropdownMenuItem disabled data-testid="status-ai-guidance-summary">
{getVaultAiGuidanceSummary(guidanceStatus)}
</DropdownMenuItem>
@@ -156,7 +161,7 @@ function GuidanceMenuSection({
onSelect={() => onRestoreGuidance?.()}
data-testid="status-ai-guidance-restore"
>
Restore Tolaria AI Guidance
{translate(locale, 'status.ai.restoreGuidance')}
</DropdownMenuItem>
)}
</>
@@ -170,6 +175,7 @@ function AgentMenuContent({
selectedAgentReady,
onSetDefaultAgent,
onRestoreGuidance,
locale = 'en',
}: AiAgentsBadgeProps & { selectedAgentReady: boolean }) {
const installedAgents = installedAgentDefinitions(statuses)
const missingAgents = missingAgentDefinitions(statuses)
@@ -181,9 +187,9 @@ function AgentMenuContent({
className="min-w-[18rem]"
data-testid="status-ai-agents-menu"
>
<DropdownMenuLabel>{menuHeading(defaultAgent, selectedAgentReady)}</DropdownMenuLabel>
<DropdownMenuLabel>{menuHeading(locale, defaultAgent, selectedAgentReady)}</DropdownMenuLabel>
{installedAgents.length === 0 ? (
<DropdownMenuItem disabled>No AI agents detected</DropdownMenuItem>
<DropdownMenuItem disabled>{translate(locale, 'status.ai.noAgents')}</DropdownMenuItem>
) : (
<DropdownMenuRadioGroup
value={selectedAgentReady ? defaultAgent : undefined}
@@ -202,19 +208,20 @@ function AgentMenuContent({
{missingAgents.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>Install</DropdownMenuLabel>
<DropdownMenuLabel>{translate(locale, 'status.ai.install')}</DropdownMenuLabel>
{missingAgents.map((definition) => (
<DropdownMenuItem
key={definition.id}
onSelect={() => void openExternalUrl(definition.installUrl)}
>
Install {definition.label}
{translate(locale, 'status.ai.installAgent', { agent: definition.label })}
</DropdownMenuItem>
))}
</>
)}
<GuidanceMenuSection
guidanceStatus={guidanceStatus}
locale={locale}
onRestoreGuidance={onRestoreGuidance}
/>
</DropdownMenuContent>
@@ -228,6 +235,7 @@ export function AiAgentsBadge({
onSetDefaultAgent,
onRestoreGuidance,
compact = false,
locale = 'en',
}: AiAgentsBadgeProps) {
const selectedAgentReady = isAiAgentInstalled(statuses, defaultAgent)
const showWarning = hasAiAgentWarning(statuses, defaultAgent, guidanceStatus)
@@ -239,14 +247,14 @@ export function AiAgentsBadge({
<>
<CompactSeparator compact={compact} />
<DropdownMenu>
<ActionTooltip copy={{ label: badgeTooltip(statuses, defaultAgent, guidanceStatus) }} side="top">
<ActionTooltip copy={{ label: badgeTooltip(locale, statuses, defaultAgent, guidanceStatus) }} side="top">
<DropdownMenuTrigger asChild={true}>
<Button
type="button"
variant="ghost"
size="xs"
className={triggerButtonClassName(compact)}
aria-label="Open AI agent options"
aria-label={translate(locale, 'status.ai.openOptions')}
data-testid="status-ai-agents"
>
<span style={{ ...ICON_STYLE, color: showWarning ? 'var(--accent-orange)' : 'var(--muted-foreground)' }}>
@@ -264,6 +272,7 @@ export function AiAgentsBadge({
onSetDefaultAgent={onSetDefaultAgent}
onRestoreGuidance={onRestoreGuidance}
selectedAgentReady={selectedAgentReady}
locale={locale}
/>
</DropdownMenu>
</>

View File

@@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import type { ClaudeCodeStatus } from '../../hooks/useClaudeCodeStatus'
import type { McpStatus } from '../../hooks/useMcpStatus'
import { translate, type AppLocale, type TranslationKey } from '../../lib/i18n'
import type { GitRemoteStatus, LastCommitInfo, SyncStatus } from '../../types'
import { openExternalUrl } from '../../utils/url'
import { useDismissibleLayer } from './useDismissibleLayer'
@@ -26,11 +27,11 @@ const SYNC_ICON_MAP: Record<string, typeof RefreshCw> = {
pull_required: ArrowDown,
}
const SYNC_LABELS: Record<string, string> = {
syncing: 'Syncing',
conflict: 'Conflict',
error: 'Sync failed',
pull_required: 'Pull required',
const SYNC_LABEL_KEYS: Partial<Record<SyncStatus, TranslationKey>> = {
syncing: 'status.sync.syncing',
conflict: 'status.sync.conflict',
error: 'status.sync.failed',
pull_required: 'status.sync.pullRequired',
}
const SYNC_COLORS: Record<string, string> = {
@@ -39,40 +40,43 @@ const SYNC_COLORS: Record<string, string> = {
pull_required: 'var(--accent-orange)',
}
const MCP_TOOLTIPS: Partial<Record<McpStatus, string>> = {
not_installed: 'External AI tools not connected — click to set up',
const MCP_TOOLTIP_KEYS: Partial<Record<McpStatus, TranslationKey>> = {
not_installed: 'status.mcp.notConnected',
}
const CLAUDE_INSTALL_URL = 'https://docs.anthropic.com/en/docs/claude-code'
function formatElapsedSync(lastSyncTime: number | null): string {
if (!lastSyncTime) return 'Not synced'
function formatElapsedSync(locale: AppLocale, lastSyncTime: number | null): string {
if (!lastSyncTime) return translate(locale, 'status.sync.notSynced')
const secs = Math.round((Date.now() - lastSyncTime) / 1000)
return secs < 60 ? 'Synced just now' : `Synced ${Math.floor(secs / 60)}m ago`
return secs < 60
? translate(locale, 'status.sync.justNow')
: translate(locale, 'status.sync.minutesAgo', { minutes: Math.floor(secs / 60) })
}
function formatSyncLabel(status: SyncStatus, lastSyncTime: number | null): string {
return SYNC_LABELS[status] ?? formatElapsedSync(lastSyncTime)
function formatSyncLabel(locale: AppLocale, status: SyncStatus, lastSyncTime: number | null): string {
const labelKey = SYNC_LABEL_KEYS[status]
return labelKey ? translate(locale, labelKey) : formatElapsedSync(locale, lastSyncTime)
}
function syncIconColor(status: SyncStatus): string {
return SYNC_COLORS[status] ?? 'var(--accent-green)'
}
function syncBadgeTooltipCopy(status: SyncStatus): ActionTooltipCopy {
if (status === 'conflict') return { label: 'Resolve merge conflicts' }
if (status === 'syncing') return { label: 'Sync in progress' }
if (status === 'pull_required') return { label: 'Pull from remote and push' }
if (status === 'error') return { label: 'Retry sync' }
return { label: 'Sync now' }
function syncBadgeTooltipCopy(locale: AppLocale, status: SyncStatus): ActionTooltipCopy {
if (status === 'conflict') return { label: translate(locale, 'status.sync.resolveConflicts') }
if (status === 'syncing') return { label: translate(locale, 'status.sync.inProgress') }
if (status === 'pull_required') return { label: translate(locale, 'status.sync.pullAndPush') }
if (status === 'error') return { label: translate(locale, 'status.sync.retry') }
return { label: translate(locale, 'status.sync.now') }
}
function syncStatusText(status: SyncStatus): string {
if (status === 'idle') return 'Synced'
if (status === 'pull_required') return 'Pull required'
if (status === 'conflict') return 'Conflicts'
if (status === 'error') return 'Error'
if (status === 'syncing') return 'Syncing'
function syncStatusText(locale: AppLocale, status: SyncStatus): string {
if (status === 'idle') return translate(locale, 'status.sync.synced')
if (status === 'pull_required') return translate(locale, 'status.sync.pullRequired')
if (status === 'conflict') return translate(locale, 'status.sync.conflicts')
if (status === 'error') return translate(locale, 'status.sync.error')
if (status === 'syncing') return translate(locale, 'status.sync.syncing')
return status
}
@@ -84,31 +88,32 @@ function isRemoteMissing(remoteStatus: GitRemoteStatus | null | undefined): bool
return remoteStatus?.hasRemote === false
}
function commitButtonTooltipCopy(remoteStatus: GitRemoteStatus | null | undefined): ActionTooltipCopy {
function commitButtonTooltipCopy(locale: AppLocale, remoteStatus: GitRemoteStatus | null | undefined): ActionTooltipCopy {
return {
label: isRemoteMissing(remoteStatus)
? 'Commit changes locally'
: 'Commit and push changes',
? translate(locale, 'status.commit.local')
: translate(locale, 'status.commit.push'),
}
}
function getMcpBadgeConfig(status: McpStatus, onInstall?: () => void) {
function getMcpBadgeConfig(locale: AppLocale, status: McpStatus, onInstall?: () => void) {
if (status === 'installed' || status === 'checking') return null
const clickable = status === 'not_installed' && Boolean(onInstall)
return {
clickable,
tooltip: MCP_TOOLTIPS[status] ?? 'MCP status unknown',
tooltip: translate(locale, MCP_TOOLTIP_KEYS[status] ?? 'status.mcp.unknown'),
onClick: clickable ? onInstall : undefined,
}
}
function getClaudeCodeBadgeConfig(status: ClaudeCodeStatus, version?: string | null) {
function getClaudeCodeBadgeConfig(locale: AppLocale, status: ClaudeCodeStatus, version?: string | null) {
if (status === 'checking') return null
const missing = status === 'missing'
const label = translate(locale, missing ? 'status.claude.missing' : 'status.claude.label')
return {
missing,
label: missing ? 'Claude Code missing' : 'Claude Code',
tooltip: missing ? 'Claude Code not found — click to install' : `Claude Code${version ? ` ${version}` : ''}`,
label,
tooltip: missing ? translate(locale, 'status.claude.install') : `${label}${version ? ` ${version}` : ''}`,
onActivate: missing ? () => openExternalUrl(CLAUDE_INSTALL_URL) : undefined,
}
}
@@ -174,36 +179,235 @@ function StatusBarSeparator({ show = true }: { show?: boolean }) {
return <span style={SEP_STYLE}>|</span>
}
function RemoteStatusSummary({ remoteStatus }: { remoteStatus: GitRemoteStatus | null }) {
if (!hasRemote(remoteStatus)) {
return <div style={{ color: 'var(--muted-foreground)', marginBottom: 6 }}>No remote configured</div>
}
function CompactStatusActionBadge({
showSeparator,
copyLabel,
onClick,
testId,
className,
compact,
icon,
label,
trailingWarning = false,
}: {
showSeparator: boolean
copyLabel: string
onClick?: () => void
testId: string
className?: string
compact: boolean
icon: ReactNode
label: ReactNode
trailingWarning?: boolean
}) {
return (
<>
<StatusBarSeparator show={showSeparator} />
<StatusBarAction
copy={{ label: copyLabel }}
onClick={onClick}
testId={testId}
className={className}
compact={compact}
>
<span style={ICON_STYLE}>
{icon}
{compact ? null : label}
{trailingWarning && <AlertTriangle size={10} style={{ marginLeft: 2 }} />}
</span>
</StatusBarAction>
</>
)
}
type RemoteSummaryState =
| { kind: 'missing' }
| { kind: 'inSync' }
| { kind: 'diverged'; ahead: number; behind: number }
function getRemoteSummaryState(remoteStatus: GitRemoteStatus | null): RemoteSummaryState {
if (!hasRemote(remoteStatus)) return { kind: 'missing' }
const ahead = remoteStatus?.ahead ?? 0
const behind = remoteStatus?.behind ?? 0
return ahead === 0 && behind === 0
? { kind: 'inSync' }
: { kind: 'diverged', ahead, behind }
}
if (ahead === 0 && behind === 0) {
return <div style={{ display: 'flex', gap: 12, marginBottom: 6, color: 'var(--muted-foreground)' }}>In sync with remote</div>
function RemoteSummaryLine({ children }: { children: ReactNode }) {
return (
<div style={{ display: 'flex', gap: 12, marginBottom: 6, color: 'var(--muted-foreground)' }}>
{children}
</div>
)
}
function RemoteDivergenceItem({
count,
direction,
locale,
}: {
count: number
direction: 'ahead' | 'behind'
locale: AppLocale
}) {
if (count <= 0) return null
const arrow = direction === 'ahead' ? '↑' : '↓'
const titleKey = direction === 'ahead' ? 'status.remote.aheadTitle' : 'status.remote.behindTitle'
const labelKey = direction === 'ahead' ? 'status.remote.ahead' : 'status.remote.behind'
const style = direction === 'behind' ? { color: 'var(--accent-orange)' } : undefined
return (
<span title={translate(locale, titleKey, { count, plural: count > 1 ? 's' : '' })} style={style}>
{arrow} {translate(locale, labelKey, { count })}
</span>
)
}
interface StatusWarningRenderConfig {
copyLabel: string
onClick?: () => void
testId: string
className?: string
icon: ReactNode
label: ReactNode
trailingWarning?: boolean
}
type StatusWarningBadgeProps = {
showSeparator: boolean
compact: boolean
locale: AppLocale
} & (
| { kind: 'conflict'; count: number; onClick?: () => void }
| { kind: 'missingGit'; onClick?: () => void }
| { kind: 'mcp'; status: McpStatus; onInstall?: () => void }
| { kind: 'claude'; status: ClaudeCodeStatus; version?: string | null }
)
interface StatusBadgeDisplayOptions {
showSeparator?: boolean
compact?: boolean
locale?: AppLocale
}
type ConflictBadgeProps = StatusBadgeDisplayOptions & {
count: number
onClick?: () => void
}
type MissingGitBadgeProps = StatusBadgeDisplayOptions & {
onClick?: () => void
}
type McpBadgeProps = StatusBadgeDisplayOptions & {
status: McpStatus
onInstall?: () => void
}
type ClaudeCodeBadgeProps = StatusBadgeDisplayOptions & {
status: ClaudeCodeStatus
version?: string | null
}
function withStatusBadgeDefaults({
showSeparator = true,
compact = false,
locale = 'en',
}: StatusBadgeDisplayOptions) {
return { showSeparator, compact, locale }
}
function getStatusWarningBadgeConfig(props: StatusWarningBadgeProps): StatusWarningRenderConfig | null {
switch (props.kind) {
case 'conflict':
return {
copyLabel: translate(props.locale, 'status.sync.resolveConflicts'),
onClick: props.onClick,
testId: 'status-conflict-count',
className: 'text-[var(--destructive)]',
icon: <AlertTriangle size={13} />,
label: translate(props.locale, 'status.conflict.count', { count: props.count, plural: props.count > 1 ? 's' : '' }),
}
case 'missingGit':
return {
copyLabel: translate(props.locale, 'status.git.disabledTooltip'),
onClick: props.onClick,
testId: 'status-missing-git',
className: 'text-[var(--accent-orange)]',
icon: <GitBranch size={13} />,
label: translate(props.locale, 'status.git.disabled'),
trailingWarning: true,
}
case 'mcp': {
const config = getMcpBadgeConfig(props.locale, props.status, props.onInstall)
return config && {
copyLabel: config.tooltip,
onClick: config.onClick,
testId: 'status-mcp',
className: 'text-[var(--accent-orange)]',
icon: <Cpu size={13} />,
label: 'MCP',
trailingWarning: true,
}
}
case 'claude': {
const config = getClaudeCodeBadgeConfig(props.locale, props.status, props.version)
return config && {
copyLabel: config.tooltip,
onClick: config.onActivate,
testId: 'status-claude-code',
className: config.missing ? 'text-[var(--accent-orange)]' : undefined,
icon: <Terminal size={13} />,
label: config.label,
trailingWarning: config.missing,
}
}
}
}
function StatusWarningBadge(props: StatusWarningBadgeProps) {
const config = getStatusWarningBadgeConfig(props)
if (!config) return null
return (
<CompactStatusActionBadge
showSeparator={props.showSeparator}
compact={props.compact}
{...config}
/>
)
}
function RemoteStatusSummary({ remoteStatus, locale = 'en' }: { remoteStatus: GitRemoteStatus | null; locale?: AppLocale }) {
const state = getRemoteSummaryState(remoteStatus)
if (state.kind === 'missing') {
return <div style={{ color: 'var(--muted-foreground)', marginBottom: 6 }}>{translate(locale, 'status.remote.noneConfigured')}</div>
}
if (state.kind === 'inSync') {
return <RemoteSummaryLine>{translate(locale, 'status.remote.inSync')}</RemoteSummaryLine>
}
return (
<div style={{ display: 'flex', gap: 12, marginBottom: 6, color: 'var(--muted-foreground)' }}>
{ahead > 0 && <span title={`${ahead} commit${ahead > 1 ? 's' : ''} ahead of remote`}> {ahead} ahead</span>}
{behind > 0 && (
<span title={`${behind} commit${behind > 1 ? 's' : ''} behind remote`} style={{ color: 'var(--accent-orange)' }}>
{behind} behind
</span>
)}
</div>
<RemoteSummaryLine>
<RemoteDivergenceItem count={state.ahead} direction="ahead" locale={locale} />
<RemoteDivergenceItem count={state.behind} direction="behind" locale={locale} />
</RemoteSummaryLine>
)
}
function PullAction({
remoteStatus,
locale = 'en',
onPull,
onClose,
}: {
remoteStatus: GitRemoteStatus | null
locale?: AppLocale
onPull?: () => void
onClose: () => void
}) {
@@ -232,7 +436,7 @@ function PullAction({
onMouseLeave={(event) => { event.currentTarget.style.background = 'transparent' }}
data-testid="git-status-pull-btn"
>
<ArrowDown size={11} />Pull
<ArrowDown size={11} />{translate(locale, 'status.sync.pull')}
</button>
</div>
)
@@ -241,11 +445,13 @@ function PullAction({
function GitStatusPopup({
status,
remoteStatus,
locale = 'en',
onPull,
onClose,
}: {
status: SyncStatus
remoteStatus: GitRemoteStatus | null
locale?: AppLocale
onPull?: () => void
onClose: () => void
}) {
@@ -272,16 +478,16 @@ function GitStatusPopup({
<GitBranch size={13} style={{ color: 'var(--muted-foreground)' }} />
<span style={{ fontWeight: 500 }}>{remoteStatus?.branch || '—'}</span>
</div>
<RemoteStatusSummary remoteStatus={remoteStatus} />
<RemoteStatusSummary remoteStatus={remoteStatus} locale={locale} />
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4, color: 'var(--muted-foreground)' }}>
Status: {syncStatusText(status)}
{translate(locale, 'status.sync.status', { status: syncStatusText(locale, status) })}
</div>
<PullAction remoteStatus={remoteStatus} onPull={onPull} onClose={onClose} />
<PullAction remoteStatus={remoteStatus} locale={locale} onPull={onPull} onClose={onClose} />
</div>
)
}
export function CommitBadge({ info }: { info: LastCommitInfo }) {
export function CommitBadge({ info, locale = 'en' }: { info: LastCommitInfo; locale?: AppLocale }) {
const commitUrl = info.commitUrl
if (commitUrl) {
@@ -290,7 +496,7 @@ export function CommitBadge({ info }: { info: LastCommitInfo }) {
role="button"
onClick={() => openExternalUrl(commitUrl)}
style={{ ...ICON_STYLE, color: 'var(--muted-foreground)', textDecoration: 'none', cursor: 'pointer', padding: '2px 4px', borderRadius: 3 }}
title={`Open commit ${info.shortHash} on GitHub`}
title={translate(locale, 'status.commit.openOnGitHub', { hash: info.shortHash })}
data-testid="status-commit-link"
onMouseEnter={(event) => { event.currentTarget.style.color = 'var(--foreground)' }}
onMouseLeave={(event) => { event.currentTarget.style.color = 'var(--muted-foreground)' }}
@@ -313,10 +519,12 @@ export function OfflineBadge({
isOffline,
showSeparator = true,
compact = false,
locale = 'en',
}: {
isOffline?: boolean
showSeparator?: boolean
compact?: boolean
locale?: AppLocale
}) {
if (!isOffline) return null
@@ -332,13 +540,13 @@ export function OfflineBadge({
padding: '2px 6px',
fontWeight: 600,
}}
title="No internet connection"
title={translate(locale, 'status.offline.title')}
data-testid="status-offline"
>
<span aria-hidden="true" style={{ fontSize: 10, lineHeight: 1 }}>
</span>
{compact ? null : 'Offline'}
{compact ? null : translate(locale, 'status.offline.label')}
</span>
</>
)
@@ -349,11 +557,13 @@ export function NoRemoteBadge({
onAddRemote,
showSeparator = true,
compact = false,
locale = 'en',
}: {
remoteStatus?: GitRemoteStatus | null
onAddRemote?: () => void
showSeparator?: boolean
compact?: boolean
locale?: AppLocale
}) {
if (!isRemoteMissing(remoteStatus)) return null
@@ -362,14 +572,14 @@ export function NoRemoteBadge({
<>
<StatusBarSeparator show={showSeparator} />
<StatusBarAction
copy={{ label: 'Add a remote to this vault' }}
copy={{ label: translate(locale, 'status.remote.add') }}
onClick={onAddRemote}
testId="status-no-remote"
compact={compact}
>
<span style={ICON_STYLE}>
<GitBranch size={12} />
{compact ? null : 'No remote'}
{compact ? null : translate(locale, 'status.remote.none')}
</span>
</StatusBarAction>
</>
@@ -388,11 +598,11 @@ export function NoRemoteBadge({
padding: '2px 6px',
fontWeight: 600,
}}
title="This git vault has no remote configured. Commits stay local until you add one."
title={translate(locale, 'status.remote.noneDescription')}
data-testid="status-no-remote"
>
<GitBranch size={12} />
{compact ? null : 'No remote'}
{compact ? null : translate(locale, 'status.remote.none')}
</span>
</>
)
@@ -406,6 +616,7 @@ export function SyncBadge({
onPullAndPush,
onOpenConflictResolver,
compact = false,
locale = 'en',
}: {
status: SyncStatus
lastSyncTime: number | null
@@ -414,6 +625,7 @@ export function SyncBadge({
onPullAndPush?: () => void
onOpenConflictResolver?: () => void
compact?: boolean
locale?: AppLocale
}) {
const [showPopup, setShowPopup] = useState(false)
const popupRef = useRef<HTMLDivElement>(null)
@@ -438,16 +650,17 @@ export function SyncBadge({
return (
<div ref={popupRef} style={{ position: 'relative' }}>
<StatusBarAction copy={syncBadgeTooltipCopy(status)} onClick={handleClick} testId="status-sync" compact={compact}>
<StatusBarAction copy={syncBadgeTooltipCopy(locale, status)} onClick={handleClick} testId="status-sync" compact={compact}>
<span style={ICON_STYLE}>
<SyncIcon size={13} style={{ color: syncIconColor(status) }} className={isSyncing ? 'animate-spin' : ''} />
{compact ? null : formatSyncLabel(status, lastSyncTime)}
{compact ? null : formatSyncLabel(locale, status, lastSyncTime)}
</span>
</StatusBarAction>
{showPopup && (
<GitStatusPopup
status={status}
remoteStatus={remoteStatus ?? null}
locale={locale}
onPull={onTriggerSync}
onClose={() => setShowPopup(false)}
/>
@@ -459,32 +672,17 @@ export function SyncBadge({
export function ConflictBadge({
count,
onClick,
showSeparator = true,
compact = false,
}: {
count: number
onClick?: () => void
showSeparator?: boolean
compact?: boolean
}) {
...displayOptions
}: ConflictBadgeProps) {
if (count <= 0) return null
return (
<>
<StatusBarSeparator show={showSeparator} />
<StatusBarAction
copy={{ label: 'Resolve merge conflicts' }}
onClick={onClick}
testId="status-conflict-count"
className="text-[var(--destructive)]"
compact={compact}
>
<span style={ICON_STYLE}>
<AlertTriangle size={13} />
{compact ? null : `${count} conflict${count > 1 ? 's' : ''}`}
</span>
</StatusBarAction>
</>
<StatusWarningBadge
kind="conflict"
count={count}
onClick={onClick}
{...withStatusBadgeDefaults(displayOptions)}
/>
)
}
@@ -493,18 +691,20 @@ export function ChangesBadge({
onClick,
showSeparator = true,
compact = false,
locale = 'en',
}: {
count: number
onClick?: () => void
showSeparator?: boolean
compact?: boolean
locale?: AppLocale
}) {
if (count <= 0) return null
return (
<>
<StatusBarSeparator show={showSeparator} />
<StatusBarAction copy={{ label: 'View pending changes' }} onClick={onClick} testId="status-modified-count" compact={compact}>
<StatusBarAction copy={{ label: translate(locale, 'status.changes.view') }} onClick={onClick} testId="status-modified-count" compact={compact}>
<span style={ICON_STYLE}>
<GitDiff size={13} style={{ color: 'var(--accent-orange)' }} />
<span
@@ -524,7 +724,7 @@ export function ChangesBadge({
>
{count}
</span>
{compact ? null : 'Changes'}
{compact ? null : translate(locale, 'status.changes.label')}
</span>
</StatusBarAction>
</>
@@ -536,21 +736,23 @@ export function CommitButton({
remoteStatus,
showSeparator = true,
compact = false,
locale = 'en',
}: {
onClick?: () => void
remoteStatus?: GitRemoteStatus | null
showSeparator?: boolean
compact?: boolean
locale?: AppLocale
}) {
if (!onClick) return null
return (
<>
<StatusBarSeparator show={showSeparator} />
<StatusBarAction copy={commitButtonTooltipCopy(remoteStatus)} onClick={onClick} testId="status-commit-push" compact={compact}>
<StatusBarAction copy={commitButtonTooltipCopy(locale, remoteStatus)} onClick={onClick} testId="status-commit-push" compact={compact}>
<span style={ICON_STYLE}>
<GitCommitHorizontal size={13} />
{compact ? null : 'Commit'}
{compact ? null : translate(locale, 'status.commit.label')}
</span>
</StatusBarAction>
</>
@@ -559,30 +761,14 @@ export function CommitButton({
export function MissingGitBadge({
onClick,
showSeparator = true,
compact = false,
}: {
onClick?: () => void
showSeparator?: boolean
compact?: boolean
}) {
...displayOptions
}: MissingGitBadgeProps) {
return (
<>
<StatusBarSeparator show={showSeparator} />
<StatusBarAction
copy={{ label: 'Git is disabled for this vault. Initialize Git to enable history, sync, commits, and change views.' }}
onClick={onClick}
testId="status-missing-git"
className="text-[var(--accent-orange)]"
compact={compact}
>
<span style={ICON_STYLE}>
<GitBranch size={13} />
{compact ? null : 'Git disabled'}
<AlertTriangle size={10} style={{ marginLeft: 2 }} />
</span>
</StatusBarAction>
</>
<StatusWarningBadge
kind="missingGit"
onClick={onClick}
{...withStatusBadgeDefaults(displayOptions)}
/>
)
}
@@ -591,17 +777,19 @@ export function PulseBadge({
disabled,
showSeparator = true,
compact = false,
locale = 'en',
}: {
onClick?: () => void
disabled?: boolean
showSeparator?: boolean
compact?: boolean
locale?: AppLocale
}) {
return (
<>
<StatusBarSeparator show={showSeparator} />
<StatusBarAction
copy={{ label: disabled ? 'History is only available for git-enabled vaults' : 'Open change history' }}
copy={{ label: translate(locale, disabled ? 'status.history.onlyGit' : 'status.history.open') }}
onClick={disabled ? undefined : onClick}
testId="status-pulse"
disabled={Boolean(disabled)}
@@ -609,7 +797,7 @@ export function PulseBadge({
>
<span style={ICON_STYLE}>
<Pulse size={13} />
{compact ? null : 'History'}
{compact ? null : translate(locale, 'status.history.label')}
</span>
</StatusBarAction>
</>
@@ -619,67 +807,29 @@ export function PulseBadge({
export function McpBadge({
status,
onInstall,
showSeparator = true,
compact = false,
}: {
status: McpStatus
onInstall?: () => void
showSeparator?: boolean
compact?: boolean
}) {
const config = getMcpBadgeConfig(status, onInstall)
if (!config) return null
...displayOptions
}: McpBadgeProps) {
return (
<>
<StatusBarSeparator show={showSeparator} />
<StatusBarAction
copy={{ label: config.tooltip }}
onClick={config.onClick}
testId="status-mcp"
className="text-[var(--accent-orange)]"
compact={compact}
>
<span style={ICON_STYLE}>
<Cpu size={13} />
{compact ? null : 'MCP'}
<AlertTriangle size={10} style={{ marginLeft: 2 }} />
</span>
</StatusBarAction>
</>
<StatusWarningBadge
kind="mcp"
status={status}
onInstall={onInstall}
{...withStatusBadgeDefaults(displayOptions)}
/>
)
}
export function ClaudeCodeBadge({
status,
version,
showSeparator = true,
compact = false,
}: {
status: ClaudeCodeStatus
version?: string | null
showSeparator?: boolean
compact?: boolean
}) {
const config = getClaudeCodeBadgeConfig(status, version)
if (!config) return null
...displayOptions
}: ClaudeCodeBadgeProps) {
return (
<>
<StatusBarSeparator show={showSeparator} />
<StatusBarAction
copy={{ label: config.tooltip }}
onClick={config.onActivate}
testId="status-claude-code"
className={config.missing ? 'text-[var(--accent-orange)]' : undefined}
compact={compact}
>
<span style={ICON_STYLE}>
<Terminal size={13} />
{compact ? null : config.label}
{config.missing && <AlertTriangle size={10} style={{ marginLeft: 2 }} />}
</span>
</StatusBarAction>
</>
<StatusWarningBadge
kind="claude"
status={status}
version={version}
{...withStatusBadgeDefaults(displayOptions)}
/>
)
}

View File

@@ -5,6 +5,7 @@ import type { VaultAiGuidanceStatus } from '../../lib/vaultAiGuidance'
import type { ClaudeCodeStatus } from '../../hooks/useClaudeCodeStatus'
import type { McpStatus } from '../../hooks/useMcpStatus'
import type { ThemeMode } from '../../lib/themeMode'
import { translate, type AppLocale } from '../../lib/i18n'
import { useStatusBarAddRemote } from '../../hooks/useStatusBarAddRemote'
import type { GitRemoteStatus, SyncStatus } from '../../types'
import { rememberFeedbackDialogOpener } from '../../lib/feedbackDialogOpener'
@@ -29,18 +30,12 @@ import type { VaultOption } from './types'
import { VaultMenu } from './VaultMenu'
import { formatShortcutDisplay } from '../../hooks/appCommandCatalog'
const UPDATE_TOOLTIP = { label: 'Check for updates' } as const
const ZOOM_RESET_TOOLTIP = {
label: 'Reset the zoom level',
shortcut: formatShortcutDisplay({ display: '⌘0' }),
} as const
const FEEDBACK_TOOLTIP = { label: 'Contribute to Tolaria' } as const
const LIGHT_MODE_TOOLTIP = { label: 'Switch to light mode' } as const
const DARK_MODE_TOOLTIP = { label: 'Switch to dark mode' } as const
const SETTINGS_TOOLTIP = {
label: 'Open settings',
const SETTINGS_SHORTCUT = {
shortcut: formatShortcutDisplay({ display: '⌘,' }),
} as const
const ZOOM_RESET_SHORTCUT = {
shortcut: formatShortcutDisplay({ display: '⌘0' }),
} as const
interface StatusBarPrimarySectionProps {
modifiedCount: number
@@ -79,6 +74,7 @@ interface StatusBarPrimarySectionProps {
claudeCodeVersion?: string | null
stacked?: boolean
compact?: boolean
locale?: AppLocale
}
interface StatusBarSecondarySectionProps {
@@ -91,36 +87,39 @@ interface StatusBarSecondarySectionProps {
onOpenSettings?: () => void
stacked?: boolean
compact?: boolean
locale?: AppLocale
}
function BuildNumberButton({
buildNumber,
onCheckForUpdates,
compact,
locale,
}: {
buildNumber?: string
onCheckForUpdates?: () => void
compact: boolean
locale: AppLocale
}) {
const className = compact
? 'h-6 min-w-0 gap-1 rounded-sm px-1 py-0.5 text-[11px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground'
: 'h-auto gap-1 rounded-sm px-1 py-0.5 text-[11px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground'
return (
<ActionTooltip copy={UPDATE_TOOLTIP} side="top">
<ActionTooltip copy={{ label: translate(locale, 'status.update.check') }} side="top">
<Button
type="button"
variant="ghost"
size="xs"
className={className}
onClick={onCheckForUpdates}
aria-label={UPDATE_TOOLTIP.label}
aria-label={translate(locale, 'status.update.check')}
aria-disabled={onCheckForUpdates ? undefined : true}
data-testid="status-build-number"
>
<span style={ICON_STYLE}>
<Package size={13} />
{compact ? null : buildNumber ?? 'b?'}
{compact ? null : buildNumber ?? translate(locale, 'status.build.unknown')}
</span>
</Button>
</ActionTooltip>
@@ -136,6 +135,7 @@ function StatusBarAiBadge({
claudeCodeStatus,
claudeCodeVersion,
compact,
locale,
}: Pick<
StatusBarPrimarySectionProps,
| 'aiAgentsStatus'
@@ -146,6 +146,7 @@ function StatusBarAiBadge({
| 'claudeCodeStatus'
| 'claudeCodeVersion'
| 'compact'
| 'locale'
>) {
if (aiAgentsStatus && defaultAiAgent) {
return (
@@ -156,13 +157,14 @@ function StatusBarAiBadge({
onSetDefaultAgent={onSetDefaultAiAgent}
onRestoreGuidance={onRestoreVaultAiGuidance}
compact={compact}
locale={locale}
/>
)
}
if (!claudeCodeStatus) return null
return <ClaudeCodeBadge status={claudeCodeStatus} version={claudeCodeVersion} showSeparator={!compact} compact={compact} />
return <ClaudeCodeBadge status={claudeCodeStatus} version={claudeCodeVersion} showSeparator={!compact} compact={compact} locale={locale} />
}
function StatusBarPrimaryBadges({
@@ -191,6 +193,7 @@ function StatusBarPrimaryBadges({
claudeCodeVersion,
isOffline,
compact,
locale,
}: {
modifiedCount: number
visibleRemoteStatus: GitRemoteStatus | null
@@ -217,15 +220,16 @@ function StatusBarPrimaryBadges({
claudeCodeVersion?: string | null
isOffline: boolean
compact: boolean
locale: AppLocale
}) {
return (
<>
<OfflineBadge isOffline={isOffline} showSeparator={!compact} compact={compact} />
<OfflineBadge isOffline={isOffline} showSeparator={!compact} compact={compact} locale={locale} />
{isGitVault ? (
<>
<NoRemoteBadge remoteStatus={visibleRemoteStatus} onAddRemote={onAddRemote} showSeparator={!compact} compact={compact} />
<ChangesBadge count={modifiedCount} onClick={onClickPending} showSeparator={!compact} compact={compact} />
<CommitButton onClick={onCommitPush} remoteStatus={visibleRemoteStatus} showSeparator={!compact} compact={compact} />
<NoRemoteBadge remoteStatus={visibleRemoteStatus} onAddRemote={onAddRemote} showSeparator={!compact} compact={compact} locale={locale} />
<ChangesBadge count={modifiedCount} onClick={onClickPending} showSeparator={!compact} compact={compact} locale={locale} />
<CommitButton onClick={onCommitPush} remoteStatus={visibleRemoteStatus} showSeparator={!compact} compact={compact} locale={locale} />
<SyncBadge
status={syncStatus}
lastSyncTime={lastSyncTime}
@@ -234,14 +238,15 @@ function StatusBarPrimaryBadges({
onPullAndPush={onPullAndPush}
onOpenConflictResolver={onOpenConflictResolver}
compact={compact}
locale={locale}
/>
<ConflictBadge count={conflictCount} onClick={onOpenConflictResolver} showSeparator={!compact} compact={compact} />
<PulseBadge onClick={onClickPulse} showSeparator={!compact} compact={compact} />
<ConflictBadge count={conflictCount} onClick={onOpenConflictResolver} showSeparator={!compact} compact={compact} locale={locale} />
<PulseBadge onClick={onClickPulse} showSeparator={!compact} compact={compact} locale={locale} />
</>
) : (
<MissingGitBadge onClick={onInitializeGit} showSeparator={!compact} compact={compact} />
<MissingGitBadge onClick={onInitializeGit} showSeparator={!compact} compact={compact} locale={locale} />
)}
{mcpStatus && <McpBadge status={mcpStatus} onInstall={onInstallMcp} showSeparator={!compact} compact={compact} />}
{mcpStatus && <McpBadge status={mcpStatus} onInstall={onInstallMcp} showSeparator={!compact} compact={compact} locale={locale} />}
<StatusBarAiBadge
aiAgentsStatus={aiAgentsStatus}
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
@@ -251,6 +256,7 @@ function StatusBarPrimaryBadges({
claudeCodeStatus={claudeCodeStatus}
claudeCodeVersion={claudeCodeVersion}
compact={compact}
locale={locale}
/>
</>
)
@@ -258,9 +264,11 @@ function StatusBarPrimaryBadges({
function FeedbackButton({
compact,
locale,
onOpenFeedback,
}: {
compact: boolean
locale: AppLocale
onOpenFeedback: () => void
}) {
const className = compact
@@ -268,7 +276,7 @@ function FeedbackButton({
: 'h-6 px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground'
return (
<ActionTooltip copy={FEEDBACK_TOOLTIP} side="top">
<ActionTooltip copy={{ label: translate(locale, 'status.feedback.contribute') }} side="top">
<Button
type="button"
variant="ghost"
@@ -278,11 +286,11 @@ function FeedbackButton({
rememberFeedbackDialogOpener(event.currentTarget)
onOpenFeedback()
}}
aria-label={FEEDBACK_TOOLTIP.label}
aria-label={translate(locale, 'status.feedback.contribute')}
data-testid="status-feedback"
>
<Megaphone size={14} />
{compact ? null : 'Contribute'}
{compact ? null : translate(locale, 'status.feedback.label')}
</Button>
</ActionTooltip>
)
@@ -323,6 +331,7 @@ export function StatusBarPrimarySection({
onRestoreVaultAiGuidance,
claudeCodeStatus,
claudeCodeVersion,
locale = 'en',
stacked = false,
compact = false,
}: StatusBarPrimarySectionProps) {
@@ -363,9 +372,10 @@ export function StatusBarPrimarySection({
onCloneGettingStarted={onCloneGettingStarted}
onRemoveVault={onRemoveVault}
compact={compact}
locale={locale}
/>
{compact ? null : <span style={SEP_STYLE}>|</span>}
<BuildNumberButton buildNumber={buildNumber} onCheckForUpdates={onCheckForUpdates} compact={compact} />
<BuildNumberButton buildNumber={buildNumber} onCheckForUpdates={onCheckForUpdates} compact={compact} locale={locale} />
<StatusBarPrimaryBadges
modifiedCount={modifiedCount}
visibleRemoteStatus={visibleRemoteStatus}
@@ -394,6 +404,7 @@ export function StatusBarPrimarySection({
claudeCodeVersion={claudeCodeVersion}
isOffline={isOffline}
compact={compact}
locale={locale}
/>
<AddRemoteModal
open={showAddRemote}
@@ -413,12 +424,15 @@ export function StatusBarSecondarySection({
onToggleThemeMode,
onOpenFeedback,
onOpenSettings,
locale = 'en',
stacked = false,
compact = false,
}: StatusBarSecondarySectionProps) {
void noteCount
const ThemeIcon = themeMode === 'dark' ? Sun : Moon
const themeTooltip = themeMode === 'dark' ? LIGHT_MODE_TOOLTIP : DARK_MODE_TOOLTIP
const themeTooltip = {
label: translate(locale, themeMode === 'dark' ? 'status.theme.light' : 'status.theme.dark'),
}
return (
<div
@@ -432,21 +446,21 @@ export function StatusBarSecondarySection({
}}
>
{zoomLevel === 100 ? null : (
<ActionTooltip copy={ZOOM_RESET_TOOLTIP} side="top">
<ActionTooltip copy={{ label: translate(locale, 'status.zoom.reset'), ...ZOOM_RESET_SHORTCUT }} side="top">
<Button
type="button"
variant="ghost"
size="xs"
className="h-auto rounded-sm px-1 py-0.5 text-[11px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground"
onClick={onZoomReset}
aria-label={ZOOM_RESET_TOOLTIP.label}
aria-label={translate(locale, 'status.zoom.reset')}
data-testid="status-zoom"
>
<span style={ICON_STYLE}>{zoomLevel}%</span>
</Button>
</ActionTooltip>
)}
{onOpenFeedback && <FeedbackButton compact={compact} onOpenFeedback={onOpenFeedback} />}
{onOpenFeedback && <FeedbackButton compact={compact} locale={locale} onOpenFeedback={onOpenFeedback} />}
<ActionTooltip copy={themeTooltip} side="top">
<Button
type="button"
@@ -461,14 +475,14 @@ export function StatusBarSecondarySection({
<ThemeIcon size={14} />
</Button>
</ActionTooltip>
<ActionTooltip copy={SETTINGS_TOOLTIP} side="top" align="end">
<ActionTooltip copy={{ label: translate(locale, 'status.settings.open'), ...SETTINGS_SHORTCUT }} side="top" align="end">
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground"
onClick={onOpenSettings}
aria-label={SETTINGS_TOOLTIP.label}
aria-label={translate(locale, 'status.settings.open')}
data-testid="status-settings"
>
<Settings size={14} />

View File

@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
import { AlertTriangle, Check, FolderOpen, GitBranch, Plus, Rocket, X } from 'lucide-react'
import { ActionTooltip } from '@/components/ui/action-tooltip'
import { Button } from '@/components/ui/button'
import { translate, type AppLocale, type TranslationKey } from '../../lib/i18n'
import type { VaultOption } from './types'
import { useDismissibleLayer } from './useDismissibleLayer'
@@ -16,19 +17,21 @@ interface VaultMenuProps {
onCloneGettingStarted?: () => void
onRemoveVault?: (path: string) => void
compact?: boolean
locale?: AppLocale
}
interface VaultMenuItemProps {
vault: VaultOption
isActive: boolean
canRemove: boolean
locale: AppLocale
onSelect: () => void
onRemove?: () => void
}
interface VaultMenuActionProps {
icon: ReactNode
label: string
labelKey: TranslationKey
testId: string
accent?: boolean
onClick: () => void
@@ -37,7 +40,7 @@ interface VaultMenuActionProps {
interface VaultAction {
key: string
icon: ReactNode
label: string
labelKey: TranslationKey
testId: string
accent?: boolean
onClick: () => void
@@ -67,7 +70,7 @@ function buildVaultActions({
items.push({
key: 'create-empty',
icon: <Plus size={12} />,
label: 'Create empty vault',
labelKey: 'status.vault.createEmpty',
testId: 'vault-menu-create-empty',
accent: true,
onClick: onCreateEmptyVault,
@@ -78,7 +81,7 @@ function buildVaultActions({
items.push({
key: 'open-local',
icon: <FolderOpen size={12} />,
label: 'Open local folder',
labelKey: 'status.vault.openLocal',
testId: 'vault-menu-open-local',
onClick: onOpenLocalFolder,
})
@@ -88,7 +91,7 @@ function buildVaultActions({
items.push({
key: 'clone-git',
icon: <GitBranch size={12} />,
label: 'Clone Git repo',
labelKey: 'status.vault.cloneGit',
testId: 'vault-menu-clone-git',
onClick: onCloneVault,
})
@@ -98,7 +101,7 @@ function buildVaultActions({
items.push({
key: 'clone-getting-started',
icon: <Rocket size={12} />,
label: 'Clone Getting Started Vault',
labelKey: 'status.vault.cloneGettingStarted',
testId: 'vault-menu-clone-getting-started',
accent: true,
onClick: onCloneGettingStarted,
@@ -114,9 +117,9 @@ function VaultMenuIcon({ isActive, unavailable }: { isActive: boolean; unavailab
return <span style={{ width: 12 }} />
}
function VaultMenuItem({ vault, isActive, canRemove, onSelect, onRemove }: VaultMenuItemProps) {
function VaultMenuItem({ vault, isActive, canRemove, locale, onSelect, onRemove }: VaultMenuItemProps) {
const unavailable = vault.available === false
const removeLabel = `Remove ${vault.label} from list`
const removeLabel = translate(locale, 'status.vault.remove', { label: vault.label })
const itemClassName = [
'w-full justify-start rounded-sm px-2 py-1 text-xs font-normal',
canRemove ? 'pr-7' : '',
@@ -134,7 +137,7 @@ function VaultMenuItem({ vault, isActive, canRemove, onSelect, onRemove }: Vault
disabled={unavailable}
onClick={onSelect}
aria-current={isActive ? 'true' : undefined}
title={unavailable ? `Vault not found: ${vault.path}` : vault.path}
title={unavailable ? translate(locale, 'status.vault.notFound', { path: vault.path }) : vault.path}
data-testid={`vault-menu-item-${vault.label}`}
className={itemClassName}
style={{
@@ -169,7 +172,7 @@ function VaultMenuItem({ vault, isActive, canRemove, onSelect, onRemove }: Vault
)
}
function VaultMenuAction({ icon, label, testId, accent = false, onClick }: VaultMenuActionProps) {
function VaultMenuAction({ icon, labelKey, testId, accent = false, onClick, locale = 'en' }: VaultMenuActionProps & { locale?: AppLocale }) {
return (
<Button
type="button"
@@ -181,7 +184,7 @@ function VaultMenuAction({ icon, label, testId, accent = false, onClick }: Vault
data-testid={testId}
>
{icon}
{label}
{translate(locale, labelKey)}
</Button>
)
}
@@ -196,6 +199,7 @@ export function VaultMenu({
onCloneGettingStarted,
onRemoveVault,
compact = false,
locale = 'en',
}: VaultMenuProps) {
const [open, setOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
@@ -203,7 +207,7 @@ export function VaultMenu({
const canRemove = !!onRemoveVault && vaults.length > 1
const triggerClassName = getVaultTriggerClassName(open, compact)
const triggerSize = compact ? 'icon-xs' : 'xs'
const activeVaultLabel = activeVault?.label ?? 'Vault'
const activeVaultLabel = activeVault?.label ?? translate(locale, 'status.vault.default')
useDismissibleLayer(open, menuRef, () => setOpen(false))
@@ -218,14 +222,14 @@ export function VaultMenu({
return (
<div ref={menuRef} style={{ position: 'relative' }}>
<ActionTooltip copy={{ label: 'Switch vault' }} side="top">
<ActionTooltip copy={{ label: translate(locale, 'status.vault.switch') }} side="top">
<Button
type="button"
variant="ghost"
size={triggerSize}
className={triggerClassName}
onClick={() => setOpen((value) => !value)}
aria-label="Switch vault"
aria-label={translate(locale, 'status.vault.switch')}
data-testid="status-vault-trigger"
>
<FolderOpen size={13} />
@@ -254,6 +258,7 @@ export function VaultMenu({
vault={vault}
isActive={vault.path === vaultPath}
canRemove={canRemove}
locale={locale}
onSelect={() => {
onSwitchVault(vault.path)
setOpen(false)
@@ -269,9 +274,10 @@ export function VaultMenu({
<VaultMenuAction
key={action.key}
icon={action.icon}
label={action.label}
labelKey={action.labelKey}
testId={action.testId}
accent={action.accent}
locale={locale}
onClick={() => {
action.onClick()
setOpen(false)

View File

@@ -0,0 +1,157 @@
import { createTranslator, type AppLocale, type TranslationKey } from '../../lib/i18n'
import type { CommandAction, CommandGroup } from './types'
type Translate = ReturnType<typeof createTranslator>
type CommandLabeler = (command: CommandAction, t: Translate) => string
const GROUP_LABEL_KEYS = {
Navigation: 'command.group.navigation',
Note: 'command.group.note',
Git: 'command.group.git',
View: 'command.group.view',
Settings: 'command.group.settings',
} satisfies Record<CommandGroup, TranslationKey>
const STATIC_LABEL_KEYS = {
'search-notes': 'command.navigation.searchNotes',
'go-all': 'command.navigation.goAllNotes',
'go-archived': 'command.navigation.goArchived',
'go-changes': 'command.navigation.goChanges',
'go-pulse': 'command.navigation.goHistory',
'go-back': 'command.navigation.goBack',
'go-forward': 'command.navigation.goForward',
'go-inbox': 'command.navigation.goInbox',
'rename-folder': 'command.navigation.renameFolder',
'delete-folder': 'command.navigation.deleteFolder',
'filter-open': 'command.navigation.showOpenNotes',
'filter-archived': 'command.navigation.showArchivedNotes',
'create-note': 'command.note.newNote',
'create-type': 'command.note.newType',
'save-note': 'command.note.saveNote',
'delete-note': 'command.note.deleteNote',
'restore-deleted-note': 'command.note.restoreDeleted',
'set-note-icon': 'command.note.setIcon',
'change-note-type': 'command.note.changeType',
'move-note-to-folder': 'command.note.moveToFolder',
'remove-note-icon': 'command.note.removeIcon',
'open-in-new-window': 'command.note.openNewWindow',
'initialize-git': 'command.git.initialize',
'commit-push': 'command.git.commitPush',
'add-remote': 'command.git.addRemote',
'git-pull': 'command.git.pull',
'resolve-conflicts': 'command.git.resolveConflicts',
'view-changes': 'command.git.viewChanges',
'view-editor': 'command.view.editorOnly',
'view-editor-list': 'command.view.editorNoteList',
'view-all': 'command.view.fullLayout',
'toggle-inspector': 'command.view.toggleProperties',
'toggle-diff': 'command.view.toggleDiff',
'toggle-raw-editor': 'command.view.toggleRaw',
'toggle-ai-panel': 'command.view.toggleAiPanel',
'new-ai-chat': 'command.view.newAiChat',
'toggle-backlinks': 'command.view.toggleBacklinks',
'zoom-reset': 'command.view.resetZoom',
'create-empty-vault': 'command.settings.createEmptyVault',
'open-vault': 'command.settings.openVault',
'remove-vault': 'command.settings.removeVault',
'restore-getting-started': 'command.settings.restoreGettingStarted',
'reload-vault': 'command.settings.reloadVault',
'repair-vault': 'command.settings.repairVault',
'open-ai-agents': 'command.ai.openAgents',
'restore-vault-ai-guidance': 'command.ai.restoreGuidance',
} satisfies Record<string, TranslationKey>
function stripKnownPrefix(label: string, prefix: string): string {
return label.startsWith(prefix) ? label.slice(prefix.length) : label
}
function parenthesizedSuffix(label: string): string | null {
return label.match(/\(([^)]+)\)$/)?.[1] ?? null
}
function localizeNoteStateCommand(command: CommandAction, t: Translate): string | null {
if (command.id === 'archive-note') {
return t(command.label === 'Unarchive Note' ? 'command.note.unarchiveNote' : 'command.note.archiveNote')
}
if (command.id === 'toggle-favorite') {
return t(command.label === 'Remove from Favorites' ? 'command.note.removeFavorite' : 'command.note.addFavorite')
}
if (command.id === 'toggle-organized') {
return t(command.label === 'Mark as Unorganized' ? 'command.note.markUnorganized' : 'command.note.markOrganized')
}
return null
}
function localizeColumnsCommand(command: CommandAction, t: Translate): string {
if (command.label === 'Customize All Notes columns') return t('noteList.properties.customizeAllColumns')
if (command.label === 'Customize Inbox columns') return t('noteList.properties.customizeInboxColumns')
return t('noteList.properties.customizeColumns')
}
const VIEW_STATE_LABELERS = {
'toggle-note-layout': (command, t) => t(command.label === 'Use Left-Aligned Note Layout' ? 'command.view.leftLayout' : 'command.view.centerLayout'),
'zoom-in': (command, t) => t('command.view.zoomIn', { zoom: parenthesizedSuffix(command.label)?.replace('%', '') ?? '' }),
'zoom-out': (command, t) => t('command.view.zoomOut', { zoom: parenthesizedSuffix(command.label)?.replace('%', '') ?? '' }),
'customize-note-list-columns': localizeColumnsCommand,
} satisfies Record<string, CommandLabeler>
function localizeViewStateCommand(command: CommandAction, t: Translate): string | null {
return VIEW_STATE_LABELERS[command.id]?.(command, t) ?? null
}
function localizeSettingsStateCommand(command: CommandAction, t: Translate): string | null {
if (command.id === 'install-mcp') {
return t(command.label === 'Manage External AI Tools…'
? 'command.settings.manageExternalAi'
: 'command.settings.setupExternalAi')
}
if (command.id === 'switch-default-ai-agent') {
const agent = parenthesizedSuffix(command.label)
return agent
? t('command.ai.switchDefaultWithAgent', { agent })
: t('command.ai.switchDefault')
}
if (command.id.startsWith('switch-ai-agent-')) {
return t('command.ai.switchToAgent', {
agent: stripKnownPrefix(command.label, 'Switch AI Agent to '),
})
}
return null
}
function localizeTypeCommand(command: CommandAction, t: Translate): string | null {
if (command.id.startsWith('new-') && command.group === 'Note') {
return t('command.note.newTypedNote', { type: stripKnownPrefix(command.label, 'New ') })
}
if (command.id.startsWith('list-') && command.group === 'Navigation') {
return t('command.navigation.listType', { type: stripKnownPrefix(command.label, 'List ') })
}
return null
}
export function localizeCommandGroup(group: CommandGroup, locale: AppLocale = 'en'): string {
return createTranslator(locale)(GROUP_LABEL_KEYS[group])
}
export function localizeCommandActions(commands: CommandAction[], locale: AppLocale = 'en'): CommandAction[] {
const t = createTranslator(locale)
return commands.map((command) => {
const key = STATIC_LABEL_KEYS[command.id]
const label = key
? t(key)
: localizeNoteStateCommand(command, t)
?? localizeViewStateCommand(command, t)
?? localizeSettingsStateCommand(command, t)
?? localizeTypeCommand(command, t)
?? command.label
return label === command.label ? command : { ...command, label }
})
}

View File

@@ -38,18 +38,20 @@ function buildBaseTheme() {
'.cm-scroller': {
fontFamily: FONT_FAMILY,
lineHeight: '1.6',
padding: '16px 0',
padding: '0',
overflow: 'auto',
},
'.cm-content': {
padding: '0 32px 0 16px',
padding: '16px 32px 16px 12px',
caretColor: RAW_EDITOR_COLORS.foreground,
},
'.cm-gutters': {
backgroundColor: RAW_EDITOR_COLORS.gutterBackground,
color: RAW_EDITOR_COLORS.gutterText,
borderRight: `1px solid ${RAW_EDITOR_COLORS.gutterBorder}`,
paddingLeft: '16px',
minHeight: '100%',
paddingTop: '16px',
paddingLeft: '6px',
},
'.cm-lineNumbers .cm-gutterElement': {
paddingRight: '12px',

View File

@@ -13,6 +13,7 @@ import { buildSettingsCommands } from './commands/settingsCommands'
import { buildAiAgentCommands } from './commands/aiAgentCommands'
import { buildTypeCommands } from './commands/typeCommands'
import { buildFilterCommands } from './commands/filterCommands'
import { localizeCommandActions } from './commands/localizeCommands'
import { extractVaultTypes } from '../utils/vaultTypes'
// Re-export types and helpers for backward compatibility
@@ -147,82 +148,111 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
const vaultTypes = useMemo(() => extractVaultTypes(entries), [entries])
return useMemo(() => [
...buildNavigationCommands({
onQuickOpen,
onSelect,
selection,
onRenameFolder,
onDeleteFolder,
showInbox,
onGoBack,
onGoForward,
canGoBack,
canGoForward,
}),
...buildNoteCommands({
hasActiveNote, activeTabPath, isArchived,
onCreateNote, onCreateType, onSave,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onChangeNoteType, onMoveNoteToFolder, canMoveNoteToFolder,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow, onToggleFavorite, isFavorite,
onToggleOrganized, isOrganized: activeEntry?.organized ?? false,
onRestoreDeletedNote, canRestoreDeletedNote,
}),
...buildGitCommands({
modifiedCount,
isGitVault,
canAddRemote: config.canAddRemote ?? false,
onAddRemote: config.onAddRemote,
onCommitPush,
onInitializeGit,
onPull,
onResolveConflicts,
onSelect,
}),
...buildViewCommands({
hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector,
onToggleDiff, onToggleRawEditor, noteLayout, onToggleNoteLayout, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset,
onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel,
}),
...buildSettingsCommands({
mcpStatus, vaultCount, isGettingStartedHidden,
onOpenSettings, onOpenFeedback, onOpenVault, onCreateEmptyVault, onRemoveActiveVault, onRestoreGettingStarted,
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault,
locale, systemLocale, selectedUiLanguage, onSetUiLanguage,
}),
...buildAiAgentCommands({
aiAgentsStatus,
vaultAiGuidanceStatus,
selectedAiAgent,
selectedAiAgentLabel,
onOpenAiAgents,
onRestoreVaultAiGuidance,
onSetDefaultAiAgent,
onCycleDefaultAiAgent,
}),
...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect),
...buildFilterCommands({ isSectionGroup, noteListFilter, onSetNoteListFilter }),
], [
hasActiveNote, activeTabPath, isArchived, modifiedCount, activeNoteModified,
onQuickOpen, onCreateNote, onCreateNoteOfType, onCreateType, onSave, onOpenSettings, onOpenFeedback,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onInitializeGit, isGitVault, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, noteLayout, onToggleNoteLayout, onToggleAIChat, onOpenVault, onCreateEmptyVault, config.canAddRemote, config.onAddRemote,
onCheckForUpdates,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onRenameFolder, onDeleteFolder,
showInbox,
onGoBack, onGoForward, canGoBack, canGoForward,
vaultTypes,
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
mcpStatus, onInstallMcp, aiAgentsStatus, vaultAiGuidanceStatus,
onOpenAiAgents, onRestoreVaultAiGuidance, onSetDefaultAiAgent, selectedAiAgent, onCycleDefaultAiAgent, selectedAiAgentLabel,
onReloadVault, onRepairVault, locale, systemLocale, selectedUiLanguage, onSetUiLanguage,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onChangeNoteType, onMoveNoteToFolder, canMoveNoteToFolder,
isSectionGroup, noteListFilter, onSetNoteListFilter,
const navigationCommands = useMemo(() => buildNavigationCommands({
onQuickOpen,
onSelect,
selection,
onOpenInNewWindow, onToggleFavorite, isFavorite,
onToggleOrganized, onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel,
onRestoreDeletedNote, canRestoreDeletedNote, activeEntry,
onRenameFolder,
onDeleteFolder,
showInbox,
onGoBack,
onGoForward,
canGoBack,
canGoForward,
}), [
onQuickOpen, onSelect, selection, onRenameFolder, onDeleteFolder,
showInbox, onGoBack, onGoForward, canGoBack, canGoForward,
])
const noteCommands = useMemo(() => buildNoteCommands({
hasActiveNote, activeTabPath, isArchived,
onCreateNote, onCreateType, onSave,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onChangeNoteType, onMoveNoteToFolder, canMoveNoteToFolder,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow, onToggleFavorite, isFavorite,
onToggleOrganized, isOrganized: activeEntry?.organized ?? false,
onRestoreDeletedNote, canRestoreDeletedNote,
}), [
hasActiveNote, activeTabPath, isArchived,
onCreateNote, onCreateType, onSave, onDeleteNote, onArchiveNote, onUnarchiveNote,
onChangeNoteType, onMoveNoteToFolder, canMoveNoteToFolder,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow, onToggleFavorite, isFavorite,
onToggleOrganized, activeEntry?.organized, onRestoreDeletedNote, canRestoreDeletedNote,
])
const gitCommands = useMemo(() => buildGitCommands({
modifiedCount,
isGitVault,
canAddRemote: config.canAddRemote ?? false,
onAddRemote: config.onAddRemote,
onCommitPush,
onInitializeGit,
onPull,
onResolveConflicts,
onSelect,
}), [
modifiedCount, isGitVault, config.canAddRemote, config.onAddRemote,
onCommitPush, onInitializeGit, onPull, onResolveConflicts, onSelect,
])
const viewCommands = useMemo(() => buildViewCommands({
hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector,
onToggleDiff, onToggleRawEditor, noteLayout, onToggleNoteLayout, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset,
onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel,
}), [
hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector,
onToggleDiff, onToggleRawEditor, noteLayout, onToggleNoteLayout, onToggleAIChat,
zoomLevel, onZoomIn, onZoomOut, onZoomReset,
onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel,
])
const settingsCommands = useMemo(() => buildSettingsCommands({
mcpStatus, vaultCount, isGettingStartedHidden,
onOpenSettings, onOpenFeedback, onOpenVault, onCreateEmptyVault, onRemoveActiveVault, onRestoreGettingStarted,
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault,
locale, systemLocale, selectedUiLanguage, onSetUiLanguage,
}), [
mcpStatus, vaultCount, isGettingStartedHidden, onOpenSettings, onOpenFeedback,
onOpenVault, onCreateEmptyVault, onRemoveActiveVault, onRestoreGettingStarted,
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault,
locale, systemLocale, selectedUiLanguage, onSetUiLanguage,
])
const aiCommands = useMemo(() => buildAiAgentCommands({
aiAgentsStatus,
vaultAiGuidanceStatus,
selectedAiAgent,
selectedAiAgentLabel,
onOpenAiAgents,
onRestoreVaultAiGuidance,
onSetDefaultAiAgent,
onCycleDefaultAiAgent,
}), [
aiAgentsStatus, vaultAiGuidanceStatus, selectedAiAgent, selectedAiAgentLabel,
onOpenAiAgents, onRestoreVaultAiGuidance, onSetDefaultAiAgent, onCycleDefaultAiAgent,
])
const typeCommands = useMemo(
() => buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect),
[vaultTypes, onCreateNoteOfType, onSelect],
)
const filterCommands = useMemo(
() => buildFilterCommands({ isSectionGroup, noteListFilter, onSetNoteListFilter }),
[isSectionGroup, noteListFilter, onSetNoteListFilter],
)
const commands = useMemo(() => [
...navigationCommands,
...noteCommands,
...gitCommands,
...viewCommands,
...settingsCommands,
...aiCommands,
...typeCommands,
...filterCommands,
], [
navigationCommands, noteCommands, gitCommands, viewCommands,
settingsCommands, aiCommands, typeCommands, filterCommands,
])
return useMemo(() => localizeCommandActions(commands, locale), [commands, locale])
}

View File

@@ -1,10 +1,11 @@
import { describe, expect, it } from 'vitest'
import {
EN_TRANSLATIONS,
ZH_HANS_TRANSLATIONS,
localeDisplayName,
normalizeUiLanguagePreference,
resolveEffectiveLocale,
serializeUiLanguagePreference,
translate,
} from './i18n'
describe('i18n', () => {
@@ -24,10 +25,8 @@ describe('i18n', () => {
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('keeps Simplified Chinese aligned with the canonical English keys', () => {
expect(Object.keys(ZH_HANS_TRANSLATIONS).sort()).toEqual(Object.keys(EN_TRANSLATIONS).sort())
})
it('formats locale display names in the active language', () => {

View File

@@ -1,204 +1,19 @@
import { EN_TRANSLATIONS } from './locales/en'
import { ZH_HANS_TRANSLATIONS } from './locales/zh-Hans'
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
export type TranslationKey = keyof typeof EN_TRANSLATIONS
export type TranslationValues = Record<string, string | number>
export { EN_TRANSLATIONS, ZH_HANS_TRANSLATIONS }
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,
@@ -216,7 +31,7 @@ export function translate(locale: AppLocale, key: TranslationKey, values?: Trans
return interpolate(template, values)
}
export function createTranslator(locale: AppLocale) {
export function createTranslator(locale: AppLocale = DEFAULT_APP_LOCALE) {
return (key: TranslationKey, values?: TranslationValues) => translate(locale, key, values)
}

385
src/lib/locales/en.ts Normal file
View File

@@ -0,0 +1,385 @@
export 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',
'command.group.navigation': 'Navigation',
'command.group.note': 'Note',
'command.group.git': 'Git',
'command.group.view': 'View',
'command.group.settings': 'Settings',
'command.navigation.searchNotes': 'Search Notes',
'command.navigation.goAllNotes': 'Go to All Notes',
'command.navigation.goArchived': 'Go to Archived',
'command.navigation.goChanges': 'Go to Changes',
'command.navigation.goHistory': 'Go to History',
'command.navigation.goBack': 'Go Back',
'command.navigation.goForward': 'Go Forward',
'command.navigation.goInbox': 'Go to Inbox',
'command.navigation.renameFolder': 'Rename Folder',
'command.navigation.deleteFolder': 'Delete Folder',
'command.navigation.showOpenNotes': 'Show Open Notes',
'command.navigation.showArchivedNotes': 'Show Archived Notes',
'command.navigation.listType': 'List {type}',
'command.note.newNote': 'New Note',
'command.note.newType': 'New Type',
'command.note.newTypedNote': 'New {type}',
'command.note.saveNote': 'Save Note',
'command.note.deleteNote': 'Delete Note',
'command.note.archiveNote': 'Archive Note',
'command.note.unarchiveNote': 'Unarchive Note',
'command.note.addFavorite': 'Add to Favorites',
'command.note.removeFavorite': 'Remove from Favorites',
'command.note.markOrganized': 'Mark as Organized',
'command.note.markUnorganized': 'Mark as Unorganized',
'command.note.restoreDeleted': 'Restore Deleted Note',
'command.note.setIcon': 'Set Note Icon',
'command.note.removeIcon': 'Remove Note Icon',
'command.note.changeType': 'Change Note Type…',
'command.note.moveToFolder': 'Move Note to Folder…',
'command.note.openNewWindow': 'Open in New Window',
'command.git.initialize': 'Initialize Git for Current Vault',
'command.git.commitPush': 'Commit & Push',
'command.git.addRemote': 'Add Remote to Current Vault',
'command.git.pull': 'Pull from Remote',
'command.git.resolveConflicts': 'Resolve Conflicts',
'command.git.viewChanges': 'View Pending Changes',
'command.view.editorOnly': 'Editor Only',
'command.view.editorNoteList': 'Editor + Note List',
'command.view.fullLayout': 'Full Layout',
'command.view.toggleProperties': 'Toggle Properties Panel',
'command.view.toggleDiff': 'Toggle Diff Mode',
'command.view.toggleRaw': 'Toggle Raw Editor',
'command.view.leftLayout': 'Use Left-Aligned Note Layout',
'command.view.centerLayout': 'Use Centered Note Layout',
'command.view.toggleAiPanel': 'Toggle AI Panel',
'command.view.newAiChat': 'New AI chat',
'command.view.toggleBacklinks': 'Toggle Backlinks',
'command.view.zoomIn': 'Zoom In ({zoom}%)',
'command.view.zoomOut': 'Zoom Out ({zoom}%)',
'command.view.resetZoom': 'Reset Zoom',
'command.settings.createEmptyVault': 'Create Empty Vault…',
'command.settings.openVault': 'Open Vault…',
'command.settings.removeVault': 'Remove Vault from List',
'command.settings.restoreGettingStarted': 'Restore Getting Started Vault',
'command.settings.manageExternalAi': 'Manage External AI Tools…',
'command.settings.setupExternalAi': 'Set Up External AI Tools…',
'command.settings.reloadVault': 'Reload Vault',
'command.settings.repairVault': 'Repair Vault',
'command.ai.openAgents': 'Open AI Agents',
'command.ai.restoreGuidance': 'Restore Tolaria AI Guidance',
'command.ai.switchToAgent': 'Switch AI Agent to {agent}',
'command.ai.switchDefault': 'Switch Default AI Agent',
'command.ai.switchDefaultWithAgent': 'Switch Default AI Agent ({agent})',
'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',
'common.cancel': 'Cancel',
'locale.en': 'English',
'locale.zhHans': 'Simplified Chinese',
'sidebar.nav.inbox': 'Inbox',
'sidebar.nav.allNotes': 'All Notes',
'sidebar.nav.archive': 'Archive',
'sidebar.group.favorites': 'FAVORITES',
'sidebar.group.views': 'VIEWS',
'sidebar.group.types': 'TYPES',
'sidebar.group.folders': 'FOLDERS',
'sidebar.action.createView': 'Create view',
'sidebar.action.editView': 'Edit view',
'sidebar.action.deleteView': 'Delete view',
'sidebar.action.customizeSections': 'Customize sections',
'sidebar.action.renameSection': 'Rename section…',
'sidebar.action.customizeIconColor': 'Customize icon & color…',
'sidebar.action.createType': 'Create new type',
'sidebar.action.collapse': 'Collapse sidebar',
'sidebar.action.expand': 'Expand sidebar',
'sidebar.action.createFolder': 'Create folder',
'sidebar.action.renameFolder': 'Rename folder',
'sidebar.action.deleteFolder': 'Delete folder',
'sidebar.action.renameFolderMenu': 'Rename folder...',
'sidebar.action.deleteFolderMenu': 'Delete folder...',
'sidebar.folder.name': 'Folder name',
'sidebar.folder.newName': 'New folder name',
'sidebar.folder.expand': 'Expand {name}',
'sidebar.folder.collapse': 'Collapse {name}',
'sidebar.section.name': 'Section name',
'sidebar.section.showInSidebar': 'Show in sidebar',
'sidebar.section.toggle': 'Toggle {label}',
'sidebar.disabled.comingSoon': 'Coming soon',
'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',
'noteList.sort.modified': 'Modified',
'noteList.sort.created': 'Created',
'noteList.sort.title': 'Title',
'noteList.sort.status': 'Status',
'noteList.sort.by': 'Sort by {label}',
'noteList.sort.menu': 'Sort {label}',
'noteList.sort.ascending': 'Ascending',
'noteList.sort.descending': 'Descending',
'noteList.filter.open': 'Open',
'noteList.filter.archived': 'Archived',
'noteList.filter.week': 'Week',
'noteList.filter.month': 'Month',
'noteList.filter.all': 'All',
'noteList.properties.customizeColumns': 'Customize columns',
'noteList.properties.customizeAllColumns': 'Customize All Notes columns',
'noteList.properties.customizeInboxColumns': 'Customize Inbox columns',
'noteList.properties.customizeViewColumns': 'Customize {name} columns',
'noteList.properties.showInNoteList': 'Show in note list',
'noteList.properties.searchPlaceholder': 'Search properties...',
'noteList.properties.searchLabel': 'Search note-list properties',
'noteList.properties.noMatches': 'No properties match this search.',
'noteList.properties.reorder': 'Reorder {name}',
'noteList.changes.restoreNote': 'Restore note',
'noteList.changes.discardChanges': 'Discard changes',
'noteList.changes.restoreDescription': 'Restore {file} from Git?',
'noteList.changes.discardDescription': 'Discard changes to {file}? This cannot be undone.',
'noteList.changes.thisFile': 'this file',
'noteList.changes.restore': 'Restore',
'noteList.changes.discard': 'Discard',
'noteList.changes.cancel': 'Cancel',
'editor.empty.selectNote': 'Select a note to start editing',
'editor.empty.shortcuts': '{quickOpen} to search · {newNote} to create',
'editor.raw.label': 'Raw editor',
'editor.toolbar.rawReturn': 'Return to the editor',
'editor.toolbar.rawOpen': 'Open the raw editor',
'editor.toolbar.centerLayout': 'Switch to centered note layout',
'editor.toolbar.leftLayout': 'Switch to left-aligned note layout',
'editor.toolbar.removeFavorite': 'Remove from favorites',
'editor.toolbar.addFavorite': 'Add to favorites',
'editor.toolbar.markUnorganized': 'Set note as not organized',
'editor.toolbar.markOrganized': 'Set note as organized',
'editor.toolbar.noDiff': 'No diff is available yet',
'editor.toolbar.loadingDiff': 'Loading the diff',
'editor.toolbar.showDiff': 'Show the current diff',
'editor.toolbar.openAi': 'Open the AI panel',
'editor.toolbar.closeAi': 'Close the AI panel',
'editor.toolbar.restoreArchived': 'Restore this archived note',
'editor.toolbar.archive': 'Archive this note',
'editor.toolbar.delete': 'Delete this note',
'editor.toolbar.openProperties': 'Open the properties panel',
'editor.filename.rename': 'Rename filename',
'editor.filename.renameToTitle': 'Rename the file to match the title',
'editor.filename.trigger': 'Filename {filename}. Press Enter to rename',
'editor.banner.archived': 'Archived',
'editor.banner.unarchive': 'Unarchive',
'editor.banner.conflict': 'This note has a merge conflict',
'editor.banner.keepMine': 'Keep mine',
'editor.banner.keepMineTooltip': 'Keep my local version',
'editor.banner.keepTheirs': 'Keep theirs',
'editor.banner.keepTheirsTooltip': 'Keep the remote version',
'inspector.title.properties': 'Properties',
'inspector.title.propertiesShortcut': 'Properties (⌘⇧I)',
'inspector.title.closePropertiesShortcut': 'Close Properties (⌘⇧I)',
'inspector.empty.noNoteSelected': 'No note selected',
'inspector.empty.noProperties': 'This note has no properties yet',
'inspector.empty.initializeProperties': 'Initialize properties',
'inspector.empty.invalidProperties': 'Invalid properties',
'inspector.empty.fixInEditor': 'Fix in editor',
'inspector.properties.addProperty': 'Add property',
'inspector.properties.deleteProperty': 'Delete property',
'inspector.properties.none': 'None',
'inspector.properties.missingType': 'Missing type',
'inspector.properties.missingTypeAria': 'Missing type {type}. Click to create this type.',
'inspector.properties.searchTypes': 'Search types...',
'inspector.properties.noMatchingTypes': 'No matching types',
'inspector.properties.yes': 'Yes',
'inspector.properties.no': 'No',
'inspector.properties.pickDate': 'Pick a date…',
'inspector.properties.valuePlaceholder': 'Value',
'inspector.properties.propertyName': 'Property name',
'inspector.relationship.add': 'Add',
'inspector.relationship.addRelationship': '+ Add relationship',
'inspector.relationship.name': 'Relationship name',
'inspector.relationship.noteTitle': 'Note title',
'inspector.relationship.createAndOpen': 'Create & open',
'inspector.info.title': 'Info',
'inspector.info.modified': 'Modified',
'inspector.info.created': 'Created',
'inspector.info.words': 'Words',
'inspector.info.size': 'Size',
'update.available': 'is available',
'update.releaseNotes': 'Release Notes',
'update.updateNow': 'Update Now',
'update.dismiss': 'Dismiss',
'update.downloading': 'Downloading Tolaria {version}...',
'update.readyRestart': 'is ready - restart to apply',
'update.restartNow': 'Restart Now',
'status.update.check': 'Check for updates',
'status.zoom.reset': 'Reset the zoom level',
'status.feedback.contribute': 'Contribute to Tolaria',
'status.feedback.label': 'Contribute',
'status.theme.light': 'Switch to light mode',
'status.theme.dark': 'Switch to dark mode',
'status.settings.open': 'Open settings',
'status.build.unknown': 'b?',
'status.vault.switch': 'Switch vault',
'status.vault.default': 'Vault',
'status.vault.createEmpty': 'Create empty vault',
'status.vault.openLocal': 'Open local folder',
'status.vault.cloneGit': 'Clone Git repo',
'status.vault.cloneGettingStarted': 'Clone Getting Started Vault',
'status.vault.notFound': 'Vault not found: {path}',
'status.vault.remove': 'Remove {label} from list',
'status.remote.noneConfigured': 'No remote configured',
'status.remote.inSync': 'In sync with remote',
'status.remote.aheadTitle': '{count} commit{plural} ahead of remote',
'status.remote.behindTitle': '{count} commit{plural} behind remote',
'status.remote.ahead': '{count} ahead',
'status.remote.behind': '{count} behind',
'status.remote.add': 'Add a remote to this vault',
'status.remote.none': 'No remote',
'status.remote.noneDescription': 'This git vault has no remote configured. Commits stay local until you add one.',
'status.sync.syncing': 'Syncing...',
'status.sync.conflict': 'Conflict',
'status.sync.failed': 'Sync failed',
'status.sync.pullRequired': 'Pull required',
'status.sync.notSynced': 'Not synced',
'status.sync.justNow': 'Synced just now',
'status.sync.minutesAgo': 'Synced {minutes}m ago',
'status.sync.resolveConflicts': 'Resolve merge conflicts',
'status.sync.inProgress': 'Sync in progress',
'status.sync.pullAndPush': 'Pull from remote and push',
'status.sync.retry': 'Retry sync',
'status.sync.now': 'Sync now',
'status.sync.synced': 'Synced',
'status.sync.conflicts': 'Conflicts',
'status.sync.error': 'Error',
'status.sync.status': 'Status: {status}',
'status.sync.pull': 'Pull',
'status.conflict.count': '{count} conflict{plural}',
'status.offline.title': 'No internet connection',
'status.offline.label': 'Offline',
'status.changes.view': 'View pending changes',
'status.changes.label': 'Changes',
'status.commit.local': 'Commit changes locally',
'status.commit.push': 'Commit and push changes',
'status.commit.label': 'Commit',
'status.commit.openOnGitHub': 'Open commit {hash} on GitHub',
'status.git.disabledTooltip': 'Git is disabled for this vault. Initialize Git to enable history, sync, commits, and change views.',
'status.git.disabled': 'Git disabled',
'status.history.onlyGit': 'History is only available for git-enabled vaults',
'status.history.open': 'Open change history',
'status.history.label': 'History',
'status.mcp.notConnected': 'External AI tools not connected — click to set up',
'status.mcp.unknown': 'MCP status unknown',
'status.claude.missing': 'Claude Code missing',
'status.claude.label': 'Claude Code',
'status.claude.install': 'Claude Code not found — click to install',
'status.ai.noAgents': 'No AI agents detected',
'status.ai.noAgentsTooltip': 'No AI agents detected — click for setup details',
'status.ai.selectedMissing': '{agent} is selected but not installed — click for setup details',
'status.ai.defaultAgent': 'Default AI agent: {agent}{version}',
'status.ai.restoreDetails': '{base}. {summary} — click for restore details',
'status.ai.withGuidance': '{base}. {summary}',
'status.ai.active': 'Active AI agent: {agent}',
'status.ai.unavailable': 'Selected AI agent unavailable: {agent}',
'status.ai.install': 'Install',
'status.ai.installAgent': 'Install {agent}',
'status.ai.vaultGuidance': 'Vault guidance',
'status.ai.restoreGuidance': 'Restore Tolaria AI Guidance',
'status.ai.openOptions': 'Open AI agent options',
'pulse.title': 'History',
'pulse.today': 'Today',
'pulse.yesterday': 'Yesterday',
'pulse.commitCount': '{count} {label}',
'pulse.commitSingular': 'commit',
'pulse.commitPlural': 'commits',
'pulse.openOnGitHub': 'Open on GitHub',
'pulse.expandFiles': 'Expand files',
'pulse.collapseFiles': 'Collapse files',
'pulse.noActivity': 'No activity yet',
'pulse.emptyDescription': "Commit changes to see your vault's history",
'pulse.retry': 'Retry',
'pulse.loadingActivity': 'Loading activity…',
'pulse.loading': 'Loading…',
'pulse.loadError': 'Failed to load activity',
} as const

387
src/lib/locales/zh-Hans.ts Normal file
View File

@@ -0,0 +1,387 @@
import type { EN_TRANSLATIONS } from './en'
export const ZH_HANS_TRANSLATIONS = {
'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': '检查更新',
'command.group.navigation': '导航',
'command.group.note': '笔记',
'command.group.git': 'Git',
'command.group.view': '视图',
'command.group.settings': '设置',
'command.navigation.searchNotes': '搜索笔记',
'command.navigation.goAllNotes': '前往全部笔记',
'command.navigation.goArchived': '前往归档',
'command.navigation.goChanges': '前往更改',
'command.navigation.goHistory': '前往历史',
'command.navigation.goBack': '后退',
'command.navigation.goForward': '前进',
'command.navigation.goInbox': '前往收件箱',
'command.navigation.renameFolder': '重命名文件夹',
'command.navigation.deleteFolder': '删除文件夹',
'command.navigation.showOpenNotes': '显示打开的笔记',
'command.navigation.showArchivedNotes': '显示归档笔记',
'command.navigation.listType': '列出{type}',
'command.note.newNote': '新建笔记',
'command.note.newType': '新建类型',
'command.note.newTypedNote': '新建{type}',
'command.note.saveNote': '保存笔记',
'command.note.deleteNote': '删除笔记',
'command.note.archiveNote': '归档笔记',
'command.note.unarchiveNote': '取消归档笔记',
'command.note.addFavorite': '添加到收藏',
'command.note.removeFavorite': '从收藏中移除',
'command.note.markOrganized': '标记为已整理',
'command.note.markUnorganized': '标记为未整理',
'command.note.restoreDeleted': '恢复已删除笔记',
'command.note.setIcon': '设置笔记图标',
'command.note.removeIcon': '移除笔记图标',
'command.note.changeType': '更改笔记类型…',
'command.note.moveToFolder': '将笔记移动到文件夹…',
'command.note.openNewWindow': '在新窗口中打开',
'command.git.initialize': '为当前仓库初始化 Git',
'command.git.commitPush': '提交并推送',
'command.git.addRemote': '为当前仓库添加远程地址',
'command.git.pull': '从远程拉取',
'command.git.resolveConflicts': '解决冲突',
'command.git.viewChanges': '查看待处理更改',
'command.view.editorOnly': '仅编辑器',
'command.view.editorNoteList': '编辑器 + 笔记列表',
'command.view.fullLayout': '完整布局',
'command.view.toggleProperties': '切换属性面板',
'command.view.toggleDiff': '切换差异模式',
'command.view.toggleRaw': '切换源码编辑器',
'command.view.leftLayout': '使用左对齐笔记布局',
'command.view.centerLayout': '使用居中笔记布局',
'command.view.toggleAiPanel': '切换 AI 面板',
'command.view.newAiChat': '新建 AI 对话',
'command.view.toggleBacklinks': '切换反向链接',
'command.view.zoomIn': '放大({zoom}%',
'command.view.zoomOut': '缩小({zoom}%',
'command.view.resetZoom': '重置缩放',
'command.settings.createEmptyVault': '创建空仓库…',
'command.settings.openVault': '打开仓库…',
'command.settings.removeVault': '从列表移除仓库',
'command.settings.restoreGettingStarted': '恢复入门仓库',
'command.settings.manageExternalAi': '管理外部 AI 工具…',
'command.settings.setupExternalAi': '设置外部 AI 工具…',
'command.settings.reloadVault': '重新加载仓库',
'command.settings.repairVault': '修复仓库',
'command.ai.openAgents': '打开 AI 代理设置',
'command.ai.restoreGuidance': '恢复 Tolaria AI 指导文件',
'command.ai.switchToAgent': '将 AI 代理切换为 {agent}',
'command.ai.switchDefault': '切换默认 AI 代理',
'command.ai.switchDefaultWithAgent': '切换默认 AI 代理({agent}',
'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.description': '选择 Tolaria 在 AI 面板和命令面板中使用的 CLI 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 是否显示收件箱工作流,以及整理时如何移动到下一项。',
'settings.workflow.explicit': '显式整理笔记',
'settings.workflow.explicitDescription': '启用后,收件箱会显示尚未整理的笔记,并提供一个开关用于标记为已整理。',
'settings.workflow.autoAdvance': '自动前进到下一条收件箱笔记',
'settings.workflow.autoAdvanceDescription': '启用后,将收件箱笔记标记为已整理会立即打开下一条可见的收件箱笔记。',
'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': '保存',
'common.cancel': '取消',
'locale.en': '英文',
'locale.zhHans': '简体中文',
'sidebar.nav.inbox': '收件箱',
'sidebar.nav.allNotes': '全部笔记',
'sidebar.nav.archive': '归档',
'sidebar.group.favorites': '收藏',
'sidebar.group.views': '视图',
'sidebar.group.types': '类型',
'sidebar.group.folders': '文件夹',
'sidebar.action.createView': '创建视图',
'sidebar.action.editView': '编辑视图',
'sidebar.action.deleteView': '删除视图',
'sidebar.action.customizeSections': '自定义分组',
'sidebar.action.renameSection': '重命名分组…',
'sidebar.action.customizeIconColor': '自定义图标和颜色…',
'sidebar.action.createType': '新建类型',
'sidebar.action.collapse': '折叠侧边栏',
'sidebar.action.expand': '展开侧边栏',
'sidebar.action.createFolder': '创建文件夹',
'sidebar.action.renameFolder': '重命名文件夹',
'sidebar.action.deleteFolder': '删除文件夹',
'sidebar.action.renameFolderMenu': '重命名文件夹...',
'sidebar.action.deleteFolderMenu': '删除文件夹...',
'sidebar.folder.name': '文件夹名称',
'sidebar.folder.newName': '新文件夹名称',
'sidebar.folder.expand': '展开{name}',
'sidebar.folder.collapse': '折叠{name}',
'sidebar.section.name': '分组名称',
'sidebar.section.showInSidebar': '在侧边栏显示',
'sidebar.section.toggle': '切换{label}',
'sidebar.disabled.comingSoon': '即将推出',
'noteList.title.archive': '归档',
'noteList.title.changes': '更改',
'noteList.title.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': '没有相关项',
'noteList.sort.modified': '已修改',
'noteList.sort.created': '已创建',
'noteList.sort.title': '标题',
'noteList.sort.status': '状态',
'noteList.sort.by': '按{label}排序',
'noteList.sort.menu': '排序{label}',
'noteList.sort.ascending': '升序',
'noteList.sort.descending': '降序',
'noteList.filter.open': '打开',
'noteList.filter.archived': '已归档',
'noteList.filter.week': '本周',
'noteList.filter.month': '本月',
'noteList.filter.all': '全部',
'noteList.properties.customizeColumns': '自定义列',
'noteList.properties.customizeAllColumns': '自定义全部笔记列',
'noteList.properties.customizeInboxColumns': '自定义收件箱列',
'noteList.properties.customizeViewColumns': '自定义{name}列',
'noteList.properties.showInNoteList': '在笔记列表中显示',
'noteList.properties.searchPlaceholder': '搜索属性...',
'noteList.properties.searchLabel': '搜索笔记列表属性',
'noteList.properties.noMatches': '没有匹配的属性。',
'noteList.properties.reorder': '重新排序{name}',
'noteList.changes.restoreNote': '恢复笔记',
'noteList.changes.discardChanges': '丢弃更改',
'noteList.changes.restoreDescription': '从 Git 恢复 {file}',
'noteList.changes.discardDescription': '丢弃对 {file} 的更改?此操作无法撤销。',
'noteList.changes.thisFile': '此文件',
'noteList.changes.restore': '恢复',
'noteList.changes.discard': '丢弃',
'noteList.changes.cancel': '取消',
'editor.empty.selectNote': '选择一条笔记开始编辑',
'editor.empty.shortcuts': '{quickOpen} 搜索 · {newNote} 创建',
'editor.raw.label': '源码编辑器',
'editor.toolbar.rawReturn': '返回编辑器',
'editor.toolbar.rawOpen': '打开源码编辑器',
'editor.toolbar.centerLayout': '切换到居中笔记布局',
'editor.toolbar.leftLayout': '切换到左对齐笔记布局',
'editor.toolbar.removeFavorite': '从收藏中移除',
'editor.toolbar.addFavorite': '添加到收藏',
'editor.toolbar.markUnorganized': '将笔记标记为未整理',
'editor.toolbar.markOrganized': '将笔记标记为已整理',
'editor.toolbar.noDiff': '尚无可用差异',
'editor.toolbar.loadingDiff': '正在加载差异',
'editor.toolbar.showDiff': '显示当前差异',
'editor.toolbar.openAi': '打开 AI 面板',
'editor.toolbar.closeAi': '关闭 AI 面板',
'editor.toolbar.restoreArchived': '恢复这条归档笔记',
'editor.toolbar.archive': '归档这条笔记',
'editor.toolbar.delete': '删除这条笔记',
'editor.toolbar.openProperties': '打开属性面板',
'editor.filename.rename': '重命名文件名',
'editor.filename.renameToTitle': '将文件重命名为匹配标题',
'editor.filename.trigger': '文件名 {filename}。按 Enter 重命名',
'editor.banner.archived': '已归档',
'editor.banner.unarchive': '取消归档',
'editor.banner.conflict': '这条笔记存在合并冲突',
'editor.banner.keepMine': '保留本地版本',
'editor.banner.keepMineTooltip': '保留我的本地版本',
'editor.banner.keepTheirs': '保留远程版本',
'editor.banner.keepTheirsTooltip': '保留远程版本',
'inspector.title.properties': '属性',
'inspector.title.propertiesShortcut': '属性⌘⇧I',
'inspector.title.closePropertiesShortcut': '关闭属性⌘⇧I',
'inspector.empty.noNoteSelected': '未选择笔记',
'inspector.empty.noProperties': '这条笔记还没有属性',
'inspector.empty.initializeProperties': '初始化属性',
'inspector.empty.invalidProperties': '属性无效',
'inspector.empty.fixInEditor': '在编辑器中修复',
'inspector.properties.addProperty': '添加属性',
'inspector.properties.deleteProperty': '删除属性',
'inspector.properties.none': '无',
'inspector.properties.missingType': '缺少类型',
'inspector.properties.missingTypeAria': '缺少类型 {type}。点击创建这个类型。',
'inspector.properties.searchTypes': '搜索类型...',
'inspector.properties.noMatchingTypes': '没有匹配的类型',
'inspector.properties.yes': '是',
'inspector.properties.no': '否',
'inspector.properties.pickDate': '选择日期…',
'inspector.properties.valuePlaceholder': '值',
'inspector.properties.propertyName': '属性名称',
'inspector.relationship.add': '添加',
'inspector.relationship.addRelationship': '+ 添加关系',
'inspector.relationship.name': '关系名称',
'inspector.relationship.noteTitle': '笔记标题',
'inspector.relationship.createAndOpen': '创建并打开',
'inspector.info.title': '信息',
'inspector.info.modified': '修改时间',
'inspector.info.created': '创建时间',
'inspector.info.words': '字数',
'inspector.info.size': '大小',
'update.available': '可用',
'update.releaseNotes': '发行说明',
'update.updateNow': '立即更新',
'update.dismiss': '关闭',
'update.downloading': '正在下载 Tolaria {version}...',
'update.readyRestart': '已准备好 - 重启后应用',
'update.restartNow': '立即重启',
'status.update.check': '检查更新',
'status.zoom.reset': '重置缩放级别',
'status.feedback.contribute': '为 Tolaria 贡献反馈',
'status.feedback.label': '贡献',
'status.theme.light': '切换到浅色模式',
'status.theme.dark': '切换到深色模式',
'status.settings.open': '打开设置',
'status.build.unknown': 'b?',
'status.vault.switch': '切换仓库',
'status.vault.default': '仓库',
'status.vault.createEmpty': '创建空仓库',
'status.vault.openLocal': '打开本地文件夹',
'status.vault.cloneGit': '克隆 Git 仓库',
'status.vault.cloneGettingStarted': '克隆入门仓库',
'status.vault.notFound': '未找到仓库:{path}',
'status.vault.remove': '从列表移除 {label}',
'status.remote.noneConfigured': '未配置远程仓库',
'status.remote.inSync': '已与远程同步',
'status.remote.aheadTitle': '领先远程 {count} 个提交',
'status.remote.behindTitle': '落后远程 {count} 个提交',
'status.remote.ahead': '领先 {count}',
'status.remote.behind': '落后 {count}',
'status.remote.add': '为此仓库添加远程地址',
'status.remote.none': '无远程',
'status.remote.noneDescription': '这个 Git 仓库尚未配置远程地址。添加远程地址前,提交会保留在本地。',
'status.sync.syncing': '正在同步...',
'status.sync.conflict': '冲突',
'status.sync.failed': '同步失败',
'status.sync.pullRequired': '需要拉取',
'status.sync.notSynced': '未同步',
'status.sync.justNow': '刚刚已同步',
'status.sync.minutesAgo': '{minutes} 分钟前已同步',
'status.sync.resolveConflicts': '解决合并冲突',
'status.sync.inProgress': '同步进行中',
'status.sync.pullAndPush': '从远程拉取并推送',
'status.sync.retry': '重试同步',
'status.sync.now': '立即同步',
'status.sync.synced': '已同步',
'status.sync.conflicts': '冲突',
'status.sync.error': '错误',
'status.sync.status': '状态:{status}',
'status.sync.pull': '拉取',
'status.conflict.count': '{count} 个冲突',
'status.offline.title': '无互联网连接',
'status.offline.label': '离线',
'status.changes.view': '查看待处理更改',
'status.changes.label': '更改',
'status.commit.local': '在本地提交更改',
'status.commit.push': '提交并推送更改',
'status.commit.label': '提交',
'status.commit.openOnGitHub': '在 GitHub 打开提交 {hash}',
'status.git.disabledTooltip': '此仓库未启用 Git。初始化 Git 后可使用历史、同步、提交和更改视图。',
'status.git.disabled': 'Git 已禁用',
'status.history.onlyGit': '历史仅适用于启用 Git 的仓库',
'status.history.open': '打开更改历史',
'status.history.label': '历史',
'status.mcp.notConnected': '外部 AI 工具未连接 - 点击设置',
'status.mcp.unknown': 'MCP 状态未知',
'status.claude.missing': '缺少 Claude Code',
'status.claude.label': 'Claude Code',
'status.claude.install': '未找到 Claude Code - 点击安装',
'status.ai.noAgents': '未检测到 AI 代理',
'status.ai.noAgentsTooltip': '未检测到 AI 代理 - 点击查看设置详情',
'status.ai.selectedMissing': '已选择 {agent},但尚未安装 - 点击查看设置详情',
'status.ai.defaultAgent': '默认 AI 代理:{agent}{version}',
'status.ai.restoreDetails': '{base}。{summary} - 点击查看恢复详情',
'status.ai.withGuidance': '{base}。{summary}',
'status.ai.active': '当前 AI 代理:{agent}',
'status.ai.unavailable': '所选 AI 代理不可用:{agent}',
'status.ai.install': '安装',
'status.ai.installAgent': '安装 {agent}',
'status.ai.vaultGuidance': '仓库指导文件',
'status.ai.restoreGuidance': '恢复 Tolaria AI 指导文件',
'status.ai.openOptions': '打开 AI 代理选项',
'pulse.title': '历史',
'pulse.today': '今天',
'pulse.yesterday': '昨天',
'pulse.commitCount': '{count} 个{label}',
'pulse.commitSingular': '提交',
'pulse.commitPlural': '提交',
'pulse.openOnGitHub': '在 GitHub 打开',
'pulse.expandFiles': '展开文件',
'pulse.collapseFiles': '折叠文件',
'pulse.noActivity': '暂无活动',
'pulse.emptyDescription': '提交更改后即可查看仓库历史',
'pulse.retry': '重试',
'pulse.loadingActivity': '正在加载活动…',
'pulse.loading': '正在加载…',
'pulse.loadError': '加载活动失败',
} satisfies Record<keyof typeof EN_TRANSLATIONS, string>