From 749647bf64a267f00ccf17b89bc351eb0700c610 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 8 Mar 2026 20:33:57 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20fast=20note=20open=20=E2=80=94=20use=20?= =?UTF-8?q?allContent=20cache=20to=20skip=20IPC=20disk=20reads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleSelectNote and handleReplaceActiveTab now check the in-memory allContent cache before issuing a Tauri IPC call. Cache hits open the tab synchronously (zero latency). Cache misses fall back to the disk read and populate allContent via onContentLoaded so subsequent opens of the same note are instant. Co-Authored-By: Claude Opus 4.6 --- src/App.tsx | 7 +++- src/hooks/useNoteActions.ts | 7 +++- src/hooks/useTabManagement.test.ts | 56 ++++++++++++++++++++++++++++++ src/hooks/useTabManagement.ts | 31 +++++++++++++++-- 4 files changed, 96 insertions(+), 5 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 3e0dbff9..edd085af 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -182,7 +182,12 @@ function App() { // Read at callback time, so it's always current when user presses Cmd+N. const contentChangeRef = useRef<(path: string, content: string) => void>(() => {}) - const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles }) + // Stable ref for allContent so getCachedContent callback never changes identity + const allContentForCacheRef = useRef(vault.allContent) + allContentForCacheRef.current = vault.allContent // eslint-disable-line react-hooks/refs -- ref sync pattern + const getCachedContent = useCallback((path: string) => allContentForCacheRef.current[path], []) + + const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, getCachedContent }) // Keep tab entries in sync with vault entries so banners (trash/archive) // and read-only state react immediately without reopening the note. diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index a1166e89..3061a516 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -36,6 +36,8 @@ export interface NoteActionsConfig { markContentPending?: (path: string, content: string) => void /** Called after a new note is persisted to disk (e.g. to refresh git status for Changes view). */ onNewNotePersisted?: () => void + /** Return cached content for a path (from allContent), or undefined on cache miss. */ + getCachedContent?: (path: string) => string | undefined } async function performRename( @@ -326,7 +328,10 @@ async function runFrontmatterAndApply( export function useNoteActions(config: NoteActionsConfig) { const { addEntry, removeEntry, updateContent, entries, setToastMessage, updateEntry, addPendingSave, removePendingSave } = config - const tabMgmt = useTabManagement() + const tabMgmt = useTabManagement({ + getCachedContent: config.getCachedContent, + onContentLoaded: updateContent, + }) const { setTabs, handleSelectNote, openTabWithContent, handleCloseTab, handleCloseTabRef, activeTabPathRef, handleSwitchTab } = tabMgmt const tabsRef = useRef(tabMgmt.tabs) // eslint-disable-next-line react-hooks/refs diff --git a/src/hooks/useTabManagement.test.ts b/src/hooks/useTabManagement.test.ts index e2180d40..9fa179dd 100644 --- a/src/hooks/useTabManagement.test.ts +++ b/src/hooks/useTabManagement.test.ts @@ -74,6 +74,38 @@ describe('useTabManagement', () => { expect(result.current.activeTabPath).toBe('/vault/note/a.md') }) + it('uses cached content when getCachedContent returns a value', async () => { + const getCachedContent = vi.fn().mockReturnValue('# Cached content') + const onContentLoaded = vi.fn() + const { result } = renderHook(() => useTabManagement({ getCachedContent, onContentLoaded })) + const entry = makeEntry({ path: '/vault/note/a.md' }) + + await act(async () => { + await result.current.handleSelectNote(entry) + }) + + expect(result.current.tabs).toHaveLength(1) + expect(result.current.tabs[0].content).toBe('# Cached content') + expect(getCachedContent).toHaveBeenCalledWith('/vault/note/a.md') + // Should NOT call onContentLoaded — content came from cache, no disk read + expect(onContentLoaded).not.toHaveBeenCalled() + }) + + it('falls back to disk read and calls onContentLoaded when cache misses', async () => { + const getCachedContent = vi.fn().mockReturnValue(undefined) + const onContentLoaded = vi.fn() + const { result } = renderHook(() => useTabManagement({ getCachedContent, onContentLoaded })) + const entry = makeEntry({ path: '/vault/note/a.md' }) + + await act(async () => { + await result.current.handleSelectNote(entry) + }) + + expect(result.current.tabs).toHaveLength(1) + expect(result.current.tabs[0].content).toBe('# Mock content') + expect(onContentLoaded).toHaveBeenCalledWith('/vault/note/a.md', '# Mock content') + }) + it('switches to existing tab without duplicating', async () => { const { result } = renderHook(() => useTabManagement()) const entry = makeEntry({ path: '/vault/note/a.md' }) @@ -296,6 +328,30 @@ describe('useTabManagement', () => { expect(result.current.activeTabPath).toBe('/vault/a.md') }) + it('uses cached content when replacing active tab', async () => { + const getCachedContent = vi.fn((path: string) => + path === '/vault/b.md' ? '# B cached' : undefined, + ) + const onContentLoaded = vi.fn() + const { result } = renderHook(() => useTabManagement({ getCachedContent, onContentLoaded })) + + await act(async () => { + await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' })) + }) + // Reset after the first disk read (for A) + onContentLoaded.mockClear() + + const replacement = makeEntry({ path: '/vault/b.md', title: 'B' }) + await act(async () => { + await result.current.handleReplaceActiveTab(replacement) + }) + + expect(result.current.tabs).toHaveLength(1) + expect(result.current.tabs[0].content).toBe('# B cached') + // Cache hit for B — no disk read, no onContentLoaded call + expect(onContentLoaded).not.toHaveBeenCalled() + }) + it('switches to existing tab instead of replacing when note is already open', async () => { const { result } = renderHook(() => useTabManagement()) diff --git a/src/hooks/useTabManagement.ts b/src/hooks/useTabManagement.ts index 76b82f33..df9dcbda 100644 --- a/src/hooks/useTabManagement.ts +++ b/src/hooks/useTabManagement.ts @@ -84,19 +84,28 @@ async function loadAndSetTab( entry: VaultEntry, updater: (prev: Tab[], content: string) => Tab[], setTabs: React.Dispatch>, + onContentLoaded?: (path: string, content: string) => void, ) { try { const content = await loadNoteContent(entry.path) setTabs((prev) => updater(prev, content)) + onContentLoaded?.(entry.path, content) } catch (err) { console.warn('Failed to load note content:', err) setTabs((prev) => updater(prev, '')) } } +export interface TabManagementOptions { + /** Return cached content for a path, or undefined for a cache miss. */ + getCachedContent?: (path: string) => string | undefined + /** Called after a disk read so the caller can populate its cache. */ + onContentLoaded?: (path: string, content: string) => void +} + export type { Tab } -export function useTabManagement() { +export function useTabManagement(options?: TabManagementOptions) { const [tabs, setTabs] = useState([]) const [activeTabPath, setActiveTabPath] = useState(null) const activeTabPathRef = useRef(activeTabPath) @@ -104,10 +113,20 @@ export function useTabManagement() { const tabsRef = useRef(tabs) useEffect(() => { tabsRef.current = tabs }) const handleCloseTabRef = useRef<(path: string) => void>(() => {}) + const getCachedContentRef = useRef(options?.getCachedContent) + useEffect(() => { getCachedContentRef.current = options?.getCachedContent }) + const onContentLoadedRef = useRef(options?.onContentLoaded) + useEffect(() => { onContentLoadedRef.current = options?.onContentLoaded }) const handleSelectNote = useCallback(async (entry: VaultEntry) => { if (isTabOpen(tabsRef.current, entry.path)) { setActiveTabPath(entry.path); return } - await loadAndSetTab(entry, (prev, content) => addTabIfAbsent(prev, entry, content), setTabs) + const cached = getCachedContentRef.current?.(entry.path) + if (cached !== undefined) { + setTabs((prev) => addTabIfAbsent(prev, entry, cached)) + setActiveTabPath(entry.path) + return + } + await loadAndSetTab(entry, (prev, content) => addTabIfAbsent(prev, entry, content), setTabs, onContentLoadedRef.current) setActiveTabPath(entry.path) }, []) @@ -137,7 +156,13 @@ export function useTabManagement() { if (isTabOpen(tabsRef.current, entry.path)) { setActiveTabPath(entry.path); return } const currentPath = activeTabPathRef.current if (!currentPath) { handleSelectNote(entry); return } - await loadAndSetTab(entry, (prev, content) => replaceTabEntry(prev, currentPath, entry, content), setTabs) + const cached = getCachedContentRef.current?.(entry.path) + if (cached !== undefined) { + setTabs((prev) => replaceTabEntry(prev, currentPath, entry, cached)) + setActiveTabPath(entry.path) + return + } + await loadAndSetTab(entry, (prev, content) => replaceTabEntry(prev, currentPath, entry, content), setTabs, onContentLoadedRef.current) setActiveTabPath(entry.path) }, [handleSelectNote])