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>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -218,6 +218,7 @@ function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBef
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-tab-path={tab.entry.path}
|
||||
draggable={!isEditing}
|
||||
{...dragProps}
|
||||
className={cn(
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useClosedTabHistory } from './useClosedTabHistory'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
const stubEntry = (path: string): VaultEntry => ({
|
||||
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() })
|
||||
|
||||
@@ -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<ClosedTabEntry[]>([])
|
||||
|
||||
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)
|
||||
|
||||
@@ -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([])
|
||||
|
||||
60
tests/smoke/reopen-closed-tab.spec.ts
Normal file
60
tests/smoke/reopen-closed-tab.spec.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user