fix: hide stale title chrome while notes load
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user