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:
Test
2026-03-12 00:11:02 +01:00
parent 0d6cce1588
commit 70f984c399
7 changed files with 105 additions and 48 deletions

View File

@@ -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

View File

@@ -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+19 | Switch to tab N |
| Cmd+[ / Cmd+] | Navigate back / forward |

View File

@@ -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(

View File

@@ -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() })

View File

@@ -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)

View File

@@ -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([])

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