Files
tolaria/src/hooks/useTabManagement.ts
Test 70f984c399 feat: add Playwright smoke tests, data-tab-path attr, store full VaultEntry in closed tab history
- Refactor useClosedTabHistory to store full VaultEntry (not stub) for reliable reopening
- Add data-tab-path attribute to TabItem for precise Playwright selectors
- Add 2 Playwright smoke tests: single close/reopen and empty-history no-op
- Update ARCHITECTURE.md and ABSTRACTIONS.md with closed tab history docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:54:48 +01:00

233 lines
7.9 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'
import { useClosedTabHistory } from './useClosedTabHistory'
interface Tab {
entry: VaultEntry
content: string
}
const TAB_ORDER_KEY = 'laputa-tab-order'
// --- 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()
}
function saveTabOrder(tabs: Tab[]) {
try {
localStorage.setItem(TAB_ORDER_KEY, JSON.stringify(tabs.map(t => t.entry.path)))
} catch { /* localStorage may be unavailable */ }
}
function loadTabOrder(): string[] {
try {
const stored = localStorage.getItem(TAB_ORDER_KEY)
return stored ? JSON.parse(stored) : []
} catch {
return []
}
}
function clearTabOrder() {
try { localStorage.removeItem(TAB_ORDER_KEY) } catch { /* noop */ }
}
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 })
}
function addTabIfAbsent(prev: Tab[], entry: VaultEntry, content: string): Tab[] {
if (prev.some((t) => t.entry.path === entry.path)) return prev
return [...prev, { entry, content }]
}
function resolveNextActiveTab(prev: Tab[], closedPath: string): string | null {
const next = prev.filter((t) => t.entry.path !== closedPath)
if (next.length === 0) return null
const closedIdx = prev.findIndex((t) => t.entry.path === closedPath)
const newIdx = Math.min(closedIdx, next.length - 1)
return next[newIdx].entry.path
}
function replaceTabEntry(prev: Tab[], targetPath: string, entry: VaultEntry, content: string): Tab[] {
return prev.map((t) => t.entry.path === targetPath ? { entry, content } : t)
}
function reorderArray(tabs: Tab[], fromIndex: number, toIndex: number): Tab[] {
const next = [...tabs]
const [moved] = next.splice(fromIndex, 1)
next.splice(toIndex, 0, moved)
return next
}
function restoreOrder(prev: Tab[], savedOrder: string[]): Tab[] {
if (prev.length <= 1) return prev
const pathToTab = new Map(prev.map(t => [t.entry.path, t]))
const ordered: Tab[] = []
for (const path of savedOrder) {
const tab = pathToTab.get(path)
if (tab) {
ordered.push(tab)
pathToTab.delete(path)
}
}
for (const tab of pathToTab.values()) {
ordered.push(tab)
}
return ordered
}
function isTabOpen(tabs: Tab[], path: string): boolean {
return tabs.some((t) => t.entry.path === path)
}
async function loadAndSetTab(
entry: VaultEntry,
updater: (prev: Tab[], content: string) => Tab[],
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>,
) {
try {
const content = await loadNoteContent(entry.path)
setTabs((prev) => updater(prev, content))
} catch (err) {
console.warn('Failed to load note content:', err)
setTabs((prev) => updater(prev, ''))
}
}
export type { Tab }
export function useTabManagement() {
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 })
const handleCloseTabRef = useRef<(path: string) => void>(() => {})
const closedTabHistory = useClosedTabHistory()
// Sequence counter for rapid-switch safety: only the latest navigation wins.
// Prevents stale content from an earlier click appearing after a later click.
const navSeqRef = useRef(0)
const handleSelectNote = useCallback(async (entry: VaultEntry) => {
if (isTabOpen(tabsRef.current, entry.path)) { setActiveTabPath(entry.path); return }
const seq = ++navSeqRef.current
await loadAndSetTab(entry, (prev, content) => addTabIfAbsent(prev, entry, content), setTabs)
if (navSeqRef.current === seq) setActiveTabPath(entry.path)
}, [])
const handleCloseTab = useCallback((path: string) => {
setTabs((prev) => {
const idx = prev.findIndex((t) => t.entry.path === path)
if (idx !== -1) closedTabHistory.push(path, idx, prev[idx].entry)
const next = prev.filter((t) => t.entry.path !== path)
if (path === activeTabPathRef.current) { setActiveTabPath(resolveNextActiveTab(prev, path)) }
return next
})
}, [closedTabHistory])
useEffect(() => { handleCloseTabRef.current = handleCloseTab })
const handleSwitchTab = useCallback((path: string) => { setActiveTabPath(path) }, [])
const handleReorderTabs = useCallback((fromIndex: number, toIndex: number) => {
setTabs((prev) => { const next = reorderArray(prev, fromIndex, toIndex); saveTabOrder(next); return next })
}, [])
/** Open a tab with known content — no IPC round-trip. Used for newly created notes. */
const openTabWithContent = useCallback((entry: VaultEntry, content: string) => {
if (isTabOpen(tabsRef.current, entry.path)) { setActiveTabPath(entry.path); return }
setTabs((prev) => addTabIfAbsent(prev, entry, content))
setActiveTabPath(entry.path)
}, [])
const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => {
if (isTabOpen(tabsRef.current, entry.path)) { setActiveTabPath(entry.path); return }
const currentPath = activeTabPathRef.current
if (!currentPath) { handleSelectNote(entry); return }
const seq = ++navSeqRef.current
await loadAndSetTab(entry, (prev, content) => replaceTabEntry(prev, currentPath, entry, content), setTabs)
if (navSeqRef.current === seq) setActiveTabPath(entry.path)
}, [handleSelectNote])
const handleReopenClosedTab = useCallback(async () => {
const closed = closedTabHistory.pop()
if (!closed) return
// If tab is already open, just switch to it
if (isTabOpen(tabsRef.current, closed.path)) {
setActiveTabPath(closed.path)
return
}
// Reopen using the stored VaultEntry — loads fresh content from disk
await handleSelectNote(closed.entry)
}, [closedTabHistory, handleSelectNote])
const closeAllTabs = useCallback(() => {
setTabs([])
setActiveTabPath(null)
}, [])
useEffect(() => {
if (tabs.length > 0) saveTabOrder(tabs)
else clearTabOrder()
}, [tabs])
useEffect(() => {
const savedOrder = loadTabOrder()
if (savedOrder.length > 0) {
setTabs((prev) => restoreOrder(prev, savedOrder)) // eslint-disable-line react-hooks/set-state-in-effect -- restore tab order on mount
}
}, [])
return {
tabs,
setTabs,
activeTabPath,
activeTabPathRef,
handleCloseTabRef,
handleSelectNote,
openTabWithContent,
handleCloseTab,
handleSwitchTab,
handleReorderTabs,
handleReplaceActiveTab,
handleReopenClosedTab,
closeAllTabs,
closedTabHistory,
}
}