From 81eb5e844d2dbe17f5a3fdff3b93fcc712ae6e00 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 9 Apr 2026 12:20:03 +0200 Subject: [PATCH] fix: hide stale title chrome while notes load --- .../EditorContentLayout.test.tsx | 14 ++ .../editor-content/EditorContentLayout.tsx | 6 +- src/hooks/useTabManagement.test.ts | 127 ++++++++--------- src/hooks/useTabManagement.ts | 132 ++++++++++++------ 4 files changed, 166 insertions(+), 113 deletions(-) diff --git a/src/components/editor-content/EditorContentLayout.test.tsx b/src/components/editor-content/EditorContentLayout.test.tsx index f378218b..7e8125c3 100644 --- a/src/components/editor-content/EditorContentLayout.test.tsx +++ b/src/components/editor-content/EditorContentLayout.test.tsx @@ -117,4 +117,18 @@ describe('EditorContentLayout', () => { expect(titleRow?.querySelector('[data-testid="note-icon-display"]')).not.toBeNull() expect(titleRow?.querySelector('[data-testid="title-field-input"]')).not.toBeNull() }) + + it('shows the loading skeleton instead of stale editor chrome while switching tabs', () => { + const { container } = render( + , + ) + + expect(container.querySelector('.animate-pulse')).not.toBeNull() + expect(screen.queryByTestId('title-field-input')).not.toBeInTheDocument() + }) }) diff --git a/src/components/editor-content/EditorContentLayout.tsx b/src/components/editor-content/EditorContentLayout.tsx index 5a2eea97..29d50560 100644 --- a/src/components/editor-content/EditorContentLayout.tsx +++ b/src/components/editor-content/EditorContentLayout.tsx @@ -302,7 +302,11 @@ export function EditorContentLayout(model: EditorContentModel) { } = model if (!activeTab) { - return
+ return ( +
+ {isLoadingNewTab && showEditor && } +
+ ) } return ( diff --git a/src/hooks/useTabManagement.test.ts b/src/hooks/useTabManagement.test.ts index bf6078f0..cf47551f 100644 --- a/src/hooks/useTabManagement.test.ts +++ b/src/hooks/useTabManagement.test.ts @@ -33,6 +33,26 @@ const makeEntry = (overrides: Partial = {}): VaultEntry => ({ ...overrides, }) +type HookState = { current: ReturnType } + +async function selectNote(result: HookState, overrides: Partial) { + await act(async () => { + await result.current.handleSelectNote(makeEntry(overrides)) + }) +} + +async function replaceActiveNote(result: HookState, overrides: Partial) { + await act(async () => { + await result.current.handleReplaceActiveTab(makeEntry(overrides)) + }) +} + +function expectSingleActiveTab(result: HookState, path: string) { + expect(result.current.tabs).toHaveLength(1) + expect(result.current.tabs[0].entry.path).toBe(path) + expect(result.current.activeTabPath).toBe(path) +} + describe('useTabManagement (single-note model)', () => { beforeEach(() => { vi.clearAllMocks() @@ -47,41 +67,47 @@ describe('useTabManagement (single-note model)', () => { describe('handleSelectNote', () => { it('opens a note and sets it active', async () => { const { result } = renderHook(() => useTabManagement()) - const entry = makeEntry({ path: '/vault/note/a.md' }) + await selectNote(result, { path: '/vault/note/a.md' }) + expectSingleActiveTab(result, '/vault/note/a.md') + }) - await act(async () => { - await result.current.handleSelectNote(entry) + it('switches the active path immediately while the next note is still loading', async () => { + const { mockInvoke } = await import('../mock-tauri') + + let resolveContent: (value: string) => void + vi.mocked(mockInvoke).mockImplementationOnce( + () => new Promise((resolve) => { resolveContent = resolve }), + ) + + const { result } = renderHook(() => useTabManagement()) + void act(() => { + void result.current.handleSelectNote(makeEntry({ path: '/vault/note/pending.md', title: 'Pending' })) }) - expect(result.current.tabs).toHaveLength(1) - expect(result.current.tabs[0].entry.path).toBe('/vault/note/a.md') - expect(result.current.activeTabPath).toBe('/vault/note/a.md') + expect(result.current.activeTabPath).toBe('/vault/note/pending.md') + expect(result.current.tabs).toEqual([]) + + await act(async () => { + resolveContent!('# Pending content') + }) + + expect(result.current.tabs[0].entry.path).toBe('/vault/note/pending.md') + expect(result.current.tabs[0].content).toBe('# Pending content') }) it('replaces the current note when selecting a different one', async () => { const { result } = renderHook(() => useTabManagement()) - - await act(async () => { - await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' })) - }) - await act(async () => { - await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' })) - }) - - expect(result.current.tabs).toHaveLength(1) - expect(result.current.tabs[0].entry.path).toBe('/vault/b.md') - expect(result.current.activeTabPath).toBe('/vault/b.md') + await selectNote(result, { path: '/vault/a.md', title: 'A' }) + await selectNote(result, { path: '/vault/b.md', title: 'B' }) + expectSingleActiveTab(result, '/vault/b.md') }) it('is a no-op when selecting the already-open note', async () => { const { result } = renderHook(() => useTabManagement()) - const entry = makeEntry({ path: '/vault/a.md' }) - + const entry = { path: '/vault/a.md' } + await selectNote(result, entry) await act(async () => { - await result.current.handleSelectNote(entry) - }) - await act(async () => { - await result.current.handleSelectNote(entry) + await result.current.handleSelectNote(makeEntry(entry)) }) expect(result.current.tabs).toHaveLength(1) @@ -93,11 +119,7 @@ describe('useTabManagement (single-note model)', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const { result } = renderHook(() => useTabManagement()) - const entry = makeEntry() - - await act(async () => { - await result.current.handleSelectNote(entry) - }) + await selectNote(result, {}) expect(result.current.tabs).toHaveLength(1) expect(result.current.tabs[0].content).toBe('') @@ -108,31 +130,17 @@ describe('useTabManagement (single-note model)', () => { describe('handleReplaceActiveTab', () => { it('replaces the current note with a new entry', async () => { const { result } = renderHook(() => useTabManagement()) - - await act(async () => { - await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' })) - }) - - 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].entry.path).toBe('/vault/b.md') - expect(result.current.activeTabPath).toBe('/vault/b.md') + await selectNote(result, { path: '/vault/a.md', title: 'A' }) + await replaceActiveNote(result, { path: '/vault/b.md', title: 'B' }) + expectSingleActiveTab(result, '/vault/b.md') }) it('is a no-op when replacing with the same entry', async () => { const { result } = renderHook(() => useTabManagement()) - const entry = makeEntry({ path: '/vault/a.md' }) - + const entry = { path: '/vault/a.md' } + await selectNote(result, entry) await act(async () => { - await result.current.handleSelectNote(entry) - }) - - await act(async () => { - await result.current.handleReplaceActiveTab(entry) + await result.current.handleReplaceActiveTab(makeEntry(entry)) }) expect(result.current.tabs).toHaveLength(1) @@ -140,14 +148,8 @@ describe('useTabManagement (single-note model)', () => { it('opens a note when no note is active', async () => { const { result } = renderHook(() => useTabManagement()) - const entry = makeEntry({ path: '/vault/a.md' }) - - await act(async () => { - await result.current.handleReplaceActiveTab(entry) - }) - - expect(result.current.tabs).toHaveLength(1) - expect(result.current.activeTabPath).toBe('/vault/a.md') + await replaceActiveNote(result, { path: '/vault/a.md' }) + expectSingleActiveTab(result, '/vault/a.md') }) }) @@ -189,10 +191,7 @@ describe('useTabManagement (single-note model)', () => { describe('closeAllTabs', () => { it('clears the note and active path', async () => { const { result } = renderHook(() => useTabManagement()) - - await act(async () => { - await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md' })) - }) + await selectNote(result, { path: '/vault/a.md' }) act(() => { result.current.closeAllTabs() @@ -212,9 +211,7 @@ describe('useTabManagement (single-note model)', () => { await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1)) const { result } = renderHook(() => useTabManagement()) - await act(async () => { - await result.current.handleSelectNote(makeEntry({ path: '/vault/note/pre.md', title: 'Pre' })) - }) + await selectNote(result, { path: '/vault/note/pre.md', title: 'Pre' }) expect(result.current.tabs[0].content).toBe('# Prefetched content') expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1) @@ -231,9 +228,7 @@ describe('useTabManagement (single-note model)', () => { vi.mocked(mockInvoke).mockResolvedValue('# Fresh') const { result } = renderHook(() => useTabManagement()) - await act(async () => { - await result.current.handleSelectNote(makeEntry({ path: '/vault/note/stale.md', title: 'Stale' })) - }) + await selectNote(result, { path: '/vault/note/stale.md', title: 'Stale' }) expect(result.current.tabs[0].content).toBe('# Fresh') expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(2) diff --git a/src/hooks/useTabManagement.ts b/src/hooks/useTabManagement.ts index 47a639bd..6b0c49b6 100644 --- a/src/hooks/useTabManagement.ts +++ b/src/hooks/useTabManagement.ts @@ -49,6 +49,69 @@ async function loadNoteContent(path: string): Promise { export type { Tab } +function syncActiveTabPath( + activeTabPathRef: React.MutableRefObject, + setActiveTabPath: React.Dispatch>, + path: string | null, +) { + activeTabPathRef.current = path + setActiveTabPath(path) +} + +function setSingleTab( + tabsRef: React.MutableRefObject, + setTabs: React.Dispatch>, + nextTab: Tab, +) { + tabsRef.current = [nextTab] + setTabs([nextTab]) +} + +function isAlreadyViewingPath( + tabsRef: React.MutableRefObject, + activeTabPathRef: React.MutableRefObject, + path: string, +) { + return activeTabPathRef.current === path || tabsRef.current.some((tab) => tab.entry.path === path) +} + +async function navigateToEntry(options: { + entry: VaultEntry + navSeqRef: React.MutableRefObject + tabsRef: React.MutableRefObject + activeTabPathRef: React.MutableRefObject + setTabs: React.Dispatch> + setActiveTabPath: React.Dispatch> +}) { + const { + entry, + navSeqRef, + tabsRef, + activeTabPathRef, + setTabs, + setActiveTabPath, + } = options + + if (entry.fileKind === 'binary') return + if (isAlreadyViewingPath(tabsRef, activeTabPathRef, entry.path)) { + syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path) + return + } + + const seq = ++navSeqRef.current + syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path) + + try { + const content = await loadNoteContent(entry.path) + if (navSeqRef.current !== seq) return + setSingleTab(tabsRef, setTabs, { entry, content }) + } catch (err) { + console.warn('Failed to load note content:', err) + if (navSeqRef.current !== seq) return + setSingleTab(tabsRef, setTabs, { entry, content: '' }) + } +} + export function useTabManagement() { // Single-note model: tabs has 0 or 1 elements. const [tabs, setTabs] = useState([]) @@ -63,64 +126,41 @@ export function useTabManagement() { /** Open a note — replaces the current note (single-note model). */ const handleSelectNote = useCallback(async (entry: VaultEntry) => { - // Binary files cannot be opened - if (entry.fileKind === 'binary') return - // Already viewing this note — no-op - if (tabsRef.current.some(t => t.entry.path === entry.path)) { - setActiveTabPath(entry.path) - return - } - const seq = ++navSeqRef.current - try { - const content = await loadNoteContent(entry.path) - if (navSeqRef.current === seq) { - setTabs([{ entry, content }]) - setActiveTabPath(entry.path) - } - } catch (err) { - console.warn('Failed to load note content:', err) - if (navSeqRef.current === seq) { - setTabs([{ entry, content: '' }]) - setActiveTabPath(entry.path) - } - } + await navigateToEntry({ + entry, + navSeqRef, + tabsRef, + activeTabPathRef, + setTabs, + setActiveTabPath, + }) }, []) - const handleSwitchTab = useCallback((path: string) => { setActiveTabPath(path) }, []) + const handleSwitchTab = useCallback((path: string) => { + syncActiveTabPath(activeTabPathRef, setActiveTabPath, path) + }, []) /** Open a tab with known content — no IPC round-trip. Used for newly created notes. */ const openTabWithContent = useCallback((entry: VaultEntry, content: string) => { - setTabs([{ entry, content }]) - setActiveTabPath(entry.path) + setSingleTab(tabsRef, setTabs, { entry, content }) + syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path) }, []) const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => { - // Binary files cannot be opened - if (entry.fileKind === 'binary') return - // In single-note model, replace is the same as select - if (tabsRef.current.some(t => t.entry.path === entry.path)) { - setActiveTabPath(entry.path) - return - } - const seq = ++navSeqRef.current - try { - const content = await loadNoteContent(entry.path) - if (navSeqRef.current === seq) { - setTabs([{ entry, content }]) - setActiveTabPath(entry.path) - } - } catch (err) { - console.warn('Failed to load note content:', err) - if (navSeqRef.current === seq) { - setTabs([{ entry, content: '' }]) - setActiveTabPath(entry.path) - } - } + await navigateToEntry({ + entry, + navSeqRef, + tabsRef, + activeTabPathRef, + setTabs, + setActiveTabPath, + }) }, []) const closeAllTabs = useCallback(() => { + tabsRef.current = [] setTabs([]) - setActiveTabPath(null) + syncActiveTabPath(activeTabPathRef, setActiveTabPath, null) }, []) return {