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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user