diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 8ca3e7a4..b18cbd87 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -416,6 +416,10 @@ The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels: 4. **GitHistoryPanel**: Shows recent commits from file history with relative timestamps. +## Closed Tab History + +`useClosedTabHistory` hook (`src/hooks/useClosedTabHistory.ts`) provides a LIFO stack for closed tab entries, used by `useTabManagement` to support Cmd+Shift+T reopen. Each entry stores the note's path, tab index, and full `VaultEntry`. The stack is in-memory only (resets on restart), capped at 20 entries, and deduplicates by path. + ## Search & Indexing ### Search Modes diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a34a61b1..c340b24b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -651,7 +651,7 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks: | `App.tsx` | `selection`, panel widths, dialog visibility, toast, view mode | UI state | | `useVaultLoader` | `entries`, `allContent`, `modifiedFiles` | Vault data | | `useNoteActions` | `tabs`, `activeTabPath` | Open tabs and note operations | -| `useTabManagement` | Tab ordering, pinning, swapping | Tab lifecycle | +| `useTabManagement` | Tab ordering, pinning, swapping, closed-tab history | Tab lifecycle | | `useVaultSwitcher` | `vaultPath`, `extraVaults` | Vault switching | | `useThemeManager` | `themes`, `activeThemeId`, `isDark` | Theme state | | `useAIChat` | `messages`, `isStreaming` | AI chat conversation | @@ -672,6 +672,7 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c | Cmd+N | Create new note | | Cmd+S | Save current note | | Cmd+W | Close active tab | +| Cmd+Shift+T | Reopen last closed tab (LIFO history, up to 20) | | Cmd+Z / Cmd+Shift+Z | Undo / Redo | | Cmd+1–9 | Switch to tab N | | Cmd+[ / Cmd+] | Navigate back / forward | diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index 0a9e9cf2..89fcd8b1 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -218,6 +218,7 @@ function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBef }) { return (
({ + path, filename: path.split('/').pop() ?? '', title: path.split('/').pop()?.replace(/\.md$/, '') ?? '', + isA: 'Note', aliases: [], belongsTo: [], relatedTo: [], status: 'Active', owner: null, cadence: null, + archived: false, trashed: false, trashedAt: null, modifiedAt: 0, createdAt: 0, fileSize: 0, + snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [], +}) describe('useClosedTabHistory', () => { it('starts with empty history', () => { @@ -11,11 +19,12 @@ describe('useClosedTabHistory', () => { it('records a closed tab and allows reopening', () => { const { result } = renderHook(() => useClosedTabHistory()) - act(() => { result.current.push('/vault/a.md', 0) }) + act(() => { result.current.push('/vault/a.md', 0, stubEntry('/vault/a.md')) }) expect(result.current.canReopen).toBe(true) const entry = result.current.pop() - expect(entry).toEqual({ path: '/vault/a.md', index: 0 }) + expect(entry?.path).toBe('/vault/a.md') + expect(entry?.index).toBe(0) expect(result.current.canReopen).toBe(false) }) @@ -23,14 +32,14 @@ describe('useClosedTabHistory', () => { const { result } = renderHook(() => useClosedTabHistory()) act(() => { - result.current.push('/vault/a.md', 0) - result.current.push('/vault/b.md', 1) - result.current.push('/vault/c.md', 2) + result.current.push('/vault/a.md', 0, stubEntry('/vault/a.md')) + result.current.push('/vault/b.md', 1, stubEntry('/vault/b.md')) + result.current.push('/vault/c.md', 2, stubEntry('/vault/c.md')) }) - expect(result.current.pop()).toEqual({ path: '/vault/c.md', index: 2 }) - expect(result.current.pop()).toEqual({ path: '/vault/b.md', index: 1 }) - expect(result.current.pop()).toEqual({ path: '/vault/a.md', index: 0 }) + expect(result.current.pop()?.path).toBe('/vault/c.md') + expect(result.current.pop()?.path).toBe('/vault/b.md') + expect(result.current.pop()?.path).toBe('/vault/a.md') expect(result.current.pop()).toBeNull() }) @@ -44,13 +53,13 @@ describe('useClosedTabHistory', () => { act(() => { for (let i = 0; i < 25; i++) { - result.current.push(`/vault/${i}.md`, i) + result.current.push(`/vault/${i}.md`, i, stubEntry(`/vault/${i}.md`)) } }) // Should only have last 20 entries (5-24) const first = result.current.pop() - expect(first).toEqual({ path: '/vault/24.md', index: 24 }) + expect(first?.path).toBe('/vault/24.md') // Pop remaining 19 for (let i = 0; i < 19; i++) { @@ -63,14 +72,14 @@ describe('useClosedTabHistory', () => { const { result } = renderHook(() => useClosedTabHistory()) act(() => { - result.current.push('/vault/a.md', 0) - result.current.push('/vault/b.md', 1) - result.current.push('/vault/a.md', 2) // close a.md again at different index + result.current.push('/vault/a.md', 0, stubEntry('/vault/a.md')) + result.current.push('/vault/b.md', 1, stubEntry('/vault/b.md')) + result.current.push('/vault/a.md', 2, stubEntry('/vault/a.md')) // close a.md again at different index }) // a.md should only appear once (the latest), at the top - expect(result.current.pop()).toEqual({ path: '/vault/a.md', index: 2 }) - expect(result.current.pop()).toEqual({ path: '/vault/b.md', index: 1 }) + expect(result.current.pop()?.path).toBe('/vault/a.md') + expect(result.current.pop()?.path).toBe('/vault/b.md') expect(result.current.pop()).toBeNull() }) @@ -78,8 +87,8 @@ describe('useClosedTabHistory', () => { const { result } = renderHook(() => useClosedTabHistory()) act(() => { - result.current.push('/vault/a.md', 0) - result.current.push('/vault/b.md', 1) + result.current.push('/vault/a.md', 0, stubEntry('/vault/a.md')) + result.current.push('/vault/b.md', 1, stubEntry('/vault/b.md')) }) act(() => { result.current.clear() }) diff --git a/src/hooks/useClosedTabHistory.ts b/src/hooks/useClosedTabHistory.ts index 2eaec473..f2d35ab2 100644 --- a/src/hooks/useClosedTabHistory.ts +++ b/src/hooks/useClosedTabHistory.ts @@ -1,8 +1,10 @@ import { useCallback, useRef } from 'react' +import type { VaultEntry } from '../types' export interface ClosedTabEntry { path: string index: number + entry: VaultEntry } const MAX_HISTORY = 20 @@ -10,11 +12,11 @@ const MAX_HISTORY = 20 export function useClosedTabHistory() { const stackRef = useRef([]) - const push = useCallback((path: string, index: number) => { + const push = useCallback((path: string, index: number, entry: VaultEntry) => { const stack = stackRef.current // Remove any existing entry for this path (dedup) const filtered = stack.filter(e => e.path !== path) - filtered.push({ path, index }) + filtered.push({ path, index, entry }) // Cap at MAX_HISTORY if (filtered.length > MAX_HISTORY) { filtered.splice(0, filtered.length - MAX_HISTORY) diff --git a/src/hooks/useTabManagement.ts b/src/hooks/useTabManagement.ts index 08721803..baee22c6 100644 --- a/src/hooks/useTabManagement.ts +++ b/src/hooks/useTabManagement.ts @@ -154,7 +154,7 @@ export function useTabManagement() { const handleCloseTab = useCallback((path: string) => { setTabs((prev) => { const idx = prev.findIndex((t) => t.entry.path === path) - if (idx !== -1) closedTabHistory.push(path, idx) + 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 @@ -185,36 +185,16 @@ export function useTabManagement() { }, [handleSelectNote]) const handleReopenClosedTab = useCallback(async () => { - const entry = closedTabHistory.pop() - if (!entry) return + const closed = closedTabHistory.pop() + if (!closed) return // If tab is already open, just switch to it - if (isTabOpen(tabsRef.current, entry.path)) { - setActiveTabPath(entry.path) + if (isTabOpen(tabsRef.current, closed.path)) { + setActiveTabPath(closed.path) return } - const seq = ++navSeqRef.current - try { - const content = await loadNoteContent(entry.path) - setTabs((prev) => { - if (prev.some(t => t.entry.path === entry.path)) return prev - const insertIdx = Math.min(entry.index, prev.length) - const next = [...prev] - next.splice(insertIdx, 0, { entry: { path: entry.path, filename: entry.path.split('/').pop() ?? '', title: entry.path.split('/').pop()?.replace(/\.md$/, '') ?? '' } as VaultEntry, content }) - return next - }) - if (navSeqRef.current === seq) setActiveTabPath(entry.path) - } catch { - // If content loading fails, still open with empty content - setTabs((prev) => { - if (prev.some(t => t.entry.path === entry.path)) return prev - const insertIdx = Math.min(entry.index, prev.length) - const next = [...prev] - next.splice(insertIdx, 0, { entry: { path: entry.path, filename: entry.path.split('/').pop() ?? '', title: entry.path.split('/').pop()?.replace(/\.md$/, '') ?? '' } as VaultEntry, content: '' }) - return next - }) - if (navSeqRef.current === seq) setActiveTabPath(entry.path) - } - }, [closedTabHistory]) + // Reopen using the stored VaultEntry — loads fresh content from disk + await handleSelectNote(closed.entry) + }, [closedTabHistory, handleSelectNote]) const closeAllTabs = useCallback(() => { setTabs([]) diff --git a/tests/smoke/reopen-closed-tab.spec.ts b/tests/smoke/reopen-closed-tab.spec.ts new file mode 100644 index 00000000..9538bc3c --- /dev/null +++ b/tests/smoke/reopen-closed-tab.spec.ts @@ -0,0 +1,60 @@ +import { test, expect, type Page } from '@playwright/test' + +/** Dispatch Ctrl+Shift+T directly via JS. + * Chromium intercepts Ctrl+Shift+T at the browser level ("reopen browser tab"), + * so we dispatch the event programmatically to bypass that. */ +async function pressReopenClosedTab(page: Page) { + await page.evaluate(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { + key: 't', code: 'KeyT', ctrlKey: true, shiftKey: true, bubbles: true, + })) + }) +} + +const TAB = '[data-tab-path]' + +test.describe('Reopen closed tab (Cmd+Shift+T)', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.evaluate(() => localStorage.removeItem('laputa-tab-order')) + await page.reload() + await page.waitForLoadState('networkidle') + }) + + test('open note → close tab → Cmd+Shift+T → tab reopens', async ({ page }) => { + const firstNote = page.locator('.cursor-pointer.border-b').first() + await expect(firstNote).toBeVisible() + await firstNote.click() + await page.waitForTimeout(500) + + const tabs = page.locator(TAB) + await expect(tabs.first()).toBeVisible({ timeout: 3000 }) + const tabTitle = await tabs.first().textContent() + + // Close the tab via its close button + const closeBtn = tabs.first().locator('button').first() + await closeBtn.click() + await page.waitForTimeout(300) + await expect(tabs).toHaveCount(0, { timeout: 2000 }) + + // Reopen with Ctrl+Shift+T + await pressReopenClosedTab(page) + await page.waitForTimeout(500) + + // Verify tab is back with same title + await expect(tabs.first()).toBeVisible({ timeout: 3000 }) + const reopenedTitle = await tabs.first().textContent() + expect(reopenedTitle).toBe(tabTitle) + }) + + test('Cmd+Shift+T does nothing when no closed tabs', async ({ page }) => { + const tabs = page.locator(TAB) + const countBefore = await tabs.count() + + await pressReopenClosedTab(page) + await page.waitForTimeout(300) + + const countAfter = await tabs.count() + expect(countAfter).toBe(countBefore) + }) +})