feat: fast note open — use allContent cache to skip IPC disk reads

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 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-08 20:33:57 +01:00
parent 8ce0706b82
commit 749647bf64
4 changed files with 96 additions and 5 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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())

View File

@@ -84,19 +84,28 @@ async function loadAndSetTab(
entry: VaultEntry,
updater: (prev: Tab[], content: string) => Tab[],
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>,
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<Tab[]>([])
const [activeTabPath, setActiveTabPath] = useState<string | null>(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])