From 948b1501bf58ed679564831b301b13adf22bb49d Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 23 May 2026 13:36:39 +0200 Subject: [PATCH 1/6] fix: route sync to selected git repository --- lara.lock | 1 + src/App.tsx | 38 ++++- src/components/Editor.test.tsx | 2 +- src/components/StatusBar.test.tsx | 24 +++ src/components/StatusBar.tsx | 10 ++ src/components/codeBlockOptions.ts | 55 +++++++ .../status-bar/StatusBarBadges.extra.test.tsx | 7 +- src/components/status-bar/StatusBarBadges.tsx | 65 +++++--- .../status-bar/StatusBarSections.tsx | 140 +++++++++++++++--- src/hooks/commands/gitCommands.ts | 38 ++++- src/hooks/commands/localizeCommands.ts | 11 ++ src/hooks/useAppCommands.ts | 7 + src/hooks/useAutoSync.extra.test.ts | 2 +- src/hooks/useAutoSync.test.ts | 29 +++- src/hooks/useAutoSync.ts | 58 ++++---- src/hooks/useCommandRegistry.test.ts | 20 +++ src/hooks/useCommandRegistry.ts | 11 +- src/hooks/useGitRepositories.test.ts | 23 +++ src/hooks/useGitRepositories.ts | 3 + src/lib/locales/de-DE.json | 1 + src/lib/locales/en.json | 1 + src/lib/locales/es-419.json | 1 + src/lib/locales/es-ES.json | 1 + src/lib/locales/fr-FR.json | 1 + src/lib/locales/it-IT.json | 1 + src/lib/locales/ja-JP.json | 1 + src/lib/locales/ko-KR.json | 1 + src/lib/locales/pl-PL.json | 1 + src/lib/locales/pt-BR.json | 1 + src/lib/locales/pt-PT.json | 1 + src/lib/locales/ru-RU.json | 1 + src/lib/locales/vi.json | 1 + src/lib/locales/zh-CN.json | 1 + src/lib/locales/zh-TW.json | 1 + 34 files changed, 474 insertions(+), 85 deletions(-) diff --git a/lara.lock b/lara.lock index a4a04043..40080338 100644 --- a/lara.lock +++ b/lara.lock @@ -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 diff --git a/src/App.tsx b/src/App.tsx index 8496592b..3a243b6e 100644 --- a/src/App.tsx +++ b/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,7 +832,7 @@ 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) => { @@ -1014,6 +1019,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 @@ -1034,6 +1043,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() @@ -1553,10 +1573,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, @@ -1786,7 +1808,7 @@ function App() { - 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} /> + 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} /> setToastMessage(null)} /> diff --git a/src/components/Editor.test.tsx b/src/components/Editor.test.tsx index ef3dbc42..a24691d5 100644 --- a/src/components/Editor.test.tsx +++ b/src/components/Editor.test.tsx @@ -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 diff --git a/src/components/StatusBar.test.tsx b/src/components/StatusBar.test.tsx index 93b40058..50020f51 100644 --- a/src/components/StatusBar.test.tsx +++ b/src/components/StatusBar.test.tsx @@ -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( + + ) + + 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() expect(screen.getByTestId('status-pulse')).toBeInTheDocument() diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index bc2174a2..8fcc76d0 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -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} diff --git a/src/components/codeBlockOptions.ts b/src/components/codeBlockOptions.ts index 271b6f82..9b6510bf 100644 --- a/src/components/codeBlockOptions.ts +++ b/src/components/codeBlockOptions.ts @@ -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>> +type TolariaLoadLanguage = TolariaCodeHighlighter['loadLanguage'] +type TolariaLanguageInput = Parameters[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 { 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, createHighlighter: createTolariaCodeHighlighter, defaultLanguage: 'text', + supportedLanguages: { + ...codeBlockOptions.supportedLanguages, + go: GO_LANGUAGE, + }, } if (supportsModernRegexFeatures()) return options diff --git a/src/components/status-bar/StatusBarBadges.extra.test.tsx b/src/components/status-bar/StatusBarBadges.extra.test.tsx index 195bac1f..e590ed67 100644 --- a/src/components/status-bar/StatusBarBadges.extra.test.tsx +++ b/src/components/status-bar/StatusBarBadges.extra.test.tsx @@ -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() diff --git a/src/components/status-bar/StatusBarBadges.tsx b/src/components/status-bar/StatusBarBadges.tsx index 8c6aba6e..138ee969 100644 --- a/src/components/status-bar/StatusBarBadges.tsx +++ b/src/components/status-bar/StatusBarBadges.tsx @@ -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 (
- +
) } @@ -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 && ( +
+ +
+ )}
{remoteStatus?.branch || '—'} @@ -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(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({ - {compact ? null : formatSyncLabel(locale, status, lastSyncTime)} + {compact ? null : formatSyncBadgeLabel(locale, status, lastSyncTime, selectedRepositoryLabel)} {showPopup && ( setShowPopup(false)} diff --git a/src/components/status-bar/StatusBarSections.tsx b/src/components/status-bar/StatusBarSections.tsx index 738e918a..040d129d 100644 --- a/src/components/status-bar/StatusBarSections.tsx +++ b/src/components/status-bar/StatusBarSections.tsx @@ -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 : | } +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 ( + <> + { + 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} + /> + + + ) +} + 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 (
- { - 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} /> -
) } diff --git a/src/hooks/commands/gitCommands.ts b/src/hooks/commands/gitCommands.ts index 9ad67581..9a523e3d 100644 --- a/src/hooks/commands/gitCommands.ts +++ b/src/hooks/commands/gitCommands.ts @@ -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): 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' }) }, ] diff --git a/src/hooks/commands/localizeCommands.ts b/src/hooks/commands/localizeCommands.ts index 8ce0049d..f973c765 100644 --- a/src/hooks/commands/localizeCommands.ts +++ b/src/hooks/commands/localizeCommands.ts @@ -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 } diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index cef285d6..1fc508ec 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -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, diff --git a/src/hooks/useAutoSync.extra.test.ts b/src/hooks/useAutoSync.extra.test.ts index 4cf03956..cc2bfd87 100644 --- a/src/hooks/useAutoSync.extra.test.ts +++ b/src/hooks/useAutoSync.extra.test.ts @@ -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') diff --git a/src/hooks/useAutoSync.test.ts b/src/hooks/useAutoSync.test.ts index 398ade88..807c239b 100644 --- a/src/hooks/useAutoSync.test.ts +++ b/src/hooks/useAutoSync.test.ts @@ -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' diff --git a/src/hooks/useAutoSync.ts b/src/hooks/useAutoSync.ts index a5257132..deb623e0 100644 --- a/src/hooks/useAutoSync.ts +++ b/src/hooks/useAutoSync.ts @@ -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, - refreshCommitInfo: () => void, + refreshCommitInfo: (vaultPath?: string) => void, + vaultPath?: string, ): void { setLastSyncTime(Date.now()) - refreshCommitInfo() + refreshCommitInfo(vaultPath) } function useRemoteStatusRefresher( vaultPath: string, setRemoteStatus: SyncSetState, ) { - return useCallback(async () => { + return useCallback(async (targetVaultPath = vaultPath) => { try { - const status = await tauriCall('git_remote_status', { vaultPath }) + const status = await tauriCall('git_remote_status', { vaultPath: targetVaultPath }) setRemoteStatus(status) return status } catch { @@ -109,9 +110,9 @@ function useConflictChecker( setConflictFiles: SyncSetState, callbacksRef: MutableRefObject, ) { - return useCallback(async (): Promise => { + return useCallback(async (targetVaultPath = vaultPath): Promise => { try { - const files = await tauriCall('get_conflict_files', { vaultPath }) + const files = await tauriCall('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, ) { - return useCallback(() => { - tauriCall('get_last_commit_info', { vaultPath }) + return useCallback((targetVaultPath = vaultPath) => { + tauriCall('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 setConflictFiles: SyncSetState setSyncStatus: SyncSetState }): Promise { 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('git_pull', { vaultPath }) - markPullTimestamp(setLastSyncTime, refreshCommitInfo) + const result = await tauriCall('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('git_pull', { vaultPath }) - markPullTimestamp(setLastSyncTime, refreshCommitInfo) + const pullResult = await tauriCall('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('git_push', { vaultPath }) + const pushResult = await tauriCall('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 } } diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts index c1e65539..9901e6b1 100644 --- a/src/hooks/useCommandRegistry.test.ts +++ b/src/hooks/useCommandRegistry.test.ts @@ -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( diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts index f3c1958a..bd971b32 100644 --- a/src/hooks/useCommandRegistry.ts +++ b/src/hooks/useCommandRegistry.ts @@ -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({ diff --git a/src/hooks/useGitRepositories.test.ts b/src/hooks/useGitRepositories.test.ts index 7d19eb0e..95e4fb6d 100644 --- a/src/hooks/useGitRepositories.test.ts +++ b/src/hooks/useGitRepositories.test.ts @@ -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') + }) }) diff --git a/src/hooks/useGitRepositories.ts b/src/hooks/useGitRepositories.ts index d67cbd2d..614ab3ac 100644 --- a/src/hooks/useGitRepositories.ts +++ b/src/hooks/useGitRepositories.ts @@ -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, } } diff --git a/src/lib/locales/de-DE.json b/src/lib/locales/de-DE.json index b7c58f1e..fccb8def 100644 --- a/src/lib/locales/de-DE.json +++ b/src/lib/locales/de-DE.json @@ -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", diff --git a/src/lib/locales/en.json b/src/lib/locales/en.json index bf9986b6..cfec816f 100644 --- a/src/lib/locales/en.json +++ b/src/lib/locales/en.json @@ -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", diff --git a/src/lib/locales/es-419.json b/src/lib/locales/es-419.json index 5bfd19f9..0c48188f 100644 --- a/src/lib/locales/es-419.json +++ b/src/lib/locales/es-419.json @@ -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", diff --git a/src/lib/locales/es-ES.json b/src/lib/locales/es-ES.json index 54eca87d..1c981957 100644 --- a/src/lib/locales/es-ES.json +++ b/src/lib/locales/es-ES.json @@ -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", diff --git a/src/lib/locales/fr-FR.json b/src/lib/locales/fr-FR.json index 4ed3991a..badf307f 100644 --- a/src/lib/locales/fr-FR.json +++ b/src/lib/locales/fr-FR.json @@ -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", diff --git a/src/lib/locales/it-IT.json b/src/lib/locales/it-IT.json index a9823e11..07f6e879 100644 --- a/src/lib/locales/it-IT.json +++ b/src/lib/locales/it-IT.json @@ -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", diff --git a/src/lib/locales/ja-JP.json b/src/lib/locales/ja-JP.json index 80d4d668..6426a153 100644 --- a/src/lib/locales/ja-JP.json +++ b/src/lib/locales/ja-JP.json @@ -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": "リポジトリ", diff --git a/src/lib/locales/ko-KR.json b/src/lib/locales/ko-KR.json index 3d0ae42e..473059a6 100644 --- a/src/lib/locales/ko-KR.json +++ b/src/lib/locales/ko-KR.json @@ -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": "저장소", diff --git a/src/lib/locales/pl-PL.json b/src/lib/locales/pl-PL.json index 9291061a..21353127 100644 --- a/src/lib/locales/pl-PL.json +++ b/src/lib/locales/pl-PL.json @@ -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", diff --git a/src/lib/locales/pt-BR.json b/src/lib/locales/pt-BR.json index 65154c86..4e90fc28 100644 --- a/src/lib/locales/pt-BR.json +++ b/src/lib/locales/pt-BR.json @@ -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", diff --git a/src/lib/locales/pt-PT.json b/src/lib/locales/pt-PT.json index 101a79db..14427bfd 100644 --- a/src/lib/locales/pt-PT.json +++ b/src/lib/locales/pt-PT.json @@ -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", diff --git a/src/lib/locales/ru-RU.json b/src/lib/locales/ru-RU.json index dc8705bb..a4fe2dad 100644 --- a/src/lib/locales/ru-RU.json +++ b/src/lib/locales/ru-RU.json @@ -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": "Репозиторий", diff --git a/src/lib/locales/vi.json b/src/lib/locales/vi.json index 0e6c90a4..6ef7d21c 100644 --- a/src/lib/locales/vi.json +++ b/src/lib/locales/vi.json @@ -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ữ", diff --git a/src/lib/locales/zh-CN.json b/src/lib/locales/zh-CN.json index 816c644a..1d3d3ad9 100644 --- a/src/lib/locales/zh-CN.json +++ b/src/lib/locales/zh-CN.json @@ -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": "仓库", diff --git a/src/lib/locales/zh-TW.json b/src/lib/locales/zh-TW.json index 7759bbc0..d95894be 100644 --- a/src/lib/locales/zh-TW.json +++ b/src/lib/locales/zh-TW.json @@ -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": "儲存庫", From 7709ad100294fb0f3977e1f20c07ad033a9b9cb2 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 23 May 2026 14:41:11 +0200 Subject: [PATCH 2/6] fix: recover missing note frontmatter writes --- src/App.tsx | 6 +- src/hooks/frontmatterOps.ts | 54 ++++++++++++--- src/hooks/useEntryActions.ts | 14 +++- src/hooks/useNoteActions.missing-path.test.ts | 67 +++++++++++++++++++ src/hooks/useNoteActions.ts | 46 ++++++++++++- 5 files changed, 172 insertions(+), 15 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 3a243b6e..101cb16b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -835,8 +835,10 @@ function App() { 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) => { diff --git a/src/hooks/frontmatterOps.ts b/src/hooks/frontmatterOps.ts index 53dffaa0..42174234 100644 --- a/src/hooks/frontmatterOps.ts +++ b/src/hooks/frontmatterOps.ts @@ -272,6 +272,7 @@ export interface FrontmatterApplyCallbacks { updateEntry: (path: VaultPath, patch: Partial) => void toast: (message: ToastMessage) => void getEntry?: (path: VaultPath) => VaultEntry | undefined + onMissingNotePath?: (path: VaultPath, error: unknown) => void | Promise 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 { + 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 { + 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 }) } } diff --git a/src/hooks/useEntryActions.ts b/src/hooks/useEntryActions.ts index e4fcd6d5..43643fd1 100644 --- a/src/hooks/useEntryActions.ts +++ b/src/hooks/useEntryActions.ts @@ -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, 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]) diff --git a/src/hooks/useNoteActions.missing-path.test.ts b/src/hooks/useNoteActions.missing-path.test.ts index 0341b625..10f1f367 100644 --- a/src/hooks/useNoteActions.missing-path.test.ts +++ b/src/hooks/useNoteActions.missing-path.test.ts @@ -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 = {}): NoteActionsConf } } +async function openNoteForFrontmatterRecovery( + result: { current: ReturnType }, + 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 }, + 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() + }) }) diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index 028fe83f..55104834 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -315,6 +315,28 @@ function buildTabManagementOptions( return options } +function handleMissingFrontmatterTarget({ + activeTabPathRef, + closeAllTabs, + entries, + path, + reloadVault, + setToastMessage, +}: { + activeTabPathRef: MutableRefObject + closeAllTabs: () => void + entries: VaultEntry[] + path: string + reloadVault?: () => Promise + 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 + closeAllTabs: () => void entries: VaultEntry[] + reloadVault?: () => Promise 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, From 5bb3fe05508a209bf75ca2b5a57ef27bdfab44f1 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 23 May 2026 15:28:35 +0200 Subject: [PATCH 3/6] fix: recover stale table editor transforms --- ...torTransformErrorRecoveryExtension.test.ts | 22 +++++++++++++ ...chEditorTransformErrorRecoveryExtension.ts | 31 ++++++++++++++----- .../smoke/table-cell-paste-regression.spec.ts | 1 + tests/smoke/table-resize-crash.spec.ts | 1 + 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/components/richEditorTransformErrorRecoveryExtension.test.ts b/src/components/richEditorTransformErrorRecoveryExtension.test.ts index d32ab8a9..ff23ab51 100644 --- a/src/components/richEditorTransformErrorRecoveryExtension.test.ts +++ b/src/components/richEditorTransformErrorRecoveryExtension.test.ts @@ -48,6 +48,12 @@ describe('isRecoverableEditorTransformError', () => { expect(isRecoverableEditorTransformError(new RangeError( 'Invalid content for node blockContainer: ', ))).toBe(true) + expect(isRecoverableEditorTransformError(new RangeError( + 'Index 1 out of range for ', + ))).toBe(true) + expect(isRecoverableEditorTransformError(new RangeError( + 'Index 1 out of range for ', + ))).toBe(false) expect(isRecoverableEditorTransformError(new Error('unrelated'))).toBe(false) }) }) @@ -93,6 +99,22 @@ describe('installRichEditorTransformErrorRecovery', () => { }) }) + it('recovers table selection transactions whose target row changed underneath BlockNote', () => { + const tableError = new RangeError( + 'Index 1 out of range for ', + ) + const { currentDoc, view } = createView(tableError) + 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: 'table_position_out_of_range', + }) + }) + it('keeps non-ProseMirror dispatch failures visible', () => { const { view } = createView(new Error('plugin failed')) diff --git a/src/components/richEditorTransformErrorRecoveryExtension.ts b/src/components/richEditorTransformErrorRecoveryExtension.ts index 6a3c3954..a9069f1b 100644 --- a/src/components/richEditorTransformErrorRecoveryExtension.ts +++ b/src/components/richEditorTransformErrorRecoveryExtension.ts @@ -35,7 +35,11 @@ interface RepairableBlockNoteEditor { replaceBlocks?: (currentBlocks: unknown[], nextBlocks: unknown[]) => unknown } -type RecoveryReason = 'mismatched_transaction' | 'stale_transaction' | 'transform_error' +type RecoveryReason = + | 'mismatched_transaction' + | 'stale_transaction' + | 'table_position_out_of_range' + | 'transform_error' function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null @@ -67,12 +71,20 @@ function isInvalidContentTransactionError(error: unknown): boolean { return error instanceof RangeError && error.message.startsWith('Invalid content for node ') } +function isTablePositionOutOfRangeError(error: unknown): boolean { + return error instanceof RangeError && /^Index \d+ out of range for Date: Sat, 23 May 2026 16:11:20 +0200 Subject: [PATCH 4/6] fix: recover invalid insertion depth transforms --- ...torTransformErrorRecoveryExtension.test.ts | 53 +++++++++++-------- ...chEditorTransformErrorRecoveryExtension.ts | 10 +++- tests/smoke/mixed-rich-text-keypress.spec.ts | 2 + 3 files changed, 41 insertions(+), 24 deletions(-) diff --git a/src/components/richEditorTransformErrorRecoveryExtension.test.ts b/src/components/richEditorTransformErrorRecoveryExtension.test.ts index ff23ab51..116bc91a 100644 --- a/src/components/richEditorTransformErrorRecoveryExtension.test.ts +++ b/src/components/richEditorTransformErrorRecoveryExtension.test.ts @@ -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,9 @@ describe('isRecoverableEditorTransformError', () => { expect(isRecoverableEditorTransformError(new RangeError( 'Invalid content for node 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 ', ))).toBe(true) @@ -84,35 +100,26 @@ describe('installRichEditorTransformErrorRecovery', () => { }) it('recovers invalid-content schema transactions from mixed paragraph and list editing', () => { - const schemaError = new RangeError( - 'Invalid content for node blockContainer: ', + expectDocumentRepairRecovery( + new RangeError( + 'Invalid content for node blockContainer: ', + ), + 'transform_error', ) - const { currentDoc, view } = createView(schemaError) - 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: 'transform_error', - }) }) it('recovers table selection transactions whose target row changed underneath BlockNote', () => { - const tableError = new RangeError( - 'Index 1 out of range for ', + expectDocumentRepairRecovery( + new RangeError('Index 1 out of range for '), + 'table_position_out_of_range', ) - const { currentDoc, view } = createView(tableError) - 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: '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('keeps non-ProseMirror dispatch failures visible', () => { diff --git a/src/components/richEditorTransformErrorRecoveryExtension.ts b/src/components/richEditorTransformErrorRecoveryExtension.ts index a9069f1b..d92e2b5d 100644 --- a/src/components/richEditorTransformErrorRecoveryExtension.ts +++ b/src/components/richEditorTransformErrorRecoveryExtension.ts @@ -36,6 +36,7 @@ interface RepairableBlockNoteEditor { } type RecoveryReason = + | 'invalid_insertion_depth' | 'mismatched_transaction' | 'stale_transaction' | 'table_position_out_of_range' @@ -71,6 +72,10 @@ 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 Date: Sat, 23 May 2026 16:53:43 +0200 Subject: [PATCH 5/6] fix: recover stale editor block references --- ...torTransformErrorRecoveryExtension.test.ts | 18 +++++ ...chEditorTransformErrorRecoveryExtension.ts | 15 ++++- .../tolariaBlockNoteSideMenu.test.tsx | 26 ++++++++ src/components/tolariaBlockNoteSideMenu.tsx | 29 ++++++--- .../tolariaEditorFormatting.behavior.test.tsx | 20 +++++- src/components/tolariaEditorFormatting.tsx | 65 ++++++++++++++++--- src/lib/locales/be-BY.json | 1 + src/lib/locales/be-Latn.json | 1 + 8 files changed, 157 insertions(+), 18 deletions(-) diff --git a/src/components/richEditorTransformErrorRecoveryExtension.test.ts b/src/components/richEditorTransformErrorRecoveryExtension.test.ts index 116bc91a..4a70be05 100644 --- a/src/components/richEditorTransformErrorRecoveryExtension.test.ts +++ b/src/components/richEditorTransformErrorRecoveryExtension.test.ts @@ -67,6 +67,9 @@ describe('isRecoverableEditorTransformError', () => { expect(isRecoverableEditorTransformError(new RangeError( 'Index 1 out of range for ', ))).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 ', ))).toBe(false) @@ -122,6 +125,21 @@ describe('installRichEditorTransformErrorRecovery', () => { ) }) + 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).not.toHaveBeenCalled() + expect(trackEvent).toHaveBeenCalledWith('rich_editor_transform_error_recovered', { + reason: 'stale_block_reference', + }) + }) + it('keeps non-ProseMirror dispatch failures visible', () => { const { view } = createView(new Error('plugin failed')) diff --git a/src/components/richEditorTransformErrorRecoveryExtension.ts b/src/components/richEditorTransformErrorRecoveryExtension.ts index d92e2b5d..f0266bda 100644 --- a/src/components/richEditorTransformErrorRecoveryExtension.ts +++ b/src/components/richEditorTransformErrorRecoveryExtension.ts @@ -38,6 +38,7 @@ interface RepairableBlockNoteEditor { type RecoveryReason = | 'invalid_insertion_depth' | 'mismatched_transaction' + | 'stale_block_reference' | 'stale_transaction' | 'table_position_out_of_range' | 'transform_error' @@ -80,6 +81,10 @@ function isTablePositionOutOfRangeError(error: unknown): boolean { return error instanceof RangeError && /^Index \d+ out of range for predicate(error)) } function recoveryReason( @@ -101,6 +113,7 @@ function recoveryReason( ): RecoveryReason { if (transactionDocIsStale(transaction, view)) return 'stale_transaction' if (isMismatchedTransactionError(error)) return 'mismatched_transaction' + if (isStaleBlockReferenceError(error)) return 'stale_block_reference' if (isInvalidInsertionDepthError(error)) return 'invalid_insertion_depth' if (isTablePositionOutOfRangeError(error)) return 'table_position_out_of_range' return 'transform_error' diff --git a/src/components/tolariaBlockNoteSideMenu.test.tsx b/src/components/tolariaBlockNoteSideMenu.test.tsx index a8835ed8..7d45b483 100644 --- a/src/components/tolariaBlockNoteSideMenu.test.tsx +++ b/src/components/tolariaBlockNoteSideMenu.test.tsx @@ -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() diff --git a/src/components/tolariaBlockNoteSideMenu.tsx b/src/components/tolariaBlockNoteSideMenu.tsx index bb3ba718..86f600f5 100644 --- a/src/components/tolariaBlockNoteSideMenu.tsx +++ b/src/components/tolariaBlockNoteSideMenu.tsx @@ -25,6 +25,7 @@ import { type PointerEvent as ReactPointerEvent, type ReactNode, } from 'react' +import { isStaleBlockReferenceError } from './richEditorTransformErrorRecoveryExtension' type TolariaBlockNoteEditor = BlockNoteEditor type TolariaBlock = NonNullable> @@ -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 diff --git a/src/components/tolariaEditorFormatting.behavior.test.tsx b/src/components/tolariaEditorFormatting.behavior.test.tsx index 9b43167d..cc8853be 100644 --- a/src/components/tolariaEditorFormatting.behavior.test.tsx +++ b/src/components/tolariaEditorFormatting.behavior.test.tsx @@ -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 = 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() + + 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', diff --git a/src/components/tolariaEditorFormatting.tsx b/src/components/tolariaEditorFormatting.tsx index 2d368225..4307711c 100644 --- a/src/components/tolariaEditorFormatting.tsx +++ b/src/components/tolariaEditorFormatting.tsx @@ -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, + 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, + 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[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({ diff --git a/src/lib/locales/be-BY.json b/src/lib/locales/be-BY.json index d76800ea..ac507e58 100644 --- a/src/lib/locales/be-BY.json +++ b/src/lib/locales/be-BY.json @@ -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": "Сховішча", diff --git a/src/lib/locales/be-Latn.json b/src/lib/locales/be-Latn.json index af193b95..f4dffa59 100644 --- a/src/lib/locales/be-Latn.json +++ b/src/lib/locales/be-Latn.json @@ -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", From 2a3ecf7270fba535c7d3404735e9482e3a48370e Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 23 May 2026 17:36:55 +0200 Subject: [PATCH 6/6] fix: recover invalid editor block joins --- ...richEditorTransformErrorRecoveryExtension.test.ts | 10 ++++++++++ .../richEditorTransformErrorRecoveryExtension.ts | 12 +++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/components/richEditorTransformErrorRecoveryExtension.test.ts b/src/components/richEditorTransformErrorRecoveryExtension.test.ts index 4a70be05..64ad9ced 100644 --- a/src/components/richEditorTransformErrorRecoveryExtension.test.ts +++ b/src/components/richEditorTransformErrorRecoveryExtension.test.ts @@ -61,6 +61,9 @@ describe('isRecoverableEditorTransformError', () => { expect(isRecoverableEditorTransformError(new RangeError( 'Invalid content for node blockContainer: ', ))).toBe(true) + expect(isRecoverableEditorTransformError(transformError( + 'Cannot join blockGroup onto blockContainer', + ))).toBe(true) expect(isRecoverableEditorTransformError(new RangeError( 'Inserted content deeper than insertion position', ))).toBe(true) @@ -111,6 +114,13 @@ describe('installRichEditorTransformErrorRecovery', () => { ) }) + 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 '), diff --git a/src/components/richEditorTransformErrorRecoveryExtension.ts b/src/components/richEditorTransformErrorRecoveryExtension.ts index f0266bda..2ac264fb 100644 --- a/src/components/richEditorTransformErrorRecoveryExtension.ts +++ b/src/components/richEditorTransformErrorRecoveryExtension.ts @@ -36,6 +36,7 @@ interface RepairableBlockNoteEditor { } type RecoveryReason = + | 'invalid_block_join' | 'invalid_insertion_depth' | 'mismatched_transaction' | 'stale_block_reference' @@ -81,11 +82,15 @@ function isTablePositionOutOfRangeError(error: unknown): boolean { return error instanceof RangeError && /^Index \d+ out of range for { console.warn('[editor] Recovered rich-editor transform error:', error) trackEvent('rich_editor_transform_error_recovered', { reason }) }