diff --git a/src/App.tsx b/src/App.tsx
index e0fad570..1eb4a8e6 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -155,23 +155,33 @@ function App() {
navFromHistoryRef.current = false
}, [notes.activeTabPath]) // eslint-disable-line react-hooks/exhaustive-deps -- navHistory.push is stable
- const isTabOpen = useCallback((path: string) => notes.tabs.some(t => t.entry.path === path), [notes.tabs])
+ const isEntryExists = useCallback((path: string) => vault.entries.some(e => e.path === path), [vault.entries])
const handleGoBack = useCallback(() => {
- const target = navHistory.goBack(isTabOpen)
+ const target = navHistory.goBack(isEntryExists)
if (target) {
navFromHistoryRef.current = true
- notes.handleSwitchTab(target)
+ if (notes.tabs.some(t => t.entry.path === target)) {
+ notes.handleSwitchTab(target)
+ } else {
+ const entry = vault.entries.find(e => e.path === target)
+ if (entry) notes.handleSelectNote(entry)
+ }
}
- }, [navHistory, isTabOpen, notes])
+ }, [navHistory, isEntryExists, vault.entries, notes])
const handleGoForward = useCallback(() => {
- const target = navHistory.goForward(isTabOpen)
+ const target = navHistory.goForward(isEntryExists)
if (target) {
navFromHistoryRef.current = true
- notes.handleSwitchTab(target)
+ if (notes.tabs.some(t => t.entry.path === target)) {
+ notes.handleSwitchTab(target)
+ } else {
+ const entry = vault.entries.find(e => e.path === target)
+ if (entry) notes.handleSelectNote(entry)
+ }
}
- }, [navHistory, isTabOpen, notes])
+ }, [navHistory, isEntryExists, vault.entries, notes])
// Mouse button 3/4 (back/forward) and macOS trackpad two-finger swipe
useEffect(() => {
diff --git a/src/components/TabBar.test.tsx b/src/components/TabBar.test.tsx
index 3d638c09..d9b13c13 100644
--- a/src/components/TabBar.test.tsx
+++ b/src/components/TabBar.test.tsx
@@ -215,6 +215,33 @@ describe('TabBar', () => {
expect(screen.queryByTestId('tab-new-indicator')).not.toBeInTheDocument()
})
+ it('renders nav back/forward buttons', () => {
+ const tabs = makeTabs(['Alpha'])
+ render()
+ expect(screen.getByTestId('nav-back')).toBeInTheDocument()
+ expect(screen.getByTestId('nav-forward')).toBeInTheDocument()
+ })
+
+ it('disables nav buttons when canGoBack/canGoForward are false', () => {
+ const tabs = makeTabs(['Alpha'])
+ render()
+ expect(screen.getByTestId('nav-back')).toBeDisabled()
+ expect(screen.getByTestId('nav-forward')).toBeDisabled()
+ })
+
+ it('enables nav buttons and fires handlers on click', () => {
+ const onGoBack = vi.fn()
+ const onGoForward = vi.fn()
+ const tabs = makeTabs(['Alpha'])
+ render()
+
+ fireEvent.click(screen.getByTestId('nav-back'))
+ expect(onGoBack).toHaveBeenCalledTimes(1)
+
+ fireEvent.click(screen.getByTestId('nav-forward'))
+ expect(onGoForward).toHaveBeenCalledTimes(1)
+ })
+
it('switches tab on click', () => {
const onSwitchTab = vi.fn()
const tabs = makeTabs(['Alpha', 'Beta'])
diff --git a/src/hooks/useNavigationHistory.test.ts b/src/hooks/useNavigationHistory.test.ts
index 6b987fd3..60876c39 100644
--- a/src/hooks/useNavigationHistory.test.ts
+++ b/src/hooks/useNavigationHistory.test.ts
@@ -115,6 +115,48 @@ describe('useNavigationHistory', () => {
expect(result.current.canGoForward).toBe(false)
})
+ it('goBack without predicate returns closed-tab paths (replace scenario)', () => {
+ const { result } = renderHook(() => useNavigationHistory())
+ // Simulate: open A, then B replaces A, then C replaces B
+ act(() => { result.current.push('/a'); result.current.push('/b'); result.current.push('/c') })
+
+ // Without a predicate, goBack returns /b even though its tab was replaced
+ let target: string | null = null
+ act(() => { target = result.current.goBack() })
+ expect(target).toBe('/b')
+
+ act(() => { target = result.current.goBack() })
+ expect(target).toBe('/a')
+ })
+
+ it('goBack with entry-exists predicate skips deleted notes but returns replaced tabs', () => {
+ const { result } = renderHook(() => useNavigationHistory())
+ act(() => { result.current.push('/a'); result.current.push('/deleted'); result.current.push('/c') })
+
+ // Simulate: /deleted was removed from vault, but /a still exists
+ const vaultPaths = new Set(['/a', '/c'])
+ const isEntryExists = (p: string) => vaultPaths.has(p)
+
+ let target: string | null = null
+ act(() => { target = result.current.goBack(isEntryExists) })
+ // Should skip /deleted and return /a
+ expect(target).toBe('/a')
+ })
+
+ it('goForward with entry-exists predicate skips deleted notes', () => {
+ const { result } = renderHook(() => useNavigationHistory())
+ act(() => { result.current.push('/a'); result.current.push('/deleted'); result.current.push('/c') })
+ act(() => { result.current.goBack(); result.current.goBack() })
+
+ const vaultPaths = new Set(['/a', '/c'])
+ const isEntryExists = (p: string) => vaultPaths.has(p)
+
+ let target: string | null = null
+ act(() => { target = result.current.goForward(isEntryExists) })
+ // Should skip /deleted and return /c
+ expect(target).toBe('/c')
+ })
+
it('handles long navigation chain', () => {
const { result } = renderHook(() => useNavigationHistory())
act(() => {