From 92c0b895dc77f4c91a78e7c46d484e4c9201ba4c Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 5 May 2026 02:00:28 +0200 Subject: [PATCH] fix(editor): preserve focus during vault refresh --- src/App.tsx | 27 +++++++++++++++++++++++++-- src/hooks/useVaultBridge.ts | 4 ++++ src/utils/pulledVaultRefresh.test.ts | 15 +++++++++++++++ src/utils/pulledVaultRefresh.ts | 3 +++ 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 0d82818e..163dc173 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -130,6 +130,14 @@ import { import { requestPlainTextPaste } from './utils/plainTextPaste' import './App.css' +const ACTIVE_EDITOR_SURFACE_SELECTOR = '.editor__blocknote-container, .raw-editor-codemirror' + +function isActiveElementInsideEditorSurface(): boolean { + const activeElement = document.activeElement + if (!(activeElement instanceof HTMLElement)) return false + return Boolean(activeElement.closest(ACTIVE_EDITOR_SURFACE_SELECTOR)) +} + // Type declarations for mock content storage and test overrides declare global { interface Window { @@ -619,12 +627,18 @@ function App() { useEffect(() => { noteWindowActionsRef.current = { handleSelectNote, openTabWithContent } }, [handleSelectNote, openTabWithContent]) - const handlePulledVaultUpdate = useCallback(async (updatedFiles: string[]) => { + const handleVaultUpdate = useCallback(async ( + updatedFiles: string[], + options: { preserveFocusedEditor?: boolean } = {}, + ) => { await refreshPulledVaultState({ activeTabPath: notes.activeTabPath, closeAllTabs, getActiveTabPath: () => notes.activeTabPathRef.current, hasUnsavedChanges: (path) => vault.unsavedPaths.has(path), + shouldKeepActiveEditorMounted: options.preserveFocusedEditor + ? isActiveElementInsideEditorSurface + : undefined, reloadFolders: vault.reloadFolders, reloadVault: vault.reloadVault, reloadViews: vault.reloadViews, @@ -643,9 +657,17 @@ function App() { vault.reloadViews, vault.unsavedPaths, ]) + const handlePulledVaultUpdate = useCallback( + (updatedFiles: string[]) => handleVaultUpdate(updatedFiles), + [handleVaultUpdate], + ) + const handleFocusedVaultUpdate = useCallback( + (updatedFiles: string[]) => handleVaultUpdate(updatedFiles, { preserveFocusedEditor: true }), + [handleVaultUpdate], + ) useVaultWatcher({ vaultPath: noteWindowParams ? '' : resolvedPath, - onVaultChanged: handlePulledVaultUpdate, + onVaultChanged: handleFocusedVaultUpdate, filterChangedPaths: filterExternalVaultPaths, }) const autoSync = useAutoSync({ @@ -774,6 +796,7 @@ function App() { closeAllTabs, replaceActiveTab: handleReplaceActiveTab, hasUnsavedChanges: (path) => vault.unsavedPaths.has(path), + shouldKeepActiveEditorMounted: isActiveElementInsideEditorSurface, onSelectNote: notes.handleSelectNote, activeTabPath: notes.activeTabPath, getActiveTabPath: () => notes.activeTabPathRef.current, diff --git a/src/hooks/useVaultBridge.ts b/src/hooks/useVaultBridge.ts index 66690462..b6b34106 100644 --- a/src/hooks/useVaultBridge.ts +++ b/src/hooks/useVaultBridge.ts @@ -11,6 +11,7 @@ interface VaultBridgeDeps { closeAllTabs: () => void replaceActiveTab: (entry: VaultEntry) => Promise hasUnsavedChanges: (path: string) => boolean + shouldKeepActiveEditorMounted?: () => boolean onSelectNote: (entry: VaultEntry) => void activeTabPath: string | null getActiveTabPath?: () => string | null @@ -33,6 +34,7 @@ export function useVaultBridge({ closeAllTabs, replaceActiveTab, hasUnsavedChanges, + shouldKeepActiveEditorMounted, onSelectNote, activeTabPath, getActiveTabPath, @@ -50,6 +52,7 @@ export function useVaultBridge({ closeAllTabs, getActiveTabPath, hasUnsavedChanges, + shouldKeepActiveEditorMounted, reloadFolders, reloadVault, reloadViews, @@ -62,6 +65,7 @@ export function useVaultBridge({ closeAllTabs, getActiveTabPath, hasUnsavedChanges, + shouldKeepActiveEditorMounted, reloadFolders, reloadVault, reloadViews, diff --git a/src/utils/pulledVaultRefresh.test.ts b/src/utils/pulledVaultRefresh.test.ts index b2e9b916..66b5987f 100644 --- a/src/utils/pulledVaultRefresh.test.ts +++ b/src/utils/pulledVaultRefresh.test.ts @@ -88,6 +88,21 @@ describe('refreshPulledVaultState', () => { expect(options.closeAllTabs).not.toHaveBeenCalled() }) + it('keeps the active tab mounted while the editor is focused', async () => { + const options = makeOptions({ + shouldKeepActiveEditorMounted: vi.fn(() => true), + }) + + await refreshPulledVaultState(options) + + expect(options.shouldKeepActiveEditorMounted).toHaveBeenCalledOnce() + expect(options.reloadVault).toHaveBeenCalledOnce() + expect(options.reloadFolders).toHaveBeenCalledOnce() + expect(options.reloadViews).toHaveBeenCalledOnce() + expect(options.replaceActiveTab).not.toHaveBeenCalled() + expect(options.closeAllTabs).not.toHaveBeenCalled() + }) + it('skips stale tab replacement when the active note changes during reload', async () => { let resolveReload!: (entries: VaultEntry[]) => void let currentActivePath: string | null = '/vault/active.md' diff --git a/src/utils/pulledVaultRefresh.ts b/src/utils/pulledVaultRefresh.ts index 82d92f18..9b4053bb 100644 --- a/src/utils/pulledVaultRefresh.ts +++ b/src/utils/pulledVaultRefresh.ts @@ -6,6 +6,7 @@ interface PulledVaultRefreshOptions { getActiveTabPath?: () => string | null closeAllTabs: () => void hasUnsavedChanges: (path: string) => boolean + shouldKeepActiveEditorMounted?: () => boolean reloadFolders: () => Promise | unknown reloadVault: () => Promise reloadViews: () => Promise | unknown @@ -33,6 +34,7 @@ export async function refreshPulledVaultState(options: PulledVaultRefreshOptions closeAllTabs, getActiveTabPath, hasUnsavedChanges, + shouldKeepActiveEditorMounted, reloadFolders, reloadVault, reloadViews, @@ -51,6 +53,7 @@ export async function refreshPulledVaultState(options: PulledVaultRefreshOptions if (!activeTabPath || !latestActiveTabPath) return entries if (didActivePathChange(activeTabPath, latestActiveTabPath)) return entries if (hasUnsavedChanges(latestActiveTabPath)) return entries + if (shouldKeepActiveEditorMounted?.()) return entries const refreshedEntry = findByNotePath(entries, latestActiveTabPath) if (!refreshedEntry) {