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)
+ })
+})