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 {