Files
tolaria/src/hooks/useTabManagement.ts
Test c4136d69b4 refactor: remove tab bar — single note open at a time
Replace the multi-tab model with single-note navigation. One note is
open at a time; clicking any note (sidebar, wikilink, relationship)
replaces the current note in the editor. Back/Forward history still
works via Cmd+[/].

Removed: TabBar component, useClosedTabHistory, tab reorder/close/
reopen logic, Cmd+W/Cmd+Shift+T shortcuts, Close Tab and Reopen
Closed Tab menu items, tabLayout utility, and all related tests.

Simplified: useTabManagement (single-note), useAppNavigation (no tab
lookup), useKeyboardNavigation (note nav only), useNoteCreation (no
close-tab cleanup), useDeleteActions (deselect instead of close tab),
Editor (no TabBar render), Rust menu (removed 2 menu items).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:24:49 +01:00

145 lines
4.6 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry } from '../types'
interface Tab {
entry: VaultEntry
content: string
}
// --- Content prefetch cache ---
// Stores in-flight or resolved note content promises, keyed by path.
// Cleared on vault reload to prevent stale content after external edits.
// Latency profile: eliminates 50-200ms IPC round-trip for hover/keyboard-prefetched notes.
const prefetchCache = new Map<string, Promise<string>>()
/** Prefetch a note's content into the in-memory cache.
* Safe to call multiple times — deduplicates concurrent requests for the same path.
* Cache is short-lived: cleared on vault reload via clearPrefetchCache(). */
export function prefetchNoteContent(path: string): void {
if (prefetchCache.has(path)) return
const promise = (isTauri()
? invoke<string>('get_note_content', { path })
: mockInvoke<string>('get_note_content', { path })
).catch((err) => {
// Remove failed prefetch so a retry can occur
prefetchCache.delete(path)
throw err
})
prefetchCache.set(path, promise)
}
/** Clear the prefetch cache. Call on vault reload to prevent stale content. */
export function clearPrefetchCache(): void {
prefetchCache.clear()
}
async function loadNoteContent(path: string): Promise<string> {
// Check prefetch cache first — eliminates IPC round-trip for prefetched notes
const cached = prefetchCache.get(path)
if (cached) {
prefetchCache.delete(path)
return cached
}
return isTauri()
? invoke<string>('get_note_content', { path })
: mockInvoke<string>('get_note_content', { path })
}
/** Sync title frontmatter with filename on note open.
* Returns true if the file was modified. */
export async function syncNoteTitle(path: string): Promise<boolean> {
try {
return isTauri()
? await invoke<boolean>('sync_note_title', { path })
: false // mock: no-op
} catch { return false }
}
export type { Tab }
export function useTabManagement() {
// Single-note model: tabs has 0 or 1 elements.
const [tabs, setTabs] = useState<Tab[]>([])
const [activeTabPath, setActiveTabPath] = useState<string | null>(null)
const activeTabPathRef = useRef(activeTabPath)
useEffect(() => { activeTabPathRef.current = activeTabPath })
const tabsRef = useRef(tabs)
useEffect(() => { tabsRef.current = tabs })
// Sequence counter for rapid-switch safety: only the latest navigation wins.
const navSeqRef = useRef(0)
/** Open a note — replaces the current note (single-note model). */
const handleSelectNote = useCallback(async (entry: VaultEntry) => {
// Already viewing this note — no-op
if (tabsRef.current.some(t => t.entry.path === entry.path)) {
setActiveTabPath(entry.path)
return
}
const seq = ++navSeqRef.current
await syncNoteTitle(entry.path)
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)
}
}
}, [])
const handleSwitchTab = useCallback((path: string) => { 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)
}, [])
const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => {
// 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)
}
}
}, [])
const closeAllTabs = useCallback(() => {
setTabs([])
setActiveTabPath(null)
}, [])
return {
tabs,
setTabs,
activeTabPath,
activeTabPathRef,
handleSelectNote,
openTabWithContent,
handleSwitchTab,
handleReplaceActiveTab,
closeAllTabs,
}
}