fix: complete app localization pass
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
20
src/App.tsx
20
src/App.tsx
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">×</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')}>×</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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 · {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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 }}>←</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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
105
src/components/note-list/NoteListChangesMenuView.tsx
Normal file
105
src/components/note-list/NoteListChangesMenuView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -183,6 +183,7 @@ function NoteListBody({
|
||||
counts={filterCounts}
|
||||
onChange={onNoteListFilterChange}
|
||||
position="bottom"
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 & color…
|
||||
{translate(locale, 'sidebar.action.customizeIconColor')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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)
|
||||
|
||||
157
src/hooks/commands/localizeCommands.ts
Normal file
157
src/hooks/commands/localizeCommands.ts
Normal 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 }
|
||||
})
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
201
src/lib/i18n.ts
201
src/lib/i18n.ts
@@ -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
385
src/lib/locales/en.ts
Normal 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
387
src/lib/locales/zh-Hans.ts
Normal 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>
|
||||
Reference in New Issue
Block a user