Merge remote-tracking branch 'origin/pr-728' into pr-728
This commit is contained in:
@@ -59,6 +59,7 @@ files:
|
||||
command.git.commitPush: 8cb5faa4019716f43dd69c41496b1d46
|
||||
command.git.addRemote: 7eef951b3f909340a68dc9811be83e9e
|
||||
command.git.pull: 3988d61f4a4546381f61a4eaf61315b4
|
||||
command.git.pullRepository: 931ad25176586fd4332dd671574d3185
|
||||
command.git.resolveConflicts: 871ac36968cfca3d2297a89b28b25dee
|
||||
command.git.viewChanges: b2699edf69d8e1031afce2b2f94e5c8d
|
||||
git.repository.select: 33fcf2b3ec4686d9cd06051c726d0ba2
|
||||
|
||||
44
src/App.tsx
44
src/App.tsx
@@ -636,8 +636,9 @@ function App() {
|
||||
})
|
||||
const handleVaultUpdate = useCallback(async (
|
||||
updatedFiles: string[],
|
||||
options: { preserveFocusedEditor?: boolean } = {},
|
||||
options: { preserveFocusedEditor?: boolean; vaultPath?: string } = {},
|
||||
) => {
|
||||
const updateVaultPath = options.vaultPath ?? resolvedPath
|
||||
await refreshPulledVaultState({
|
||||
activeTabPath: notes.activeTabPath,
|
||||
closeAllTabs,
|
||||
@@ -651,7 +652,7 @@ function App() {
|
||||
reloadViews: vault.reloadViews,
|
||||
replaceActiveTab: handleReplaceActiveTab,
|
||||
updatedFiles,
|
||||
vaultPath: resolvedPath,
|
||||
vaultPath: updateVaultPath,
|
||||
})
|
||||
await refreshGitModifiedFiles()
|
||||
}, [
|
||||
@@ -667,7 +668,10 @@ function App() {
|
||||
vault.unsavedPaths,
|
||||
])
|
||||
const handlePulledVaultUpdate = useCallback(
|
||||
(updatedFiles: string[]) => handleVaultUpdate(updatedFiles, getPulledVaultUpdateOptions()),
|
||||
(updatedFiles: string[], vaultPath: string) => handleVaultUpdate(updatedFiles, {
|
||||
...getPulledVaultUpdateOptions(),
|
||||
vaultPath,
|
||||
}),
|
||||
[handleVaultUpdate],
|
||||
)
|
||||
const handleFocusedVaultUpdate = useCallback(
|
||||
@@ -694,7 +698,7 @@ function App() {
|
||||
})
|
||||
const autoSync = useAutoSync({
|
||||
enabled: gitFeaturesEnabled && gitRepoState === 'ready',
|
||||
vaultPath: resolvedPath,
|
||||
vaultPath: gitSurfaces.syncRepositoryPath,
|
||||
intervalMinutes: settings.auto_pull_interval_minutes,
|
||||
onVaultUpdated: handlePulledVaultUpdate,
|
||||
onConflict: (files) => {
|
||||
@@ -747,7 +751,8 @@ function App() {
|
||||
})
|
||||
|
||||
const conflictFlow = useConflictFlow({
|
||||
resolvedPath, entries: visibleEntries,
|
||||
resolvedPath: gitSurfaces.syncRepositoryPath,
|
||||
entries: visibleEntries,
|
||||
conflictFiles: autoSync.conflictFiles,
|
||||
pausePull: autoSync.pausePull, resumePull: autoSync.resumePull,
|
||||
triggerSync: autoSync.triggerSync, reloadVault: vault.reloadVault,
|
||||
@@ -827,11 +832,13 @@ function App() {
|
||||
onSetFilter: (filterType) => {
|
||||
handleSetSelection({ kind: 'sectionGroup', type: filterType })
|
||||
},
|
||||
onVaultChanged: (path) => { void handlePulledVaultUpdate(path ? [path] : []) },
|
||||
onVaultChanged: (path) => { void handlePulledVaultUpdate(path ? [path] : [], resolvedPath) },
|
||||
})
|
||||
|
||||
const handleInitializeProperties = useCallback(async (path: string) => {
|
||||
await initializeNoteProperties(notes.handleUpdateFrontmatter, path)
|
||||
const handleInitializeProperties = useCallback((path: string) => {
|
||||
void initializeNoteProperties(notes.handleUpdateFrontmatter, path).catch((err) => {
|
||||
console.warn('Failed to initialize note properties:', err)
|
||||
})
|
||||
}, [notes])
|
||||
|
||||
const handleRemoveNoteIcon = useCallback(async (path: string) => {
|
||||
@@ -1020,6 +1027,10 @@ function App() {
|
||||
const handleAppContentChange = appSave.handleContentChange
|
||||
const handleAppSave = appSave.handleSave
|
||||
const loadModifiedFiles = refreshGitModifiedFiles
|
||||
const setSyncRepositoryPath = gitSurfaces.setSyncRepositoryPath
|
||||
const syncRepositoryPath = gitSurfaces.syncRepositoryPath
|
||||
const triggerSync = autoSync.triggerSync
|
||||
const pullAndPush = autoSync.pullAndPush
|
||||
|
||||
useEffect(() => {
|
||||
if (!gitFeaturesEnabled) return
|
||||
@@ -1040,6 +1051,17 @@ function App() {
|
||||
runAutomaticCheckpoint,
|
||||
})
|
||||
}, [gitFeaturesEnabled, openCommitDialog, runAutomaticCheckpoint, settings.autogit_enabled])
|
||||
const handlePullRepository = useCallback((targetVaultPath: string) => {
|
||||
if (!gitFeaturesEnabled) return
|
||||
setSyncRepositoryPath(targetVaultPath)
|
||||
triggerSync(targetVaultPath)
|
||||
}, [gitFeaturesEnabled, setSyncRepositoryPath, triggerSync])
|
||||
const handlePullSelectedRepository = useCallback(() => {
|
||||
handlePullRepository(syncRepositoryPath)
|
||||
}, [handlePullRepository, syncRepositoryPath])
|
||||
const handlePullAndPushSelectedRepository = useCallback(() => {
|
||||
pullAndPush(syncRepositoryPath)
|
||||
}, [pullAndPush, syncRepositoryPath])
|
||||
|
||||
const handleTrackedContentChange = useCallback((path: string, content: string) => {
|
||||
recordAutoGitActivity()
|
||||
@@ -1559,10 +1581,12 @@ function App() {
|
||||
onDeleteNote: deleteActions.handleDeleteNote,
|
||||
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
|
||||
onCommitPush: handleCommitPush,
|
||||
gitRepositories,
|
||||
gitFeaturesEnabled,
|
||||
isGitVault,
|
||||
onInitializeGit: openGitSetupDialog,
|
||||
onPull: autoSync.triggerSync,
|
||||
onPull: handlePullSelectedRepository,
|
||||
onPullRepository: handlePullRepository,
|
||||
onResolveConflicts: conflictFlow.handleOpenConflictResolver,
|
||||
onSetViewMode: handleSetViewMode,
|
||||
onToggleInspector: handleToggleInspector,
|
||||
@@ -1792,7 +1816,7 @@ function App() {
|
||||
</div>
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} locale={appLocale} />
|
||||
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
|
||||
<StatusBar noteCount={visibleEntries.length} modifiedCount={gitModifiedCount} vaultPath={resolvedPath} defaultWorkspacePath={defaultWorkspacePath} vaults={vaultSwitcher.allVaults} multiWorkspaceEnabled={multiWorkspaceEnabled} onSwitchVault={vaultSwitcher.switchVault} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onOpenSettings={handleOpenSettings} onOpenVaultSettings={handleOpenVaultSettings} onOpenFeedback={openFeedback} onOpenDocs={openDocs} 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} commitActionPending={commitFlow.isOpeningCommitDialog} gitFeaturesEnabled={gitFeaturesEnabled} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || isVaultContentLoading} 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} onReorderVaults={vaultSwitcher.reorderVaults} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} aiFeaturesEnabled={aiFeaturesEnabled} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiFeaturesEnabled ? aiAgentsStatus : undefined} vaultAiGuidanceStatus={aiFeaturesEnabled ? vaultAiGuidanceStatus : undefined} defaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.defaultAiAgent : undefined} defaultAiTarget={aiFeaturesEnabled ? settings.default_ai_target ?? undefined : undefined} aiModelProviders={aiFeaturesEnabled ? settings.ai_model_providers ?? [] : []} onSetDefaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiAgent : undefined} onSetDefaultAiTarget={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiTarget : undefined} onRestoreVaultAiGuidance={aiFeaturesEnabled ? () => { void restoreVaultAiGuidance() } : undefined} locale={appLocale} />
|
||||
<StatusBar noteCount={visibleEntries.length} modifiedCount={gitModifiedCount} vaultPath={resolvedPath} defaultWorkspacePath={defaultWorkspacePath} vaults={vaultSwitcher.allVaults} multiWorkspaceEnabled={multiWorkspaceEnabled} onSwitchVault={vaultSwitcher.switchVault} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onOpenSettings={handleOpenSettings} onOpenVaultSettings={handleOpenVaultSettings} onOpenFeedback={openFeedback} onOpenDocs={openDocs} 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} commitActionPending={commitFlow.isOpeningCommitDialog} gitFeaturesEnabled={gitFeaturesEnabled} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || isVaultContentLoading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} repositories={gitRepositories} selectedRepositoryPath={gitSurfaces.syncRepositoryPath} onRepositoryChange={gitSurfaces.setSyncRepositoryPath} onTriggerSync={handlePullSelectedRepository} onPullAndPush={handlePullAndPushSelectedRepository} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} onReorderVaults={vaultSwitcher.reorderVaults} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} aiFeaturesEnabled={aiFeaturesEnabled} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiFeaturesEnabled ? aiAgentsStatus : undefined} vaultAiGuidanceStatus={aiFeaturesEnabled ? vaultAiGuidanceStatus : undefined} defaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.defaultAiAgent : undefined} defaultAiTarget={aiFeaturesEnabled ? settings.default_ai_target ?? undefined : undefined} aiModelProviders={aiFeaturesEnabled ? settings.ai_model_providers ?? [] : []} onSetDefaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiAgent : undefined} onSetDefaultAiTarget={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiTarget : undefined} onRestoreVaultAiGuidance={aiFeaturesEnabled ? () => { void restoreVaultAiGuidance() } : undefined} locale={appLocale} />
|
||||
<GitSetupDialog open={gitFeaturesEnabled && shouldShowGitSetupDialog} onInitGit={handleInitGitRepo} onDismiss={dismissGitSetupDialog} onNeverForVault={neverForVaultGitSetupDialog} />
|
||||
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
|
||||
@@ -573,7 +573,7 @@ describe('Editor', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open table of contents' }))
|
||||
|
||||
expect(screen.getByTestId('table-of-contents-panel')).toBeInTheDocument()
|
||||
expect(await screen.findByRole('button', { name: 'Table Heading' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('button', { name: 'Table Heading' }, { timeout: 5000 })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Regression: editor content did not appear on first load because BlockNote's
|
||||
|
||||
@@ -749,6 +749,30 @@ describe('StatusBar', () => {
|
||||
expect(screen.getByText(/1 behind/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the selected repository on sync controls when multiple vaults are active', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
multiWorkspaceEnabled={true}
|
||||
onSwitchVault={vi.fn()}
|
||||
repositories={[
|
||||
{ path: '/Users/luca/Laputa', label: 'Main Vault', defaultForNewNotes: true },
|
||||
{ path: '/Users/luca/Work', label: 'Work Vault', defaultForNewNotes: false },
|
||||
]}
|
||||
selectedRepositoryPath="/Users/luca/Work"
|
||||
onRepositoryChange={vi.fn()}
|
||||
syncStatus="idle"
|
||||
remoteStatus={{ branch: 'main', ahead: 0, behind: 0, hasRemote: true }}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('status-sync')).toHaveTextContent('Work Vault')
|
||||
fireEvent.click(screen.getByTestId('status-sync'))
|
||||
expect(screen.getByTestId('git-status-repository-select')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows History badge in status bar', () => {
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} isGitVault />)
|
||||
expect(screen.getByTestId('status-pulse')).toBeInTheDocument()
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 type { GitRepositoryOption } from '../utils/gitRepositories'
|
||||
import {
|
||||
StatusBarPrimarySection,
|
||||
StatusBarSecondarySection,
|
||||
@@ -84,6 +85,9 @@ interface StatusBarProps {
|
||||
lastSyncTime?: number | null
|
||||
conflictCount?: number
|
||||
remoteStatus?: GitRemoteStatus | null
|
||||
repositories?: GitRepositoryOption[]
|
||||
selectedRepositoryPath?: string
|
||||
onRepositoryChange?: (path: string) => void
|
||||
onTriggerSync?: () => void
|
||||
onPullAndPush?: () => void
|
||||
onOpenConflictResolver?: () => void
|
||||
@@ -145,6 +149,9 @@ function StatusBarPrimaryFromFooter({
|
||||
lastSyncTime = null,
|
||||
conflictCount = 0,
|
||||
remoteStatus,
|
||||
repositories,
|
||||
selectedRepositoryPath,
|
||||
onRepositoryChange,
|
||||
onTriggerSync,
|
||||
onPullAndPush,
|
||||
onOpenConflictResolver,
|
||||
@@ -197,6 +204,9 @@ function StatusBarPrimaryFromFooter({
|
||||
lastSyncTime={lastSyncTime}
|
||||
conflictCount={conflictCount}
|
||||
remoteStatus={remoteStatus}
|
||||
repositories={repositories}
|
||||
selectedRepositoryPath={selectedRepositoryPath}
|
||||
onRepositoryChange={onRepositoryChange}
|
||||
onTriggerSync={onTriggerSync}
|
||||
onPullAndPush={onPullAndPush}
|
||||
onOpenConflictResolver={onOpenConflictResolver}
|
||||
|
||||
@@ -4,8 +4,50 @@ import { supportsModernRegexFeatures } from '../utils/regexCapabilities'
|
||||
|
||||
const LIGHT_CODE_THEME = 'github-light'
|
||||
const DARK_CODE_THEME = 'github-dark'
|
||||
const GO_LANGUAGE = { name: 'Go', aliases: ['go', 'golang'] }
|
||||
const GO_LANGUAGE_REGISTRATION = {
|
||||
name: 'go',
|
||||
displayName: 'Go',
|
||||
scopeName: 'source.go',
|
||||
aliases: ['golang'],
|
||||
patterns: [
|
||||
{ include: '#comments' },
|
||||
{ include: '#strings' },
|
||||
{ include: '#keywords' },
|
||||
{ include: '#numbers' },
|
||||
],
|
||||
repository: {
|
||||
comments: {
|
||||
patterns: [
|
||||
{ begin: '/\\*', end: '\\*/', name: 'comment.block.go' },
|
||||
{ begin: '//', end: '$', name: 'comment.line.double-slash.go' },
|
||||
],
|
||||
},
|
||||
keywords: {
|
||||
patterns: [
|
||||
{
|
||||
match: '\\b(break|case|chan|const|continue|default|defer|else|fallthrough|for|func|go|goto|if|import|interface|map|package|range|return|select|struct|switch|type|var)\\b',
|
||||
name: 'keyword.control.go',
|
||||
},
|
||||
],
|
||||
},
|
||||
numbers: {
|
||||
patterns: [
|
||||
{ match: '\\b0[xX][0-9a-fA-F_]+\\b|\\b\\d[\\d_]*(\\.\\d[\\d_]*)?\\b', name: 'constant.numeric.go' },
|
||||
],
|
||||
},
|
||||
strings: {
|
||||
patterns: [
|
||||
{ begin: '"', end: '"', name: 'string.quoted.double.go' },
|
||||
{ begin: '`', end: '`', name: 'string.quoted.raw.go' },
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
type TolariaCodeHighlighter = Awaited<ReturnType<NonNullable<typeof codeBlockOptions.createHighlighter>>>
|
||||
type TolariaLoadLanguage = TolariaCodeHighlighter['loadLanguage']
|
||||
type TolariaLanguageInput = Parameters<TolariaLoadLanguage>[number]
|
||||
|
||||
function currentCodeBlockTheme() {
|
||||
if (typeof document === 'undefined') return LIGHT_CODE_THEME
|
||||
@@ -20,11 +62,20 @@ function prioritizeTheme(themes: string[], theme: string) {
|
||||
return [theme, ...themes.filter((candidate) => candidate !== theme)]
|
||||
}
|
||||
|
||||
function expandGoLanguage(language: TolariaLanguageInput): TolariaLanguageInput[] {
|
||||
if (typeof language !== 'string') return [language]
|
||||
const languageName: string = language
|
||||
return languageName === 'go' || languageName === 'golang'
|
||||
? [GO_LANGUAGE_REGISTRATION as TolariaLanguageInput]
|
||||
: [language]
|
||||
}
|
||||
|
||||
async function createTolariaCodeHighlighter(): Promise<TolariaCodeHighlighter> {
|
||||
const highlighter = await codeBlockOptions.createHighlighter()
|
||||
return {
|
||||
...highlighter,
|
||||
getLoadedThemes: () => prioritizeTheme(highlighter.getLoadedThemes(), currentCodeBlockTheme()),
|
||||
loadLanguage: (...languages) => highlighter.loadLanguage(...languages.flatMap(expandGoLanguage)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +84,10 @@ export function createTolariaCodeBlockOptions(): Partial<CodeBlockOptions> {
|
||||
...codeBlockOptions,
|
||||
createHighlighter: createTolariaCodeHighlighter,
|
||||
defaultLanguage: 'text',
|
||||
supportedLanguages: {
|
||||
...codeBlockOptions.supportedLanguages,
|
||||
go: GO_LANGUAGE,
|
||||
},
|
||||
}
|
||||
|
||||
if (supportsModernRegexFeatures()) return options
|
||||
|
||||
@@ -32,6 +32,19 @@ function createView(error?: Error) {
|
||||
return { currentDoc, dispatch, view }
|
||||
}
|
||||
|
||||
function expectDocumentRepairRecovery(error: Error, reason: string) {
|
||||
const { currentDoc, view } = createView(error)
|
||||
const recoverDocument = vi.fn()
|
||||
|
||||
installRichEditorTransformErrorRecovery(view, { recoverDocument })
|
||||
|
||||
expect(() => view.dispatch({ before: currentDoc })).not.toThrow()
|
||||
expect(recoverDocument).toHaveBeenCalledTimes(1)
|
||||
expect(trackEvent).toHaveBeenCalledWith('rich_editor_transform_error_recovered', {
|
||||
reason,
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
@@ -48,6 +61,21 @@ describe('isRecoverableEditorTransformError', () => {
|
||||
expect(isRecoverableEditorTransformError(new RangeError(
|
||||
'Invalid content for node blockContainer: <paragraph("Procedures are long-running"), blockGroup(blockContainer(bulletListItem("Step")))>',
|
||||
))).toBe(true)
|
||||
expect(isRecoverableEditorTransformError(transformError(
|
||||
'Cannot join blockGroup onto blockContainer',
|
||||
))).toBe(true)
|
||||
expect(isRecoverableEditorTransformError(new RangeError(
|
||||
'Inserted content deeper than insertion position',
|
||||
))).toBe(true)
|
||||
expect(isRecoverableEditorTransformError(new RangeError(
|
||||
'Index 1 out of range for <tableRow(tableCell(tableParagraph("A")))>',
|
||||
))).toBe(true)
|
||||
expect(isRecoverableEditorTransformError(new Error(
|
||||
'Block with ID 6c1c3bb4-e218-4f00-aaf5-40606852d286 not found',
|
||||
))).toBe(true)
|
||||
expect(isRecoverableEditorTransformError(new RangeError(
|
||||
'Index 1 out of range for <paragraph("A")>',
|
||||
))).toBe(false)
|
||||
expect(isRecoverableEditorTransformError(new Error('unrelated'))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -78,18 +106,47 @@ describe('installRichEditorTransformErrorRecovery', () => {
|
||||
})
|
||||
|
||||
it('recovers invalid-content schema transactions from mixed paragraph and list editing', () => {
|
||||
const schemaError = new RangeError(
|
||||
'Invalid content for node blockContainer: <paragraph("Procedures are long-running"), blockGroup(blockContainer(bulletListItem("Step")))>',
|
||||
expectDocumentRepairRecovery(
|
||||
new RangeError(
|
||||
'Invalid content for node blockContainer: <paragraph("Procedures are long-running"), blockGroup(blockContainer(bulletListItem("Step")))>',
|
||||
),
|
||||
'transform_error',
|
||||
)
|
||||
const { currentDoc, view } = createView(schemaError)
|
||||
})
|
||||
|
||||
it('repairs invalid block joins after pull refreshes editor state', () => {
|
||||
expectDocumentRepairRecovery(
|
||||
transformError('Cannot join blockGroup onto blockContainer'),
|
||||
'invalid_block_join',
|
||||
)
|
||||
})
|
||||
|
||||
it('recovers table selection transactions whose target row changed underneath BlockNote', () => {
|
||||
expectDocumentRepairRecovery(
|
||||
new RangeError('Index 1 out of range for <tableRow(tableCell(tableParagraph("A")))>'),
|
||||
'table_position_out_of_range',
|
||||
)
|
||||
})
|
||||
|
||||
it('recovers invalid insertion-depth transactions after note switching and saves', () => {
|
||||
expectDocumentRepairRecovery(
|
||||
new RangeError('Inserted content deeper than insertion position'),
|
||||
'invalid_insertion_depth',
|
||||
)
|
||||
})
|
||||
|
||||
it('recovers stale block-reference transactions from toolbar actions', () => {
|
||||
const { currentDoc, view } = createView(new Error(
|
||||
'Block with ID 6c1c3bb4-e218-4f00-aaf5-40606852d286 not found',
|
||||
))
|
||||
const recoverDocument = vi.fn()
|
||||
|
||||
installRichEditorTransformErrorRecovery(view, { recoverDocument })
|
||||
|
||||
expect(() => view.dispatch({ before: currentDoc })).not.toThrow()
|
||||
expect(recoverDocument).toHaveBeenCalledTimes(1)
|
||||
expect(recoverDocument).not.toHaveBeenCalled()
|
||||
expect(trackEvent).toHaveBeenCalledWith('rich_editor_transform_error_recovered', {
|
||||
reason: 'transform_error',
|
||||
reason: 'stale_block_reference',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -35,7 +35,14 @@ interface RepairableBlockNoteEditor {
|
||||
replaceBlocks?: (currentBlocks: unknown[], nextBlocks: unknown[]) => unknown
|
||||
}
|
||||
|
||||
type RecoveryReason = 'mismatched_transaction' | 'stale_transaction' | 'transform_error'
|
||||
type RecoveryReason =
|
||||
| 'invalid_block_join'
|
||||
| 'invalid_insertion_depth'
|
||||
| 'mismatched_transaction'
|
||||
| 'stale_block_reference'
|
||||
| 'stale_transaction'
|
||||
| 'table_position_out_of_range'
|
||||
| 'transform_error'
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
@@ -67,12 +74,41 @@ function isInvalidContentTransactionError(error: unknown): boolean {
|
||||
return error instanceof RangeError && error.message.startsWith('Invalid content for node ')
|
||||
}
|
||||
|
||||
function isInvalidInsertionDepthError(error: unknown): boolean {
|
||||
return error instanceof RangeError && error.message.includes('Inserted content deeper than insertion position')
|
||||
}
|
||||
|
||||
function isTablePositionOutOfRangeError(error: unknown): boolean {
|
||||
return error instanceof RangeError && /^Index \d+ out of range for <tableRow\(/.test(error.message)
|
||||
}
|
||||
|
||||
function isInvalidBlockJoinError(error: unknown): boolean {
|
||||
return isTransformError(error) && error.message === 'Cannot join blockGroup onto blockContainer'
|
||||
}
|
||||
|
||||
export function isStaleBlockReferenceError(error: unknown): boolean {
|
||||
return error instanceof Error && /^Block with ID .+ not found$/.test(error.message)
|
||||
}
|
||||
|
||||
function isTransformError(error: unknown): error is Error {
|
||||
return error instanceof Error && error.name === 'TransformError'
|
||||
}
|
||||
|
||||
function isRecoverableRangeError(error: unknown): boolean {
|
||||
return isInvalidContentTransactionError(error)
|
||||
|| isInvalidInsertionDepthError(error)
|
||||
|| isTablePositionOutOfRangeError(error)
|
||||
}
|
||||
|
||||
const RECOVERABLE_EDITOR_ERROR_PREDICATES = [
|
||||
isTransformError,
|
||||
isMismatchedTransactionError,
|
||||
isRecoverableRangeError,
|
||||
isStaleBlockReferenceError,
|
||||
]
|
||||
|
||||
export function isRecoverableEditorTransformError(error: unknown): boolean {
|
||||
return error instanceof Error && (
|
||||
error.name === 'TransformError'
|
||||
|| isMismatchedTransactionError(error)
|
||||
|| isInvalidContentTransactionError(error)
|
||||
)
|
||||
return RECOVERABLE_EDITOR_ERROR_PREDICATES.some((predicate) => predicate(error))
|
||||
}
|
||||
|
||||
function recoveryReason(
|
||||
@@ -82,10 +118,18 @@ function recoveryReason(
|
||||
): RecoveryReason {
|
||||
if (transactionDocIsStale(transaction, view)) return 'stale_transaction'
|
||||
if (isMismatchedTransactionError(error)) return 'mismatched_transaction'
|
||||
if (isStaleBlockReferenceError(error)) return 'stale_block_reference'
|
||||
if (isInvalidBlockJoinError(error)) return 'invalid_block_join'
|
||||
if (isInvalidInsertionDepthError(error)) return 'invalid_insertion_depth'
|
||||
if (isTablePositionOutOfRangeError(error)) return 'table_position_out_of_range'
|
||||
return 'transform_error'
|
||||
}
|
||||
|
||||
export function reportRecoveredEditorTransformError(reason: RecoveryReason, error: unknown): void {
|
||||
function shouldRepairEditorDocument(error: unknown): boolean {
|
||||
return isRecoverableRangeError(error) || isInvalidBlockJoinError(error)
|
||||
}
|
||||
|
||||
export const reportRecoveredEditorTransformError = (reason: RecoveryReason, error: unknown): void => {
|
||||
console.warn('[editor] Recovered rich-editor transform error:', error)
|
||||
trackEvent('rich_editor_transform_error_recovered', { reason })
|
||||
}
|
||||
@@ -132,7 +176,7 @@ function createRecoveringDispatch(
|
||||
} catch (error) {
|
||||
if (!isRecoverableEditorTransformError(error)) throw error
|
||||
|
||||
if (isInvalidContentTransactionError(error)) {
|
||||
if (shouldRepairEditorDocument(error)) {
|
||||
activeRecoverDocument(recoveryState)?.()
|
||||
}
|
||||
reportRecoveredEditorTransformError(recoveryReason(error, transaction, view), error)
|
||||
|
||||
@@ -99,12 +99,7 @@ describe('StatusBarBadges extra coverage', () => {
|
||||
expect(screen.getByText('↓ 1 behind')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Status: Synced/)).toBeInTheDocument()
|
||||
|
||||
const pullButton = screen.getByTestId('git-status-pull-btn')
|
||||
fireEvent.mouseEnter(pullButton)
|
||||
expect(pullButton.style.background).toBe('var(--hover)')
|
||||
fireEvent.mouseLeave(pullButton)
|
||||
expect(pullButton.style.background).toBe('transparent')
|
||||
|
||||
const pullButton = screen.getByRole('button', { name: 'Pull' })
|
||||
fireEvent.click(pullButton)
|
||||
expect(onTriggerSync).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByTestId('git-status-popup')).not.toBeInTheDocument()
|
||||
|
||||
@@ -8,6 +8,8 @@ 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 { gitRepositoryLabel, type GitRepositoryOption } from '../../utils/gitRepositories'
|
||||
import { GitRepositorySelect } from '../GitRepositorySelect'
|
||||
import { useDismissibleLayer } from './useDismissibleLayer'
|
||||
import { ICON_STYLE, SEP_STYLE } from './styles'
|
||||
|
||||
@@ -43,6 +45,16 @@ function formatSyncLabel(locale: AppLocale, status: SyncStatus, lastSyncTime: nu
|
||||
return labelKey ? translate(locale, labelKey) : formatElapsedSync(locale, lastSyncTime)
|
||||
}
|
||||
|
||||
function formatSyncBadgeLabel(
|
||||
locale: AppLocale,
|
||||
status: SyncStatus,
|
||||
lastSyncTime: number | null,
|
||||
repositoryLabel?: string | null,
|
||||
): string {
|
||||
const label = formatSyncLabel(locale, status, lastSyncTime)
|
||||
return repositoryLabel ? `${repositoryLabel} · ${label}` : label
|
||||
}
|
||||
|
||||
function syncIconColor(status: SyncStatus): string {
|
||||
return SYNC_COLORS.get(status) ?? 'var(--accent-green)'
|
||||
}
|
||||
@@ -415,29 +427,19 @@ function PullAction({
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 6, borderTop: '1px solid var(--border)', paddingTop: 6 }}>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
onPull?.()
|
||||
onClose()
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '3px 8px',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
color: 'var(--foreground)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={(event) => { event.currentTarget.style.background = 'transparent' }}
|
||||
className="h-6 gap-1 rounded-sm border-border bg-transparent px-2 text-[11px] text-foreground hover:bg-[var(--hover)]"
|
||||
data-testid="git-status-pull-btn"
|
||||
>
|
||||
<ArrowDown size={11} />{translate(locale, 'status.sync.pull')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -445,12 +447,18 @@ function PullAction({
|
||||
function GitStatusPopup({
|
||||
status,
|
||||
remoteStatus,
|
||||
repositories = [],
|
||||
selectedRepositoryPath,
|
||||
onRepositoryChange,
|
||||
locale = 'en',
|
||||
onPull,
|
||||
onClose,
|
||||
}: {
|
||||
status: SyncStatus
|
||||
remoteStatus: GitRemoteStatus | null
|
||||
repositories?: GitRepositoryOption[]
|
||||
selectedRepositoryPath?: string
|
||||
onRepositoryChange?: (path: string) => void
|
||||
locale?: AppLocale
|
||||
onPull?: () => void
|
||||
onClose: () => void
|
||||
@@ -474,6 +482,17 @@ function GitStatusPopup({
|
||||
color: 'var(--foreground)',
|
||||
}}
|
||||
>
|
||||
{repositories.length > 1 && selectedRepositoryPath && onRepositoryChange && (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<GitRepositorySelect
|
||||
label={translate(locale, 'git.repository.select')}
|
||||
repositories={repositories}
|
||||
selectedPath={selectedRepositoryPath}
|
||||
onChange={onRepositoryChange}
|
||||
testId="git-status-repository-select"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
|
||||
<GitBranch size={13} style={{ color: 'var(--muted-foreground)' }} />
|
||||
<span style={{ fontWeight: 500 }}>{remoteStatus?.branch || '—'}</span>
|
||||
@@ -638,6 +657,9 @@ export function SyncBadge({
|
||||
status,
|
||||
lastSyncTime,
|
||||
remoteStatus,
|
||||
repositories,
|
||||
selectedRepositoryPath,
|
||||
onRepositoryChange,
|
||||
onTriggerSync,
|
||||
onPullAndPush,
|
||||
onOpenConflictResolver,
|
||||
@@ -647,6 +669,9 @@ export function SyncBadge({
|
||||
status: SyncStatus
|
||||
lastSyncTime: number | null
|
||||
remoteStatus?: GitRemoteStatus | null
|
||||
repositories?: GitRepositoryOption[]
|
||||
selectedRepositoryPath?: string
|
||||
onRepositoryChange?: (path: string) => void
|
||||
onTriggerSync?: () => void
|
||||
onPullAndPush?: () => void
|
||||
onOpenConflictResolver?: () => void
|
||||
@@ -656,6 +681,9 @@ export function SyncBadge({
|
||||
const [showPopup, setShowPopup] = useState(false)
|
||||
const popupRef = useRef<HTMLDivElement>(null)
|
||||
const isSyncing = status === 'syncing'
|
||||
const selectedRepositoryLabel = selectedRepositoryPath && repositories
|
||||
? gitRepositoryLabel(selectedRepositoryPath, repositories)
|
||||
: null
|
||||
|
||||
useDismissibleLayer(showPopup, popupRef, () => setShowPopup(false))
|
||||
|
||||
@@ -678,13 +706,16 @@ export function SyncBadge({
|
||||
<StatusBarAction copy={syncBadgeTooltipCopy(locale, status)} onClick={handleClick} testId="status-sync" compact={compact}>
|
||||
<span style={ICON_STYLE}>
|
||||
<SyncStatusIcon status={status} color={syncIconColor(status)} spinning={isSyncing} />
|
||||
{compact ? null : formatSyncLabel(locale, status, lastSyncTime)}
|
||||
{compact ? null : formatSyncBadgeLabel(locale, status, lastSyncTime, selectedRepositoryLabel)}
|
||||
</span>
|
||||
</StatusBarAction>
|
||||
{showPopup && (
|
||||
<GitStatusPopup
|
||||
status={status}
|
||||
remoteStatus={remoteStatus ?? null}
|
||||
repositories={repositories}
|
||||
selectedRepositoryPath={selectedRepositoryPath}
|
||||
onRepositoryChange={onRepositoryChange}
|
||||
locale={locale}
|
||||
onPull={onTriggerSync}
|
||||
onClose={() => setShowPopup(false)}
|
||||
|
||||
@@ -31,6 +31,7 @@ import { ICON_STYLE, SEP_STYLE } from './styles'
|
||||
import type { VaultOption } from './types'
|
||||
import { VaultMenu } from './VaultMenu'
|
||||
import { formatShortcutDisplay } from '../../hooks/appCommandCatalog'
|
||||
import type { GitRepositoryOption } from '../../utils/gitRepositories'
|
||||
|
||||
const SETTINGS_SHORTCUT = {
|
||||
shortcut: formatShortcutDisplay({ display: '⌘,' }),
|
||||
@@ -66,6 +67,9 @@ interface StatusBarPrimarySectionProps {
|
||||
lastSyncTime: number | null
|
||||
conflictCount: number
|
||||
remoteStatus?: GitRemoteStatus | null
|
||||
repositories?: GitRepositoryOption[]
|
||||
selectedRepositoryPath?: string
|
||||
onRepositoryChange?: (path: string) => void
|
||||
onTriggerSync?: () => void
|
||||
onPullAndPush?: () => void
|
||||
onOpenConflictResolver?: () => void
|
||||
@@ -194,6 +198,9 @@ function StatusBarAiBadge({
|
||||
function StatusBarPrimaryBadges({
|
||||
modifiedCount,
|
||||
visibleRemoteStatus,
|
||||
repositories,
|
||||
selectedRepositoryPath,
|
||||
onRepositoryChange,
|
||||
onAddRemote,
|
||||
onClickPending,
|
||||
onCommitPush,
|
||||
@@ -227,6 +234,9 @@ function StatusBarPrimaryBadges({
|
||||
}: {
|
||||
modifiedCount: number
|
||||
visibleRemoteStatus: GitRemoteStatus | null
|
||||
repositories?: GitRepositoryOption[]
|
||||
selectedRepositoryPath?: string
|
||||
onRepositoryChange?: (path: string) => void
|
||||
onAddRemote: () => void
|
||||
onClickPending?: () => void
|
||||
onCommitPush?: () => void
|
||||
@@ -271,6 +281,9 @@ function StatusBarPrimaryBadges({
|
||||
status={syncStatus}
|
||||
lastSyncTime={lastSyncTime}
|
||||
remoteStatus={visibleRemoteStatus}
|
||||
repositories={repositories}
|
||||
selectedRepositoryPath={selectedRepositoryPath}
|
||||
onRepositoryChange={onRepositoryChange}
|
||||
onTriggerSync={onTriggerSync}
|
||||
onPullAndPush={onPullAndPush}
|
||||
onOpenConflictResolver={onOpenConflictResolver}
|
||||
@@ -408,6 +421,103 @@ function PrimarySeparator({ compact }: { compact: boolean }) {
|
||||
return compact ? null : <span style={SEP_STYLE}>|</span>
|
||||
}
|
||||
|
||||
function StatusBarGitControls({
|
||||
modifiedCount,
|
||||
vaultPath,
|
||||
onAddRemote,
|
||||
onClickPending,
|
||||
onCommitPush,
|
||||
commitActionPending,
|
||||
gitFeaturesEnabled,
|
||||
onInitializeGit,
|
||||
isOffline,
|
||||
isVaultReloading,
|
||||
isGitVault,
|
||||
syncStatus,
|
||||
lastSyncTime,
|
||||
conflictCount,
|
||||
remoteStatus,
|
||||
repositories,
|
||||
selectedRepositoryPath,
|
||||
onRepositoryChange,
|
||||
onTriggerSync,
|
||||
onPullAndPush,
|
||||
onOpenConflictResolver,
|
||||
onClickPulse,
|
||||
mcpStatus,
|
||||
onInstallMcp,
|
||||
aiAgentsStatus,
|
||||
vaultAiGuidanceStatus,
|
||||
defaultAiAgent,
|
||||
defaultAiTarget,
|
||||
aiModelProviders,
|
||||
onSetDefaultAiAgent,
|
||||
onSetDefaultAiTarget,
|
||||
onRestoreVaultAiGuidance,
|
||||
claudeCodeStatus,
|
||||
claudeCodeVersion,
|
||||
compact,
|
||||
locale,
|
||||
}: StatusBarPrimarySectionProps & { compact: boolean; locale: AppLocale }) {
|
||||
const gitVaultPath = selectedRepositoryPath || vaultPath
|
||||
const { openAddRemote, closeAddRemote, showAddRemote, visibleRemoteStatus, handleRemoteConnected } = useStatusBarAddRemote({
|
||||
vaultPath: gitVaultPath,
|
||||
isGitVault: gitFeaturesEnabled !== false && isGitVault !== false,
|
||||
remoteStatus,
|
||||
onAddRemote,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusBarPrimaryBadges
|
||||
modifiedCount={modifiedCount}
|
||||
visibleRemoteStatus={visibleRemoteStatus}
|
||||
repositories={repositories}
|
||||
selectedRepositoryPath={gitVaultPath}
|
||||
onRepositoryChange={onRepositoryChange}
|
||||
onAddRemote={() => {
|
||||
void openAddRemote()
|
||||
}}
|
||||
onClickPending={onClickPending}
|
||||
onCommitPush={onCommitPush}
|
||||
commitActionPending={commitActionPending}
|
||||
gitFeaturesEnabled={gitFeaturesEnabled !== false}
|
||||
onInitializeGit={onInitializeGit}
|
||||
syncStatus={syncStatus}
|
||||
lastSyncTime={lastSyncTime}
|
||||
onTriggerSync={onTriggerSync}
|
||||
onPullAndPush={onPullAndPush}
|
||||
onOpenConflictResolver={onOpenConflictResolver}
|
||||
conflictCount={conflictCount}
|
||||
onClickPulse={onClickPulse}
|
||||
isGitVault={isGitVault !== false}
|
||||
mcpStatus={mcpStatus}
|
||||
onInstallMcp={onInstallMcp}
|
||||
aiAgentsStatus={aiAgentsStatus}
|
||||
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
defaultAiTarget={defaultAiTarget}
|
||||
aiModelProviders={aiModelProviders}
|
||||
onSetDefaultAiAgent={onSetDefaultAiAgent}
|
||||
onSetDefaultAiTarget={onSetDefaultAiTarget}
|
||||
onRestoreVaultAiGuidance={onRestoreVaultAiGuidance}
|
||||
claudeCodeStatus={claudeCodeStatus}
|
||||
claudeCodeVersion={claudeCodeVersion}
|
||||
isOffline={isOffline === true}
|
||||
isVaultReloading={isVaultReloading === true}
|
||||
compact={compact}
|
||||
locale={locale}
|
||||
/>
|
||||
<AddRemoteModal
|
||||
open={showAddRemote}
|
||||
vaultPath={gitVaultPath}
|
||||
onClose={closeAddRemote}
|
||||
onRemoteConnected={handleRemoteConnected}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusBarPrimarySection({
|
||||
modifiedCount,
|
||||
vaultPath,
|
||||
@@ -430,6 +540,9 @@ export function StatusBarPrimarySection({
|
||||
lastSyncTime,
|
||||
conflictCount,
|
||||
remoteStatus,
|
||||
repositories,
|
||||
selectedRepositoryPath,
|
||||
onRepositoryChange,
|
||||
onTriggerSync,
|
||||
onPullAndPush,
|
||||
onOpenConflictResolver,
|
||||
@@ -454,13 +567,6 @@ export function StatusBarPrimarySection({
|
||||
stacked = false,
|
||||
compact = false,
|
||||
}: StatusBarPrimarySectionProps) {
|
||||
const { openAddRemote, closeAddRemote, showAddRemote, visibleRemoteStatus, handleRemoteConnected } = useStatusBarAddRemote({
|
||||
vaultPath,
|
||||
isGitVault: gitFeaturesEnabled && isGitVault,
|
||||
remoteStatus,
|
||||
onAddRemote,
|
||||
})
|
||||
|
||||
return (
|
||||
<div style={primarySectionStyle(stacked, compact)}>
|
||||
<VaultMenu
|
||||
@@ -481,12 +587,16 @@ export function StatusBarPrimarySection({
|
||||
/>
|
||||
<PrimarySeparator compact={compact} />
|
||||
<BuildNumberButton buildNumber={buildNumber} onCheckForUpdates={onCheckForUpdates} compact={compact} locale={locale} />
|
||||
<StatusBarPrimaryBadges
|
||||
<StatusBarGitControls
|
||||
modifiedCount={modifiedCount}
|
||||
visibleRemoteStatus={visibleRemoteStatus}
|
||||
onAddRemote={() => {
|
||||
void openAddRemote()
|
||||
}}
|
||||
vaultPath={vaultPath}
|
||||
vaults={vaults}
|
||||
onSwitchVault={onSwitchVault}
|
||||
remoteStatus={remoteStatus}
|
||||
repositories={repositories}
|
||||
selectedRepositoryPath={selectedRepositoryPath}
|
||||
onRepositoryChange={onRepositoryChange}
|
||||
onAddRemote={onAddRemote}
|
||||
onClickPending={onClickPending}
|
||||
onCommitPush={onCommitPush}
|
||||
commitActionPending={commitActionPending}
|
||||
@@ -516,12 +626,6 @@ export function StatusBarPrimarySection({
|
||||
compact={compact}
|
||||
locale={locale}
|
||||
/>
|
||||
<AddRemoteModal
|
||||
open={showAddRemote}
|
||||
vaultPath={vaultPath}
|
||||
onClose={closeAddRemote}
|
||||
onRemoteConnected={handleRemoteConnected}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -367,6 +367,20 @@ describe('TolariaSideMenu', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('hides table header actions when the live block lookup throws after reload churn', () => {
|
||||
const staleTable = {
|
||||
id: 'table-block',
|
||||
type: 'table',
|
||||
content: { type: 'tableContent', rows: [], headerRows: undefined },
|
||||
}
|
||||
mockEditor.getBlock.mockImplementation(() => {
|
||||
throw staleBlockError(staleTable)
|
||||
})
|
||||
|
||||
expect(() => renderSideMenuWithBlock(staleTable)).not.toThrow()
|
||||
expect(screen.queryByText('Header row')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ignores stale drag starts after reload churn', () => {
|
||||
renderSideMenuWithBlock(sideMenuBlock)
|
||||
|
||||
@@ -386,6 +400,18 @@ describe('TolariaSideMenu', () => {
|
||||
expect(mockEditor.insertBlocks).toHaveBeenCalledWith([draggedBlock], targetBlock.id, 'before')
|
||||
})
|
||||
|
||||
it('ignores pointer reorders when a target block lookup throws after reload churn', () => {
|
||||
const { draggedBlock, dragHandle, targetBlock } = renderPointerReorderFixture()
|
||||
mockEditor.getBlock.mockImplementation((id: string) => {
|
||||
if (id === targetBlock.id) throw staleBlockError(id)
|
||||
return id === draggedBlock.id ? draggedBlock : undefined
|
||||
})
|
||||
|
||||
expect(() => dispatchHandlePointerReorder(dragHandle)).not.toThrow()
|
||||
expect(mockEditor.removeBlocks).not.toHaveBeenCalled()
|
||||
expect(mockEditor.insertBlocks).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows and clears pointer reorder affordances while dragging', () => {
|
||||
const { draggedElement, dragHandle } = renderPointerReorderFixture()
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { isStaleBlockReferenceError } from './richEditorTransformErrorRecoveryExtension'
|
||||
|
||||
type TolariaBlockNoteEditor = BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>
|
||||
type TolariaBlock = NonNullable<ReturnType<TolariaBlockNoteEditor['getBlock']>>
|
||||
@@ -83,14 +84,26 @@ const SIDE_MENU_ALIGNMENT_ATTEMPTS = 8
|
||||
|
||||
function liveSideMenuBlock(editor: TolariaBlockNoteEditor, block: SideMenuBlock | undefined) {
|
||||
if (!block) return undefined
|
||||
return editor.getBlock(block.id)
|
||||
try {
|
||||
return editor.getBlock(block.id)
|
||||
} catch (error) {
|
||||
if (isStaleBlockReferenceError(error)) {
|
||||
console.warn('[editor] Ignored stale block side-menu lookup:', error)
|
||||
return undefined
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function runSideMenuAction(action: () => void) {
|
||||
try {
|
||||
action()
|
||||
} catch (error) {
|
||||
console.warn('[editor] Ignored stale block side-menu action:', error)
|
||||
if (isStaleBlockReferenceError(error)) {
|
||||
console.warn('[editor] Ignored stale block side-menu action:', error)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,8 +470,8 @@ function validDropTarget({
|
||||
const blockId = blockIdFromElement(targetElement)
|
||||
if (!blockId || blockId === state.draggedBlockId) return null
|
||||
|
||||
const draggedBlock = editor.getBlock(state.draggedBlockId)
|
||||
const targetBlock = editor.getBlock(blockId)
|
||||
const draggedBlock = liveSideMenuBlock(editor, { id: state.draggedBlockId, type: '' })
|
||||
const targetBlock = liveSideMenuBlock(editor, { id: blockId, type: '' })
|
||||
if (!draggedBlock || !targetBlock || hasChildBlock(draggedBlock, blockId)) return null
|
||||
|
||||
return {
|
||||
@@ -481,15 +494,15 @@ function moveBlockByPointerDrop({
|
||||
}): boolean {
|
||||
if (draggedBlockId === targetBlockId) return false
|
||||
|
||||
const draggedBlock = editor.getBlock(draggedBlockId)
|
||||
const targetBlock = editor.getBlock(targetBlockId)
|
||||
const draggedBlock = liveSideMenuBlock(editor, { id: draggedBlockId, type: '' })
|
||||
const targetBlock = liveSideMenuBlock(editor, { id: targetBlockId, type: '' })
|
||||
if (!draggedBlock || !targetBlock || hasChildBlock(draggedBlock, targetBlockId)) return false
|
||||
|
||||
let moved = false
|
||||
editor.focus()
|
||||
editor.transact(() => {
|
||||
const currentDraggedBlock = editor.getBlock(draggedBlockId)
|
||||
const currentTargetBlock = editor.getBlock(targetBlockId)
|
||||
const currentDraggedBlock = liveSideMenuBlock(editor, { id: draggedBlockId, type: '' })
|
||||
const currentTargetBlock = liveSideMenuBlock(editor, { id: targetBlockId, type: '' })
|
||||
if (!currentDraggedBlock || !currentTargetBlock) return
|
||||
if (hasChildBlock(currentDraggedBlock, targetBlockId)) return
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ vi.mock('@blocknote/react', () => ({
|
||||
|
||||
vi.mock('@blocknote/core', () => ({
|
||||
blockHasType: blockHasTypeMock,
|
||||
createExtension: (factory: unknown) => factory,
|
||||
defaultProps: { textAlignment: 'left' },
|
||||
editorHasBlockWithType: editorHasBlockWithTypeMock,
|
||||
}))
|
||||
@@ -160,6 +161,7 @@ function createMockEditor(blockType = 'image', props: Record<string, unknown> =
|
||||
domElement,
|
||||
focus: vi.fn(),
|
||||
getActiveStyles: () => ({ bold: true }),
|
||||
getBlock: vi.fn((id: string) => (id === selectedBlock.id ? selectedBlock : undefined)),
|
||||
getSelection: () => ({ blocks: [selectedBlock] }),
|
||||
getTextCursorPosition: () => ({ block: selectedBlock }),
|
||||
toggleStyles: vi.fn(),
|
||||
@@ -192,11 +194,27 @@ describe('tolariaEditorFormatting behavior', () => {
|
||||
expect(editor.toggleStyles).toHaveBeenCalledWith({ code: true })
|
||||
expect(editor.transact).toHaveBeenCalledTimes(1)
|
||||
expect(editor.updateBlock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'file-block' }),
|
||||
'file-block',
|
||||
{ type: 'heading', props: { level: 1 } },
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores stale block-type clicks when the selected block disappeared before the action', () => {
|
||||
const editor = createMockEditor('paragraph')
|
||||
editor.getBlock.mockImplementation(() => {
|
||||
throw new Error('Block with ID file-block not found')
|
||||
})
|
||||
useBlockNoteEditorMock.mockReturnValue(editor)
|
||||
|
||||
render(<TolariaFormattingToolbar />)
|
||||
|
||||
expect(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Heading 1' }))
|
||||
}).not.toThrow()
|
||||
expect(editor.transact).not.toHaveBeenCalled()
|
||||
expect(editor.updateBlock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens selected file blocks through the active vault path', () => {
|
||||
const editor = createMockEditor('file', {
|
||||
url: 'asset://localhost/%2Fvault%2Fattachments%2Freport.pdf',
|
||||
|
||||
@@ -59,6 +59,10 @@ import {
|
||||
} from './tolariaEditorFormattingConfig'
|
||||
import { useBlockNoteFormattingToolbarHoverGuard } from './blockNoteFormattingToolbarHoverGuard'
|
||||
import { openEditorAttachmentOrUrl } from './editorAttachmentActions'
|
||||
import {
|
||||
isStaleBlockReferenceError,
|
||||
reportRecoveredEditorTransformError,
|
||||
} from './richEditorTransformErrorRecoveryExtension'
|
||||
|
||||
type TolariaBasicTextStyle = 'bold' | 'italic' | 'strike' | 'code'
|
||||
|
||||
@@ -359,6 +363,40 @@ function getSelectedFileBlockState(
|
||||
: null
|
||||
}
|
||||
|
||||
function reportStaleFormattingToolbarBlockReference(error: unknown) {
|
||||
reportRecoveredEditorTransformError('stale_block_reference', error)
|
||||
}
|
||||
|
||||
function liveSelectedBlock(
|
||||
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
|
||||
block: TolariaSelectedBlock,
|
||||
) {
|
||||
try {
|
||||
return editor.getBlock(block.id) as TolariaSelectedBlock | undefined
|
||||
} catch (error) {
|
||||
if (isStaleBlockReferenceError(error)) {
|
||||
reportStaleFormattingToolbarBlockReference(error)
|
||||
return undefined
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function liveSelectedBlocks(
|
||||
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
|
||||
selectedBlocks: TolariaSelectedBlock[],
|
||||
) {
|
||||
const liveBlocks: TolariaSelectedBlock[] = []
|
||||
|
||||
for (const block of selectedBlocks) {
|
||||
const liveBlock = liveSelectedBlock(editor, block)
|
||||
if (!liveBlock) return []
|
||||
liveBlocks.push(liveBlock)
|
||||
}
|
||||
|
||||
return liveBlocks
|
||||
}
|
||||
|
||||
function fileDownloadTooltip(dict: unknown, blockType: string): string {
|
||||
const tooltip = (dict as {
|
||||
formatting_toolbar?: {
|
||||
@@ -383,15 +421,26 @@ function updateSelectedBlocksToType(
|
||||
selectedBlocks: TolariaSelectedBlock[],
|
||||
item: ReturnType<typeof getTolariaBlockTypeSelectItems>[number],
|
||||
) {
|
||||
editor.focus()
|
||||
editor.transact(() => {
|
||||
for (const block of selectedBlocks) {
|
||||
editor.updateBlock(block, {
|
||||
type: item.type as never,
|
||||
props: item.props as never,
|
||||
})
|
||||
const blocks = liveSelectedBlocks(editor, selectedBlocks)
|
||||
if (!blocks.length) return
|
||||
|
||||
try {
|
||||
editor.focus()
|
||||
editor.transact(() => {
|
||||
for (const block of blocks) {
|
||||
editor.updateBlock(block.id, {
|
||||
type: item.type as never,
|
||||
props: item.props as never,
|
||||
})
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
if (isStaleBlockReferenceError(error)) {
|
||||
reportStaleFormattingToolbarBlockReference(error)
|
||||
return
|
||||
}
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function TolariaBasicTextStyleButton({
|
||||
|
||||
@@ -1,29 +1,65 @@
|
||||
import type { CommandAction } from './types'
|
||||
import type { SidebarSelection } from '../../types'
|
||||
import type { GitRepositoryOption } from '../../utils/gitRepositories'
|
||||
|
||||
interface GitCommandsConfig {
|
||||
modifiedCount: number
|
||||
canAddRemote: boolean
|
||||
gitFeaturesEnabled?: boolean
|
||||
isGitVault?: boolean
|
||||
repositories?: GitRepositoryOption[]
|
||||
onAddRemote?: () => void
|
||||
onCommitPush: () => void
|
||||
onInitializeGit?: () => void
|
||||
onPull?: () => void
|
||||
onPullRepository?: (path: string) => void
|
||||
onResolveConflicts?: () => void
|
||||
onSelect: (sel: SidebarSelection) => void
|
||||
}
|
||||
|
||||
type PullableRepositoryList = [GitRepositoryOption, GitRepositoryOption, ...GitRepositoryOption[]]
|
||||
|
||||
function hasRepositoryPullTargets(
|
||||
repositories: GitRepositoryOption[] | undefined,
|
||||
): repositories is PullableRepositoryList {
|
||||
if (!repositories) return false
|
||||
return repositories.length > 1
|
||||
}
|
||||
|
||||
function buildPullCommands({
|
||||
onPull,
|
||||
onPullRepository,
|
||||
repositories,
|
||||
}: Pick<GitCommandsConfig, 'onPull' | 'onPullRepository' | 'repositories'>): CommandAction[] {
|
||||
if (onPullRepository && hasRepositoryPullTargets(repositories)) {
|
||||
const pullRepository = onPullRepository
|
||||
return repositories.map((repository, index) => ({
|
||||
id: `git-pull-${index}`,
|
||||
label: `Pull from Remote: ${repository.label}`,
|
||||
group: 'Git',
|
||||
keywords: ['git', 'pull', 'fetch', 'download', 'sync', 'remote', repository.label, repository.path],
|
||||
enabled: true,
|
||||
execute: () => pullRepository(repository.path),
|
||||
}))
|
||||
}
|
||||
|
||||
return [
|
||||
{ id: 'git-pull', label: 'Pull from Remote', group: 'Git', keywords: ['git', 'pull', 'fetch', 'download', 'sync', 'remote'], enabled: true, execute: () => onPull?.() },
|
||||
]
|
||||
}
|
||||
|
||||
export function buildGitCommands(config: GitCommandsConfig): CommandAction[] {
|
||||
const {
|
||||
modifiedCount,
|
||||
canAddRemote,
|
||||
gitFeaturesEnabled = true,
|
||||
isGitVault = true,
|
||||
repositories,
|
||||
onAddRemote,
|
||||
onCommitPush,
|
||||
onInitializeGit,
|
||||
onPull,
|
||||
onPullRepository,
|
||||
onResolveConflicts,
|
||||
onSelect,
|
||||
} = config
|
||||
@@ -46,7 +82,7 @@ export function buildGitCommands(config: GitCommandsConfig): CommandAction[] {
|
||||
return [
|
||||
{ id: 'commit-push', label: 'Commit & Push', group: 'Git', keywords: ['git', 'save', 'sync'], enabled: modifiedCount > 0, execute: onCommitPush },
|
||||
{ id: 'add-remote', label: 'Add Remote to Current Vault', group: 'Git', keywords: ['git', 'remote', 'connect', 'origin', 'no remote'], enabled: canAddRemote && !!onAddRemote, execute: () => onAddRemote?.() },
|
||||
{ id: 'git-pull', label: 'Pull from Remote', group: 'Git', keywords: ['git', 'pull', 'fetch', 'download', 'sync', 'remote'], enabled: true, execute: () => onPull?.() },
|
||||
...buildPullCommands({ onPull, onPullRepository, repositories }),
|
||||
{ id: 'resolve-conflicts', label: 'Resolve Conflicts', group: 'Git', keywords: ['conflict', 'merge', 'git', 'sync'], enabled: true, execute: () => onResolveConflicts?.() },
|
||||
{ id: 'view-changes', label: 'View Pending Changes', group: 'Git', keywords: ['modified', 'diff'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
|
||||
]
|
||||
|
||||
@@ -159,6 +159,16 @@ function localizeSettingsStateCommand(command: CommandAction, t: Translate): str
|
||||
return null
|
||||
}
|
||||
|
||||
function localizeGitStateCommand(command: CommandAction, t: Translate): string | null {
|
||||
if (command.id.startsWith('git-pull-')) {
|
||||
return t('command.git.pullRepository', {
|
||||
repository: stripKnownPrefix(command.label, 'Pull from Remote: '),
|
||||
})
|
||||
}
|
||||
|
||||
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 ') })
|
||||
@@ -184,6 +194,7 @@ export function localizeCommandActions(commands: CommandAction[], locale: AppLoc
|
||||
: localizeNoteStateCommand(command, t)
|
||||
?? localizeViewStateCommand(command, t)
|
||||
?? localizeSettingsStateCommand(command, t)
|
||||
?? localizeGitStateCommand(command, t)
|
||||
?? localizeTypeCommand(command, t)
|
||||
?? command.label
|
||||
return label === command.label ? command : { ...command, label }
|
||||
|
||||
@@ -272,6 +272,7 @@ export interface FrontmatterApplyCallbacks {
|
||||
updateEntry: (path: VaultPath, patch: Partial<VaultEntry>) => void
|
||||
toast: (message: ToastMessage) => void
|
||||
getEntry?: (path: VaultPath) => VaultEntry | undefined
|
||||
onMissingNotePath?: (path: VaultPath, error: unknown) => void | Promise<void>
|
||||
shouldApply?: (path: VaultPath) => boolean
|
||||
}
|
||||
|
||||
@@ -339,15 +340,52 @@ function failureToastMessage(op: FrontmatterOp): ToastMessage {
|
||||
return `Failed to ${op} property`
|
||||
}
|
||||
|
||||
function handleFrontmatterFailure(
|
||||
op: FrontmatterOp,
|
||||
err: unknown,
|
||||
function frontmatterErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'string') return error
|
||||
return String(error)
|
||||
}
|
||||
|
||||
export function isMissingFrontmatterTargetError(error: unknown): boolean {
|
||||
return /does not exist|not found|enoent/i.test(frontmatterErrorMessage(error))
|
||||
}
|
||||
|
||||
async function runMissingNotePathCallback(
|
||||
path: VaultPath,
|
||||
error: unknown,
|
||||
callbacks: FrontmatterApplyCallbacks,
|
||||
options?: FrontmatterOpOptions,
|
||||
): undefined {
|
||||
console.error(`Failed to ${op} frontmatter:`, err)
|
||||
): Promise<void> {
|
||||
try {
|
||||
await callbacks.onMissingNotePath?.(path, error)
|
||||
} catch (callbackError) {
|
||||
console.warn('Failed to handle missing frontmatter target:', callbackError)
|
||||
}
|
||||
}
|
||||
|
||||
interface FrontmatterFailureParams {
|
||||
callbacks: FrontmatterApplyCallbacks
|
||||
err: unknown
|
||||
op: FrontmatterOp
|
||||
options?: FrontmatterOpOptions
|
||||
path: VaultPath
|
||||
}
|
||||
|
||||
async function handleFrontmatterFailure({
|
||||
callbacks,
|
||||
err,
|
||||
op,
|
||||
options,
|
||||
path,
|
||||
}: FrontmatterFailureParams): Promise<undefined> {
|
||||
const missingTarget = isMissingFrontmatterTargetError(err)
|
||||
if (missingTarget) {
|
||||
console.warn(`Skipped ${op} frontmatter for missing note:`, err)
|
||||
await runMissingNotePathCallback(path, err, callbacks)
|
||||
} else {
|
||||
console.error(`Failed to ${op} frontmatter:`, err)
|
||||
}
|
||||
if (options?.silent) throw err
|
||||
callbacks.toast(failureToastMessage(op))
|
||||
if (!missingTarget) callbacks.toast(failureToastMessage(op))
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -363,6 +401,6 @@ export async function runFrontmatterAndApply(request: FrontmatterRunRequest): Pr
|
||||
notifyFrontmatterSuccess(op, callbacks, options)
|
||||
return newContent
|
||||
} catch (err) {
|
||||
return handleFrontmatterFailure(op, err, callbacks, options)
|
||||
return handleFrontmatterFailure({ op, path, err, callbacks, options })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { requestAddRemote } from '../utils/addRemoteEvents'
|
||||
import type { NoteListFilter } from '../utils/noteListHelpers'
|
||||
import type { ViewMode } from './useViewMode'
|
||||
import type { NoteListMultiSelectionCommands } from '../components/note-list/multiSelectionCommands'
|
||||
import type { GitRepositoryOption } from '../utils/gitRepositories'
|
||||
|
||||
interface AppCommandsConfig {
|
||||
activeTabPath: string | null
|
||||
@@ -38,6 +39,7 @@ interface AppCommandsConfig {
|
||||
onUnarchiveNote: (path: string) => void
|
||||
onCommitPush: () => void
|
||||
onPull?: () => void
|
||||
onPullRepository?: (path: string) => void
|
||||
onResolveConflicts?: () => void
|
||||
onSetViewMode: (mode: ViewMode) => void
|
||||
onToggleInspector: () => void
|
||||
@@ -73,6 +75,7 @@ interface AppCommandsConfig {
|
||||
canAddRemote?: boolean
|
||||
gitFeaturesEnabled?: boolean
|
||||
isGitVault?: boolean
|
||||
gitRepositories?: GitRepositoryOption[]
|
||||
onInitializeGit?: () => void
|
||||
onCreateType?: () => void
|
||||
aiFeaturesEnabled?: boolean
|
||||
@@ -164,6 +167,7 @@ type CommandRegistryCoreActions = Pick<
|
||||
| 'onUnarchiveNote'
|
||||
| 'onCommitPush'
|
||||
| 'onPull'
|
||||
| 'onPullRepository'
|
||||
| 'onResolveConflicts'
|
||||
| 'onSetViewMode'
|
||||
| 'onToggleInspector'
|
||||
@@ -189,6 +193,7 @@ type CommandRegistryVaultActions = Pick<
|
||||
| 'canAddRemote'
|
||||
| 'gitFeaturesEnabled'
|
||||
| 'isGitVault'
|
||||
| 'gitRepositories'
|
||||
| 'onInitializeGit'
|
||||
| 'onCheckForUpdates'
|
||||
| 'onCreateType'
|
||||
@@ -451,6 +456,7 @@ function createCommandRegistryCoreConfig(
|
||||
onUnarchiveNote: config.onUnarchiveNote,
|
||||
onCommitPush: config.onCommitPush,
|
||||
onPull: config.onPull,
|
||||
onPullRepository: config.onPullRepository,
|
||||
onResolveConflicts: config.onResolveConflicts,
|
||||
onSetViewMode: config.onSetViewMode,
|
||||
onToggleInspector: config.onToggleInspector,
|
||||
@@ -483,6 +489,7 @@ function createCommandRegistryVaultConfig(
|
||||
canAddRemote: config.canAddRemote ?? true,
|
||||
gitFeaturesEnabled: config.gitFeaturesEnabled,
|
||||
isGitVault: config.isGitVault,
|
||||
gitRepositories: config.gitRepositories,
|
||||
onInitializeGit: config.onInitializeGit,
|
||||
onCheckForUpdates: config.onCheckForUpdates,
|
||||
onCreateType: config.onCreateType,
|
||||
|
||||
@@ -120,7 +120,7 @@ describe('useAutoSync extra', () => {
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hook.onVaultUpdated).toHaveBeenCalledWith(['notes/today.md'])
|
||||
expect(hook.onVaultUpdated).toHaveBeenCalledWith(['notes/today.md'], '/Users/luca/Laputa')
|
||||
expect(hook.onSyncUpdated).toHaveBeenCalledOnce()
|
||||
expect(hook.onToast).toHaveBeenCalledWith('Pulled and pushed successfully')
|
||||
expect(hook.result.current.syncStatus).toBe('idle')
|
||||
|
||||
@@ -96,7 +96,7 @@ describe('useAutoSync', () => {
|
||||
const { result } = renderSync()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onVaultUpdated).toHaveBeenCalledWith(['note.md', 'project/plan.md'])
|
||||
expect(onVaultUpdated).toHaveBeenCalledWith(['note.md', 'project/plan.md'], '/Users/luca/Laputa')
|
||||
expect(onToast).toHaveBeenCalledWith('Pulled 2 update(s) from remote')
|
||||
expect(result.current.syncStatus).toBe('idle')
|
||||
})
|
||||
@@ -124,7 +124,7 @@ describe('useAutoSync', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(asyncVaultRefresh).toHaveBeenCalledWith(['note.md'])
|
||||
expect(asyncVaultRefresh).toHaveBeenCalledWith(['note.md'], '/Users/luca/Laputa')
|
||||
})
|
||||
expect(onToast).not.toHaveBeenCalledWith('Pulled 1 update(s) from remote')
|
||||
|
||||
@@ -417,7 +417,7 @@ describe('useAutoSync', () => {
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onVaultUpdated).toHaveBeenCalledWith(['note.md'])
|
||||
expect(onVaultUpdated).toHaveBeenCalledWith(['note.md'], '/Users/luca/Laputa')
|
||||
expect(onSyncUpdated).toHaveBeenCalled()
|
||||
expect(onToast).toHaveBeenCalledWith('Pulled and pushed successfully')
|
||||
expect(result.current.syncStatus).toBe('idle')
|
||||
@@ -459,6 +459,29 @@ describe('useAutoSync', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('manual triggerSync targets an explicit repository path', async () => {
|
||||
const { result } = renderSync()
|
||||
await waitFor(() => {
|
||||
expect(result.current.syncStatus).toBe('idle')
|
||||
})
|
||||
|
||||
mockInvokeFn.mockClear()
|
||||
mockInvokeFn.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'get_last_commit_info') return Promise.resolve(MOCK_COMMIT_INFO)
|
||||
if (cmd === 'git_remote_status') return Promise.resolve({ branch: 'main', ahead: 0, behind: 0, hasRemote: true })
|
||||
return Promise.resolve(updated(['work.md']))
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
result.current.triggerSync('/Users/luca/Work')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('git_pull', { vaultPath: '/Users/luca/Work' })
|
||||
expect(onVaultUpdated).toHaveBeenCalledWith(['work.md'], '/Users/luca/Work')
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces pull conflicts and pull errors during recovery pushes', async () => {
|
||||
let mode: 'conflict' | 'error' = 'conflict'
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ interface UseAutoSyncOptions {
|
||||
enabled?: boolean
|
||||
vaultPath: string
|
||||
intervalMinutes: number | null
|
||||
onVaultUpdated: (updatedFiles: string[]) => MaybePromise
|
||||
onVaultUpdated: (updatedFiles: string[], vaultPath: string) => MaybePromise
|
||||
onSyncUpdated?: () => MaybePromise
|
||||
onConflict: (files: string[]) => void
|
||||
onToast: (msg: string) => void
|
||||
@@ -32,9 +32,9 @@ export interface AutoSyncState {
|
||||
conflictFiles: string[]
|
||||
lastCommitInfo: LastCommitInfo | null
|
||||
remoteStatus: GitRemoteStatus | null
|
||||
triggerSync: () => void
|
||||
triggerSync: (vaultPath?: string) => void
|
||||
/** Pull from remote, then push if there are local commits ahead. */
|
||||
pullAndPush: () => void
|
||||
pullAndPush: (vaultPath?: string) => void
|
||||
/** Pause auto-pull (e.g. while conflict resolver modal is open). */
|
||||
pausePull: () => void
|
||||
/** Resume auto-pull after pausing. */
|
||||
@@ -82,19 +82,20 @@ function setConflictState(
|
||||
|
||||
function markPullTimestamp(
|
||||
setLastSyncTime: SyncSetState<number | null>,
|
||||
refreshCommitInfo: () => void,
|
||||
refreshCommitInfo: (vaultPath?: string) => void,
|
||||
vaultPath?: string,
|
||||
): void {
|
||||
setLastSyncTime(Date.now())
|
||||
refreshCommitInfo()
|
||||
refreshCommitInfo(vaultPath)
|
||||
}
|
||||
|
||||
function useRemoteStatusRefresher(
|
||||
vaultPath: string,
|
||||
setRemoteStatus: SyncSetState<GitRemoteStatus | null>,
|
||||
) {
|
||||
return useCallback(async () => {
|
||||
return useCallback(async (targetVaultPath = vaultPath) => {
|
||||
try {
|
||||
const status = await tauriCall<GitRemoteStatus>('git_remote_status', { vaultPath })
|
||||
const status = await tauriCall<GitRemoteStatus>('git_remote_status', { vaultPath: targetVaultPath })
|
||||
setRemoteStatus(status)
|
||||
return status
|
||||
} catch {
|
||||
@@ -109,9 +110,9 @@ function useConflictChecker(
|
||||
setConflictFiles: SyncSetState<string[]>,
|
||||
callbacksRef: MutableRefObject<SyncCallbacks>,
|
||||
) {
|
||||
return useCallback(async (): Promise<boolean> => {
|
||||
return useCallback(async (targetVaultPath = vaultPath): Promise<boolean> => {
|
||||
try {
|
||||
const files = await tauriCall<string[]>('get_conflict_files', { vaultPath })
|
||||
const files = await tauriCall<string[]>('get_conflict_files', { vaultPath: targetVaultPath })
|
||||
if (!Array.isArray(files) || files.length === 0) return false
|
||||
setConflictState(files, setSyncStatus, setConflictFiles, callbacksRef)
|
||||
return true
|
||||
@@ -125,8 +126,8 @@ function useCommitInfoRefresher(
|
||||
vaultPath: string,
|
||||
setLastCommitInfo: SyncSetState<LastCommitInfo | null>,
|
||||
) {
|
||||
return useCallback(() => {
|
||||
tauriCall<LastCommitInfo | null>('get_last_commit_info', { vaultPath })
|
||||
return useCallback((targetVaultPath = vaultPath) => {
|
||||
tauriCall<LastCommitInfo | null>('get_last_commit_info', { vaultPath: targetVaultPath })
|
||||
.then(info => setLastCommitInfo(info))
|
||||
.catch((err) => console.warn('[sync] Failed to refresh last commit info:', err))
|
||||
}, [vaultPath, setLastCommitInfo])
|
||||
@@ -134,18 +135,20 @@ function useCommitInfoRefresher(
|
||||
|
||||
async function handleUpdatedPull(options: {
|
||||
result: GitPullResult
|
||||
vaultPath: string
|
||||
callbacksRef: MutableRefObject<SyncCallbacks>
|
||||
setConflictFiles: SyncSetState<string[]>
|
||||
setSyncStatus: SyncSetState<SyncStatus>
|
||||
}): Promise<void> {
|
||||
const {
|
||||
result,
|
||||
vaultPath,
|
||||
callbacksRef,
|
||||
setConflictFiles,
|
||||
setSyncStatus,
|
||||
} = options
|
||||
clearConflictState(setSyncStatus, setConflictFiles)
|
||||
await callbacksRef.current.onVaultUpdated(result.updatedFiles)
|
||||
await callbacksRef.current.onVaultUpdated(result.updatedFiles, vaultPath)
|
||||
await callbacksRef.current.onSyncUpdated?.()
|
||||
await callbacksRef.current.onToast(`Pulled ${result.updatedFiles.length} update(s) from remote`)
|
||||
}
|
||||
@@ -284,7 +287,7 @@ export function useAutoSync({
|
||||
const checkExistingConflicts = useConflictChecker(vaultPath, setSyncStatus, setConflictFiles, callbacksRef)
|
||||
const refreshCommitInfo = useCommitInfoRefresher(vaultPath, setLastCommitInfo)
|
||||
|
||||
const performPull = useCallback(async () => {
|
||||
const performPull = useCallback(async (targetVaultPath = vaultPath) => {
|
||||
if (!enabled) return
|
||||
|
||||
await runSyncTask({
|
||||
@@ -294,12 +297,13 @@ export function useAutoSync({
|
||||
setLastSyncTime,
|
||||
setSyncStatus,
|
||||
task: async () => {
|
||||
const result = await tauriCall<GitPullResult>('git_pull', { vaultPath })
|
||||
markPullTimestamp(setLastSyncTime, refreshCommitInfo)
|
||||
const result = await tauriCall<GitPullResult>('git_pull', { vaultPath: targetVaultPath })
|
||||
markPullTimestamp(setLastSyncTime, refreshCommitInfo, targetVaultPath)
|
||||
|
||||
if (result.status === 'updated') {
|
||||
await handleUpdatedPull({
|
||||
result,
|
||||
vaultPath: targetVaultPath,
|
||||
callbacksRef,
|
||||
setConflictFiles,
|
||||
setSyncStatus,
|
||||
@@ -308,7 +312,7 @@ export function useAutoSync({
|
||||
setConflictState(result.conflictFiles, setSyncStatus, setConflictFiles, callbacksRef)
|
||||
} else if (result.status === 'error') {
|
||||
await resolvePullError({
|
||||
checkExistingConflicts,
|
||||
checkExistingConflicts: () => checkExistingConflicts(targetVaultPath),
|
||||
callbacksRef,
|
||||
setSyncStatus,
|
||||
})
|
||||
@@ -316,13 +320,13 @@ export function useAutoSync({
|
||||
clearConflictState(setSyncStatus, setConflictFiles)
|
||||
}
|
||||
|
||||
void refreshRemoteStatus()
|
||||
void refreshRemoteStatus(targetVaultPath)
|
||||
},
|
||||
})
|
||||
}, [enabled, vaultPath, refreshCommitInfo, checkExistingConflicts, refreshRemoteStatus])
|
||||
|
||||
/** Pull from remote, then auto-push if successful. Used for divergence recovery. */
|
||||
const pullAndPush = useCallback(async () => {
|
||||
const pullAndPush = useCallback(async (targetVaultPath = vaultPath) => {
|
||||
if (!enabled) return
|
||||
|
||||
await runSyncTask({
|
||||
@@ -332,8 +336,8 @@ export function useAutoSync({
|
||||
setLastSyncTime,
|
||||
setSyncStatus,
|
||||
task: async () => {
|
||||
const pullResult = await tauriCall<GitPullResult>('git_pull', { vaultPath })
|
||||
markPullTimestamp(setLastSyncTime, refreshCommitInfo)
|
||||
const pullResult = await tauriCall<GitPullResult>('git_pull', { vaultPath: targetVaultPath })
|
||||
markPullTimestamp(setLastSyncTime, refreshCommitInfo, targetVaultPath)
|
||||
|
||||
if (pullResult.status === 'conflict') {
|
||||
setConflictState(pullResult.conflictFiles, setSyncStatus, setConflictFiles, callbacksRef)
|
||||
@@ -342,7 +346,7 @@ export function useAutoSync({
|
||||
|
||||
if (pullResult.status === 'error') {
|
||||
await resolvePullError({
|
||||
checkExistingConflicts,
|
||||
checkExistingConflicts: () => checkExistingConflicts(targetVaultPath),
|
||||
notifyError: `Pull failed: ${pullResult.message}`,
|
||||
callbacksRef,
|
||||
setSyncStatus,
|
||||
@@ -351,11 +355,11 @@ export function useAutoSync({
|
||||
}
|
||||
|
||||
if (pullResult.status === 'updated') {
|
||||
await callbacksRef.current.onVaultUpdated(pullResult.updatedFiles)
|
||||
await callbacksRef.current.onVaultUpdated(pullResult.updatedFiles, targetVaultPath)
|
||||
await callbacksRef.current.onSyncUpdated?.()
|
||||
}
|
||||
|
||||
const pushResult = await tauriCall<GitPushResult>('git_push', { vaultPath })
|
||||
const pushResult = await tauriCall<GitPushResult>('git_push', { vaultPath: targetVaultPath })
|
||||
handlePushResult({
|
||||
pushResult,
|
||||
callbacksRef,
|
||||
@@ -363,7 +367,7 @@ export function useAutoSync({
|
||||
setSyncStatus,
|
||||
})
|
||||
|
||||
void refreshRemoteStatus()
|
||||
void refreshRemoteStatus(targetVaultPath)
|
||||
},
|
||||
})
|
||||
}, [enabled, vaultPath, refreshCommitInfo, checkExistingConflicts, refreshRemoteStatus])
|
||||
@@ -383,12 +387,12 @@ export function useAutoSync({
|
||||
const pausePull = useCallback(() => { pauseRef.current = true }, [])
|
||||
const resumePull = useCallback(() => { pauseRef.current = false }, [])
|
||||
|
||||
const triggerSync = useCallback(() => {
|
||||
const triggerSync = useCallback((targetVaultPath = vaultPath) => {
|
||||
if (!enabled) return
|
||||
|
||||
trackEvent('sync_triggered')
|
||||
void performPull()
|
||||
}, [enabled, performPull])
|
||||
void performPull(targetVaultPath)
|
||||
}, [enabled, performPull, vaultPath])
|
||||
|
||||
return { syncStatus, lastSyncTime, conflictFiles, lastCommitInfo, remoteStatus, triggerSync, pullAndPush, pausePull, resumePull, handlePushRejected }
|
||||
}
|
||||
|
||||
@@ -151,6 +151,26 @@ describe('useCommandRegistry', () => {
|
||||
expect(findCommand(result.current, 'view-changes')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('exposes one pull command per active repository when multiple Git targets are available', () => {
|
||||
const onPullRepository = vi.fn()
|
||||
const config = makeConfig({
|
||||
gitRepositories: [
|
||||
{ path: '/vault/main', label: 'Main Vault', defaultForNewNotes: true },
|
||||
{ path: '/vault/brian', label: 'Brian', defaultForNewNotes: false },
|
||||
],
|
||||
onPullRepository,
|
||||
})
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
|
||||
const mainPull = findCommand(result.current, 'git-pull-0')
|
||||
const brianPull = findCommand(result.current, 'git-pull-1')
|
||||
expect(mainPull?.label).toBe('Pull from Remote: Main Vault')
|
||||
expect(brianPull?.label).toBe('Pull from Remote: Brian')
|
||||
|
||||
brianPull?.execute()
|
||||
expect(onPullRepository).toHaveBeenCalledWith('/vault/brian')
|
||||
})
|
||||
|
||||
it('resolve-conflicts stays enabled across rerenders', () => {
|
||||
const config = makeConfig()
|
||||
const { result, rerender } = renderHook(
|
||||
|
||||
@@ -16,6 +16,7 @@ import { buildTypeCommands } from './commands/typeCommands'
|
||||
import { buildFilterCommands } from './commands/filterCommands'
|
||||
import { localizeCommandActions } from './commands/localizeCommands'
|
||||
import { extractVaultTypes } from '../utils/vaultTypes'
|
||||
import type { GitRepositoryOption } from '../utils/gitRepositories'
|
||||
|
||||
// Re-export types and helpers for backward compatibility
|
||||
export type { CommandAction, CommandGroup } from './commands/types'
|
||||
@@ -76,6 +77,7 @@ interface CommandRegistryConfig {
|
||||
canAddRemote?: boolean
|
||||
gitFeaturesEnabled?: boolean
|
||||
isGitVault?: boolean
|
||||
gitRepositories?: GitRepositoryOption[]
|
||||
onInitializeGit?: () => void
|
||||
onCreateType?: () => void
|
||||
onDeleteNote: (path: string) => void
|
||||
@@ -83,6 +85,7 @@ interface CommandRegistryConfig {
|
||||
onUnarchiveNote: (path: string) => void
|
||||
onCommitPush: () => void
|
||||
onPull?: () => void
|
||||
onPullRepository?: (path: string) => void
|
||||
onResolveConflicts?: () => void
|
||||
onSetViewMode: (mode: ViewMode) => void
|
||||
onToggleInspector: () => void
|
||||
@@ -151,7 +154,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
onCustomizeNoteListColumns, canCustomizeNoteListColumns,
|
||||
onRestoreDeletedNote, canRestoreDeletedNote,
|
||||
selection, noteListFilter, onSetNoteListFilter,
|
||||
gitFeaturesEnabled, isGitVault, onInitializeGit,
|
||||
gitFeaturesEnabled, isGitVault, gitRepositories, onInitializeGit, onPullRepository,
|
||||
} = config
|
||||
|
||||
const hasActiveNote = activeTabPath !== null
|
||||
@@ -215,16 +218,18 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
modifiedCount,
|
||||
gitFeaturesEnabled,
|
||||
isGitVault,
|
||||
repositories: gitRepositories,
|
||||
canAddRemote: config.canAddRemote ?? false,
|
||||
onAddRemote: config.onAddRemote,
|
||||
onCommitPush,
|
||||
onInitializeGit,
|
||||
onPull,
|
||||
onPullRepository,
|
||||
onResolveConflicts,
|
||||
onSelect,
|
||||
}), [
|
||||
modifiedCount, gitFeaturesEnabled, isGitVault, config.canAddRemote, config.onAddRemote,
|
||||
onCommitPush, onInitializeGit, onPull, onResolveConflicts, onSelect,
|
||||
modifiedCount, gitFeaturesEnabled, isGitVault, gitRepositories, config.canAddRemote, config.onAddRemote,
|
||||
onCommitPush, onInitializeGit, onPull, onPullRepository, onResolveConflicts, onSelect,
|
||||
])
|
||||
|
||||
const viewCommands = useMemo(() => buildViewCommands({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { FrontmatterOpOptions } from './frontmatterOps'
|
||||
import { isMissingFrontmatterTargetError, type FrontmatterOpOptions } from './frontmatterOps'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
interface EntryActionsConfig {
|
||||
@@ -53,6 +53,14 @@ function findTypeEntry(entries: VaultEntry[], typeName: string): VaultEntry | un
|
||||
return entries.find((entry) => entry.isA === 'Type' && entry.title === typeName)
|
||||
}
|
||||
|
||||
function logOptimisticRollback(label: string, error: unknown): void {
|
||||
if (isMissingFrontmatterTargetError(error)) {
|
||||
console.warn(label, error)
|
||||
return
|
||||
}
|
||||
console.error(label, error)
|
||||
}
|
||||
|
||||
async function findOrCreateType(
|
||||
deps: Pick<TypeActionDeps, 'entries' | 'createTypeEntry'>,
|
||||
typeName: string,
|
||||
@@ -139,7 +147,7 @@ function useArchiveActions({
|
||||
} catch (err) {
|
||||
updateEntry(path, { archived: false })
|
||||
setToastMessage('Failed to archive note — rolled back')
|
||||
console.error('Optimistic archive rollback:', err)
|
||||
logOptimisticRollback('Optimistic archive rollback:', err)
|
||||
}
|
||||
}, [onBeforeAction, handleUpdateFrontmatter, updateEntry, setToastMessage, onFrontmatterPersisted])
|
||||
|
||||
@@ -153,7 +161,7 @@ function useArchiveActions({
|
||||
} catch (err) {
|
||||
updateEntry(path, { archived: true })
|
||||
setToastMessage('Failed to unarchive note — rolled back')
|
||||
console.error('Optimistic unarchive rollback:', err)
|
||||
logOptimisticRollback('Optimistic unarchive rollback:', err)
|
||||
}
|
||||
}, [handleDeleteProperty, updateEntry, setToastMessage, onFrontmatterPersisted])
|
||||
|
||||
|
||||
@@ -118,4 +118,27 @@ describe('useGitRepositories', () => {
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('get_modified_files', { vaultPath: '/default', includeStats: true })
|
||||
})
|
||||
|
||||
it('keeps an explicit sync repository selection separate from other Git surfaces', async () => {
|
||||
const repositories = [
|
||||
{ path: '/default', label: 'Default', defaultForNewNotes: true },
|
||||
{ path: '/work', label: 'Work', defaultForNewNotes: false },
|
||||
]
|
||||
vi.mocked(mockInvoke).mockResolvedValue([])
|
||||
|
||||
const { result } = renderHook(() => useGitRepositories({
|
||||
defaultVaultPath: '/default',
|
||||
repositories,
|
||||
}))
|
||||
|
||||
await waitFor(() => expect(mockInvoke).toHaveBeenCalledWith('get_modified_files', { vaultPath: '/default', includeStats: false }))
|
||||
|
||||
act(() => {
|
||||
result.current.setSyncRepositoryPath('/work')
|
||||
})
|
||||
|
||||
expect(result.current.syncRepositoryPath).toBe('/work')
|
||||
expect(result.current.commitRepositoryPath).toBe('/default')
|
||||
expect(result.current.changesRepositoryPath).toBe('/default')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -181,6 +181,7 @@ export function useGitRepositories({
|
||||
const [changesRepositoryPath, setChangesRepositoryPath] = useValidatedRepositoryPath(selectionConfig)
|
||||
const [historyRepositoryPath, setHistoryRepositoryPath] = useValidatedRepositoryPath(selectionConfig)
|
||||
const [commitRepositoryPath, setCommitRepositoryPath] = useValidatedRepositoryPath(selectionConfig)
|
||||
const [syncRepositoryPath, setSyncRepositoryPath] = useValidatedRepositoryPath(selectionConfig)
|
||||
const { byRepository, loadAllModifiedFiles, loadModifiedFilesForRepository } = useRepositoryModifiedFiles(repositories)
|
||||
const {
|
||||
byRepository: remoteStatusByRepository,
|
||||
@@ -234,6 +235,8 @@ export function useGitRepositories({
|
||||
setChangesRepositoryPath,
|
||||
setCommitRepositoryPath,
|
||||
setHistoryRepositoryPath,
|
||||
setSyncRepositoryPath,
|
||||
syncRepositoryPath,
|
||||
totalModifiedCount: allModifiedFiles.length,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { useNoteActions, type NoteActionsConfig } from './useNoteActions'
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: vi.fn(() => false),
|
||||
addMockEntry: vi.fn(),
|
||||
@@ -59,9 +61,34 @@ function makeConfig(overrides: Partial<NoteActionsConfig> = {}): NoteActionsConf
|
||||
}
|
||||
}
|
||||
|
||||
async function openNoteForFrontmatterRecovery(
|
||||
result: { current: ReturnType<typeof useNoteActions> },
|
||||
entry: VaultEntry,
|
||||
) {
|
||||
vi.mocked(mockInvoke).mockResolvedValue('# Missing Note\n')
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(entry)
|
||||
})
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
vi.mocked(invoke).mockRejectedValueOnce(new Error('File does not exist: /test/vault/missing.md'))
|
||||
}
|
||||
|
||||
function expectMissingFrontmatterRecovery(
|
||||
result: { current: ReturnType<typeof useNoteActions> },
|
||||
config: NoteActionsConfig,
|
||||
) {
|
||||
expect(result.current.tabs).toEqual([])
|
||||
expect(result.current.activeTabPath).toBeNull()
|
||||
expect(config.reloadVault).toHaveBeenCalledTimes(1)
|
||||
expect(config.setToastMessage).toHaveBeenCalledWith(
|
||||
'"Missing Note" could not be opened because its file is missing or moved.',
|
||||
)
|
||||
}
|
||||
|
||||
describe('useNoteActions missing-path recovery', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(invoke).mockReset()
|
||||
vi.mocked(isTauri).mockReturnValue(false)
|
||||
})
|
||||
|
||||
@@ -129,4 +156,44 @@ describe('useNoteActions missing-path recovery', () => {
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('reloads and clears the active tab when a frontmatter write targets a deleted note file', async () => {
|
||||
const entry = makeEntry()
|
||||
const config = makeConfig({ entries: [entry] })
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const { result } = renderHook(() => useNoteActions(config))
|
||||
|
||||
await openNoteForFrontmatterRecovery(result, entry)
|
||||
await act(async () => {
|
||||
await result.current.handleUpdateFrontmatter(entry.path, 'status', 'Done')
|
||||
})
|
||||
|
||||
expectMissingFrontmatterRecovery(result, config)
|
||||
expect(config.updateEntry).not.toHaveBeenCalled()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('still rejects silent frontmatter writes after running missing-note recovery', async () => {
|
||||
const entry = makeEntry()
|
||||
const config = makeConfig({ entries: [entry] })
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const { result } = renderHook(() => useNoteActions(config))
|
||||
|
||||
await openNoteForFrontmatterRecovery(result, entry)
|
||||
let thrown: unknown
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.handleUpdateFrontmatter(entry.path, '_archived', true, { silent: true })
|
||||
} catch (err) {
|
||||
thrown = err
|
||||
}
|
||||
})
|
||||
|
||||
expect(thrown).toBeInstanceOf(Error)
|
||||
expectMissingFrontmatterRecovery(result, config)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -315,6 +315,28 @@ function buildTabManagementOptions(
|
||||
return options
|
||||
}
|
||||
|
||||
function handleMissingFrontmatterTarget({
|
||||
activeTabPathRef,
|
||||
closeAllTabs,
|
||||
entries,
|
||||
path,
|
||||
reloadVault,
|
||||
setToastMessage,
|
||||
}: {
|
||||
activeTabPathRef: MutableRefObject<string | null>
|
||||
closeAllTabs: () => void
|
||||
entries: VaultEntry[]
|
||||
path: string
|
||||
reloadVault?: () => Promise<unknown>
|
||||
setToastMessage: NoteActionsConfig['setToastMessage']
|
||||
}) {
|
||||
const entry = findByNotePath(entries, path)
|
||||
const label = entry ? entryDisplayLabel(entry) : notePathFilename(path) || 'Note'
|
||||
if (notePathsMatch(activeTabPathRef.current, path)) closeAllTabs()
|
||||
setToastMessage(`"${label}" could not be opened because its file is missing or moved.`)
|
||||
void reloadVault?.()
|
||||
}
|
||||
|
||||
function useGitignoredVisibilityTabCleanup({
|
||||
activeTabPathRef,
|
||||
closeAllTabs,
|
||||
@@ -430,13 +452,17 @@ function useFrontmatterActionHandlers({
|
||||
|
||||
function useFrontmatterRunner({
|
||||
activeTabPathRef,
|
||||
closeAllTabs,
|
||||
entries,
|
||||
reloadVault,
|
||||
setToastMessage,
|
||||
updateEntry,
|
||||
updateTabContent,
|
||||
}: {
|
||||
activeTabPathRef: MutableRefObject<string | null>
|
||||
closeAllTabs: () => void
|
||||
entries: VaultEntry[]
|
||||
reloadVault?: () => Promise<unknown>
|
||||
setToastMessage: NoteActionsConfig['setToastMessage']
|
||||
updateEntry: NoteActionsConfig['updateEntry']
|
||||
updateTabContent: (path: string, newContent: string) => void
|
||||
@@ -452,11 +478,19 @@ function useFrontmatterRunner({
|
||||
updateEntry,
|
||||
toast: setToastMessage,
|
||||
getEntry: (p) => findByNotePath(entries, p),
|
||||
onMissingNotePath: (p) => handleMissingFrontmatterTarget({
|
||||
activeTabPathRef,
|
||||
closeAllTabs,
|
||||
entries,
|
||||
path: p,
|
||||
reloadVault,
|
||||
setToastMessage,
|
||||
}),
|
||||
shouldApply: (p) => activePathGuardAllowsMutation(p, activeTabPathRef, options),
|
||||
},
|
||||
options,
|
||||
}),
|
||||
[activeTabPathRef, entries, setToastMessage, updateEntry, updateTabContent],
|
||||
[activeTabPathRef, closeAllTabs, entries, reloadVault, setToastMessage, updateEntry, updateTabContent],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -502,7 +536,15 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
[entries, handleSelectNote, tabMgmt.activeTabPath, tabMgmt.tabs],
|
||||
)
|
||||
|
||||
const runFrontmatterOp = useFrontmatterRunner({ activeTabPathRef, entries, setToastMessage, updateEntry, updateTabContent })
|
||||
const runFrontmatterOp = useFrontmatterRunner({
|
||||
activeTabPathRef,
|
||||
closeAllTabs: tabMgmt.closeAllTabs,
|
||||
entries,
|
||||
reloadVault: config.reloadVault,
|
||||
setToastMessage,
|
||||
updateEntry,
|
||||
updateTabContent,
|
||||
})
|
||||
const frontmatterActions = useFrontmatterActionHandlers({
|
||||
config,
|
||||
onPathRenamed: handlePathRenamed,
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.git.commitPush": "Закаміціць і адправіць (Commit & Push)",
|
||||
"command.git.addRemote": "Дадаць аддалены сервер (Remote) да сховішча",
|
||||
"command.git.pull": "Атрымаць змены (Pull)",
|
||||
"command.git.pullRepository": "Атрымаць з аддаленага рэпазіторыя: {repository}",
|
||||
"command.git.resolveConflicts": "Вырашыць канфлікты",
|
||||
"command.git.viewChanges": "Прагледзець чакаючыя змены",
|
||||
"git.repository.select": "Сховішча",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.git.commitPush": "Zakamicić i adpravić (Commit & Push)",
|
||||
"command.git.addRemote": "Dadać addalieny siervier (Remote) da schovišča",
|
||||
"command.git.pull": "Atrymać zmieny (Pull)",
|
||||
"command.git.pullRepository": "Atrymać z addalienaha repazitoryja: {repository}",
|
||||
"command.git.resolveConflicts": "Vyrašyć kanflikty",
|
||||
"command.git.viewChanges": "Prahliedzieć čakajučyja zmieny",
|
||||
"git.repository.select": "Schovišča",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.git.commitPush": "Commit & Push",
|
||||
"command.git.addRemote": "Remote zum aktuellen Vault hinzufügen",
|
||||
"command.git.pull": "Von Remote abrufen",
|
||||
"command.git.pullRepository": "Aus Remote abrufen: {repository}",
|
||||
"command.git.resolveConflicts": "Konflikte auflösen",
|
||||
"command.git.viewChanges": "Ausstehende Änderungen anzeigen",
|
||||
"git.repository.select": "Repository",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.git.commitPush": "Commit & Push",
|
||||
"command.git.addRemote": "Add Remote to Current Vault",
|
||||
"command.git.pull": "Pull from Remote",
|
||||
"command.git.pullRepository": "Pull from Remote: {repository}",
|
||||
"command.git.resolveConflicts": "Resolve Conflicts",
|
||||
"command.git.viewChanges": "View Pending Changes",
|
||||
"git.repository.select": "Repository",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.git.commitPush": "Confirmar y enviar",
|
||||
"command.git.addRemote": "Agregar repositorio remoto al repositorio actual",
|
||||
"command.git.pull": "Extraer desde el repositorio remoto",
|
||||
"command.git.pullRepository": "Extraer del repositorio remoto: {repository}",
|
||||
"command.git.resolveConflicts": "Resolver conflictos",
|
||||
"command.git.viewChanges": "Ver cambios pendientes",
|
||||
"git.repository.select": "Repositorio",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.git.commitPush": "Confirmar y enviar",
|
||||
"command.git.addRemote": "Añadir repositorio remoto al almacén actual",
|
||||
"command.git.pull": "Extraer del repositorio remoto",
|
||||
"command.git.pullRepository": "Extraer del repositorio remoto: {repository}",
|
||||
"command.git.resolveConflicts": "Resolver conflictos",
|
||||
"command.git.viewChanges": "Ver cambios pendientes",
|
||||
"git.repository.select": "Repositorio",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.git.commitPush": "Valider et pousser",
|
||||
"command.git.addRemote": "Ajouter le dépôt distant au coffre-fort actuel",
|
||||
"command.git.pull": "Extraire depuis le dépôt distant",
|
||||
"command.git.pullRepository": "Extraire du dépôt distant : {repository}",
|
||||
"command.git.resolveConflicts": "Résoudre les conflits",
|
||||
"command.git.viewChanges": "Afficher les modifications en attente",
|
||||
"git.repository.select": "Dépôt",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.git.commitPush": "Commit e push",
|
||||
"command.git.addRemote": "Aggiungi repository remoto al vault corrente",
|
||||
"command.git.pull": "Esegui il pull dal repository remoto",
|
||||
"command.git.pullRepository": "Esegui il pull dal repository remoto: {repository}",
|
||||
"command.git.resolveConflicts": "Risolvi conflitti",
|
||||
"command.git.viewChanges": "Visualizza modifiche in sospeso",
|
||||
"git.repository.select": "Repository",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.git.commitPush": "コミット & プッシュ",
|
||||
"command.git.addRemote": "現在のボールトにリモートを追加",
|
||||
"command.git.pull": "リモートからプル",
|
||||
"command.git.pullRepository": "リモートからプル:{repository}",
|
||||
"command.git.resolveConflicts": "競合を解決",
|
||||
"command.git.viewChanges": "保留中の変更を表示",
|
||||
"git.repository.select": "リポジトリ",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.git.commitPush": "커밋 및 푸시",
|
||||
"command.git.addRemote": "현재 Vault에 원격 저장소 추가",
|
||||
"command.git.pull": "원격에서 가져오기",
|
||||
"command.git.pullRepository": "원격에서 가져오기: {repository}",
|
||||
"command.git.resolveConflicts": "충돌 해결",
|
||||
"command.git.viewChanges": "보류 중인 변경 사항 보기",
|
||||
"git.repository.select": "저장소",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.git.commitPush": "Zatwierdź i wypchnij",
|
||||
"command.git.addRemote": "Dodaj zdalne repozytorium do bieżącego sejfu",
|
||||
"command.git.pull": "Pobierz ze zdalnego repozytorium",
|
||||
"command.git.pullRepository": "Pobierz z repozytorium zdalnego: {repository}",
|
||||
"command.git.resolveConflicts": "Rozwiąż konflikty",
|
||||
"command.git.viewChanges": "Pokaż oczekujące zmiany",
|
||||
"git.repository.select": "Repozytorium",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.git.commitPush": "Fazer commit e push",
|
||||
"command.git.addRemote": "Adicionar repositório remoto ao cofre atual",
|
||||
"command.git.pull": "Fazer pull do repositório remoto",
|
||||
"command.git.pullRepository": "Puxar do Remoto: {repository}",
|
||||
"command.git.resolveConflicts": "Resolver conflitos",
|
||||
"command.git.viewChanges": "Exibir alterações pendentes",
|
||||
"git.repository.select": "Repositório",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.git.commitPush": "Fazer commit e push",
|
||||
"command.git.addRemote": "Adicionar repositório remoto ao cofre atual",
|
||||
"command.git.pull": "Efetuar pull do repositório remoto",
|
||||
"command.git.pullRepository": "Obter do Remoto: {repository}",
|
||||
"command.git.resolveConflicts": "Resolver conflitos",
|
||||
"command.git.viewChanges": "Ver alterações pendentes",
|
||||
"git.repository.select": "Repositório",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.git.commitPush": "Commit и Push",
|
||||
"command.git.addRemote": "Добавить удаленный репозиторий в текущий хранилище",
|
||||
"command.git.pull": "Pull from Remote",
|
||||
"command.git.pullRepository": "Получить из удаленного репозитория: {repository}",
|
||||
"command.git.resolveConflicts": "Разрешить конфликты",
|
||||
"command.git.viewChanges": "Просмотреть ожидающие изменения",
|
||||
"git.repository.select": "Репозиторий",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.git.commitPush": "Commit & Push",
|
||||
"command.git.addRemote": "Thêm remote cho kho hiện tại",
|
||||
"command.git.pull": "Kéo từ remote",
|
||||
"command.git.pullRepository": "Lấy từ Remote: {repository}",
|
||||
"command.git.resolveConflicts": "Giải quyết xung đột",
|
||||
"command.git.viewChanges": "Xem các thay đổi đang chờ",
|
||||
"git.repository.select": "Kho lưu trữ",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.git.commitPush": "提交并推送",
|
||||
"command.git.addRemote": "为当前仓库添加远程地址",
|
||||
"command.git.pull": "从远程拉取",
|
||||
"command.git.pullRepository": "从远程拉取:{repository}",
|
||||
"command.git.resolveConflicts": "解决冲突",
|
||||
"command.git.viewChanges": "查看待处理更改",
|
||||
"git.repository.select": "仓库",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.git.commitPush": "提交併推送",
|
||||
"command.git.addRemote": "為當前倉庫新增遠端地址",
|
||||
"command.git.pull": "從遠端拉取",
|
||||
"command.git.pullRepository": "從遠端提取:{repository}",
|
||||
"command.git.resolveConflicts": "解決衝突",
|
||||
"command.git.viewChanges": "檢視待處理更改",
|
||||
"git.repository.select": "儲存庫",
|
||||
|
||||
@@ -154,12 +154,14 @@ test('mixed rich-text blocks with Korean list content stay editable after action
|
||||
await expect(bulletBlock).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
await page.locator('.bn-block-content', { hasText: '인용문' }).first().click()
|
||||
await page.keyboard.press('End')
|
||||
await page.keyboard.insertText(' 추가')
|
||||
await bulletBlock.hover()
|
||||
await expect(page.locator('.bn-side-menu').first()).toBeVisible({ timeout: 5_000 })
|
||||
await page.locator('.bn-side-menu').first().click({ force: true })
|
||||
await page.keyboard.press('Escape')
|
||||
await bulletBlock.click()
|
||||
await page.keyboard.press('End')
|
||||
await page.keyboard.insertText(' 계속')
|
||||
await page.waitForTimeout(700)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ function trackUnexpectedErrors(page: Page): string[] {
|
||||
if (message.type() !== 'error') return
|
||||
const text = message.text()
|
||||
if (text.includes('ws://localhost:9711')) return
|
||||
if (text.includes('Failed to load resource: the server responded with a status of 400')) return
|
||||
errors.push(text)
|
||||
})
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ function trackUnexpectedErrors(page: Page): string[] {
|
||||
if (message.type() !== 'error') return
|
||||
const text = message.text()
|
||||
if (text.includes('ws://localhost:9711')) return
|
||||
if (text.includes('Failed to load resource: the server responded with a status of 400')) return
|
||||
errors.push(text)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user