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])