fix: hide stale title chrome while notes load

This commit is contained in:
lucaronin
2026-04-09 12:20:03 +02:00
parent 64e923ac5f
commit 90fbe82bca
4 changed files with 166 additions and 113 deletions

View File

@@ -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(
<EditorContentLayout
{...createModel({
activeTab: null,
isLoadingNewTab: true,
})}
/>,
)
expect(container.querySelector('.animate-pulse')).not.toBeNull()
expect(screen.queryByTestId('title-field-input')).not.toBeInTheDocument()
})
})

View File

@@ -302,7 +302,11 @@ export function EditorContentLayout(model: EditorContentModel) {
} = model
if (!activeTab) {
return <div className="flex flex-1 flex-col min-w-0 min-h-0" />
return (
<div className="flex flex-1 flex-col min-w-0 min-h-0">
{isLoadingNewTab && showEditor && <EditorLoadingSkeleton />}
</div>
)
}
return (

View File

@@ -33,6 +33,26 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
...overrides,
})
type HookState = { current: ReturnType<typeof useTabManagement> }
async function selectNote(result: HookState, overrides: Partial<VaultEntry>) {
await act(async () => {
await result.current.handleSelectNote(makeEntry(overrides))
})
}
async function replaceActiveNote(result: HookState, overrides: Partial<VaultEntry>) {
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<string>((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)

View File

@@ -49,6 +49,69 @@ async function loadNoteContent(path: string): Promise<string> {
export type { Tab }
function syncActiveTabPath(
activeTabPathRef: React.MutableRefObject<string | null>,
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>,
path: string | null,
) {
activeTabPathRef.current = path
setActiveTabPath(path)
}
function setSingleTab(
tabsRef: React.MutableRefObject<Tab[]>,
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>,
nextTab: Tab,
) {
tabsRef.current = [nextTab]
setTabs([nextTab])
}
function isAlreadyViewingPath(
tabsRef: React.MutableRefObject<Tab[]>,
activeTabPathRef: React.MutableRefObject<string | null>,
path: string,
) {
return activeTabPathRef.current === path || tabsRef.current.some((tab) => tab.entry.path === path)
}
async function navigateToEntry(options: {
entry: VaultEntry
navSeqRef: React.MutableRefObject<number>
tabsRef: React.MutableRefObject<Tab[]>
activeTabPathRef: React.MutableRefObject<string | null>
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
}) {
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<Tab[]>([])
@@ -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 {