feat(ui): 自由窗口——tab 拖出侧边栏成为可移动/缩放/置顶的浮动窗口 (v0.16.0)

- 拖出:标签栏 tab 拖到主会话列(HTML5 DnD 检测 + 虚线提示浮层)或右键菜单
  「移动到自由窗口」(右/底面板共用 TabBar),tab 从 pane 移动进 SidebarState.floats
- 窗口(FreeWindow,渲染于 [data-dsh-panel-host] z-42):头部 pointer 拖动
  (rAF 直写 + 松手提交)、拖到 pane 高亮停靠(center 合并)、SE 角缩放、
  点击置顶、头部右键(回到侧边栏/关闭)、X = closeTab 走正常生命周期
- 服务语义:openTab dedupe/id 聚焦浮动 tab = 置顶窗口(不展开面板);
  closeTab/activateTab 兼顾 floats;agent 终端 reconcile 覆盖浮窗;
  features 单调新增 'floatWindows',版本 0.16.0
- 持久化:floats 随会话 localStorage,sanitizeState 宽容校验 + 几何钳制,
  uid 播种覆盖 float 前缀;i18n 三键 19 语言同步
- 关键修复(e2e 发现):portal 覆盖层(头部 Menu)的合成事件沿 React 树
  冒泡回拖拽表面,劫持菜单行点击——拖拽起点加 DOM 同源守卫(jsdom 不复现)
- e2e-mount.sh:缺省 tarball 改取 mtime 最新(旧字典序选择会把冒烟挂到
  过期产物);新增 tests/e2e/float-window.e2e.ts 双用例
This commit is contained in:
Menghuan1918
2026-08-23 23:44:18 +08:00
parent d6d8db0416
commit f17a1b30ac
39 changed files with 2100 additions and 96 deletions
+244
View File
@@ -0,0 +1,244 @@
/**
* Free-window lane (v0.16.0): against a real `dsh web` instance with the
* npm-packed plugin mounted (scripts/e2e-mount.sh, DSH_E2E_URL).
*
* 1. The tab context menu's "Move to Free Window" floats the seeded Files
* tab: `[data-dsh-float-window]` mounts and the tab leaves the strip.
* 2. The header drag moves the window (geometry committed to the session
* state).
* 3. A reload restores the window from the persisted session state.
* 4. The header context menu's "Dock Back to Sidebar" returns the tab to
* the workbench and removes the window.
* 5. The drag-out gesture itself (HTML5 DnD onto the conversation column)
* floats another tab with the hint overlay showing mid-drag.
*
* Crash discipline mirrors the mount lane: pageerror / plugin console errors
* / dsh-better-sidebar strips fail the test at every step.
*/
import { mkdirSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test, expect, request, type APIRequestContext, type Page } from '@playwright/test'
const BASE_URL = process.env.DSH_E2E_URL
if (!BASE_URL) {
throw new Error('DSH_E2E_URL is not set — boot a DSH web instance with the plugin mounted and point this lane at it (see scripts/e2e-mount.sh)')
}
const WORKSPACE_PATH = process.env.DSH_E2E_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-float-workspace')
const CRASH_STRIP_PATTERNS = [/^dsh-better-sidebar:/, /^\[dsh-better-sidebar\]/]
let api: APIRequestContext
async function seedSession(): Promise<void> {
mkdirSync(WORKSPACE_PATH, { recursive: true })
const workspace = await api.post(`${BASE_URL}/api/workspace.create`, {
data: { type: 'client-request', rpcId: 'e2e-float-workspace', method: 'workspace.create', payload: { path: WORKSPACE_PATH } },
})
expect(workspace.ok(), `workspace.create: ${workspace.status()} ${await workspace.text()}`).toBe(true)
const workspaceBody = (await workspace.json()) as {
result: { ok: true; value: { workspace: { workspaceId: string } } } | { ok: false; error: unknown }
}
expect(workspaceBody.result.ok).toBe(true)
const workspaceId = (workspaceBody.result as { value: { workspace: { workspaceId: string } } }).value.workspace.workspaceId
const session = await api.post(`${BASE_URL}/api/session.create`, {
data: { type: 'client-request', rpcId: 'e2e-float-session', method: 'session.create', payload: { workspaceId } },
})
expect(session.ok(), `session.create: ${session.status()} ${await session.text()}`).toBe(true)
const sessionBody = (await session.json()) as {
result: { ok: true; value: { sessionId: string } } | { ok: false; error: unknown }
}
expect(sessionBody.result.ok).toBe(true)
}
test.beforeAll(async () => {
api = await request.newContext({ baseURL: BASE_URL })
await seedSession()
})
test.afterAll(async () => {
await api?.dispose()
})
/** Dismiss DSH's first-run takeovers (onboarding modal etc.): stacked masks
* would intercept every subsequent pointer interaction. They mount only
* after the settings join resolves (seconds after load — also after a
* reload), so first wait bounded for one to appear, then sweep until none
* remain. Mirrors the mount lane's dance. */
async function dismissTakeovers(page: Page): Promise<void> {
try {
await expect
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
.toBeGreaterThan(0)
} catch {
return // A DSH build without onboarding takeovers: nothing to sweep.
}
for (let round = 0; round < 8; round++) {
let dismissed = false
for (const name of ['Continue', 'Configure later']) {
const button = page.getByRole('button', { name, exact: true }).first()
if ((await button.count()) === 0) continue
try {
await button.click({ timeout: 4_000 })
dismissed = true
await page.waitForTimeout(1_000)
} catch {
// Masked by a takeover stacked above; the next round retries.
}
}
if (!dismissed) break
}
}
/** Load the shell, dismiss onboarding takeovers, and expand the panel.
* Returns the sidebar host locator. (An arrow const, not a hoisted function
* declaration, so the module-level BASE_URL narrowing carries in.) */
const bootExpanded = async (page: Page): Promise<ReturnType<Page['locator']>> => {
const pageErrors: string[] = []
const consoleErrors: string[] = []
page.on('pageerror', (error) => pageErrors.push(String(error)))
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text())
})
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
const sidebar = page.locator('[data-dsh-better-sidebar]')
await expect(sidebar).toBeAttached({ timeout: 90_000 })
await dismissTakeovers(page)
await expect(sidebar.locator('[title]').first()).toBeAttached({ timeout: 90_000 })
const expandButton = sidebar.getByRole('button', { name: 'Expand sidebar' })
if ((await expandButton.count()) === 1) await expandButton.click()
await expect
.poll(async () => (
await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
), { timeout: 90_000 })
.not.toBe('')
expect(pageErrors, 'page errors during boot').toEqual([])
expect(consoleErrors.filter(text => text.includes('dsh-better-sidebar')), 'plugin console errors during boot').toEqual([])
return sidebar
}
/** The crash assertions shared by every step (mount-lane discipline). */
async function assertNoCrash(page: Page): Promise<void> {
const sidebar = page.locator('[data-dsh-better-sidebar]')
const stripTexts = await sidebar.locator('div').evaluateAll(
(nodes, patterns) => nodes.filter((node) => {
const text = (node.textContent ?? '').trim()
return patterns.some((pattern) => pattern.test(text))
}).map((node) => (node.textContent ?? '').trim()),
CRASH_STRIP_PATTERNS,
)
expect(stripTexts, 'a dsh-better-sidebar error strip is present').toEqual([])
}
/** The conversation column's viewport rect — the production drag-out target
* (Sidebar.tsx): the [data-slot="conversation"] WRAPPER may be zero-size (a
* slot pass-through), so the sized element is its parent column. */
async function conversationBox(page: Page): Promise<{ x: number; y: number; width: number; height: number } | null> {
return await page.evaluate(() => {
const col = document.querySelector('#root [data-slot="conversation"]')?.parentElement
if (col === null || col === undefined) return null
const rect = col.getBoundingClientRect()
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
})
}
test('float a tab, move the window, reload restores it, dock it back', async ({ page }) => {
const sidebar = await bootExpanded(page)
// 1. The tab context menu floats the seeded Files home tab.
const filesTab = sidebar.locator('[title="Files"][draggable="true"]').first()
await expect(filesTab).toHaveCount(1)
await filesTab.click({ button: 'right' })
const floatItem = page.getByRole('menuitem', { name: 'Move to Free Window' }).first()
await expect(floatItem, 'the tab context menu must offer "Move to Free Window"').toHaveCount(1)
await floatItem.click()
const floatWindow = sidebar.locator('[data-dsh-float-window]')
await expect(floatWindow).toHaveCount(1, { timeout: 10_000 })
// The tab left the strip with the window born over the conversation area
// (center), well clear of the panel's right edge.
const leftBefore = await floatWindow.evaluate((node) => Number.parseFloat((node as HTMLElement).style.left))
expect(Number.isFinite(leftBefore), `style.left must be numeric, got ${leftBefore}`).toBe(true)
expect(leftBefore).toBeGreaterThan(0)
expect(leftBefore).toBeLessThan(1440 - 480)
await assertNoCrash(page)
// 2. The header drag moves the window (pointer capture + commit).
const header = floatWindow.locator('[class*="floatHeader"]')
const box = await header.boundingBox()
const conv = await conversationBox(page)
expect(box, 'the float header must have geometry').not.toBeNull()
expect(conv, 'the conversation column must have geometry').not.toBeNull()
const dragToX = conv!.x + conv!.width * 0.35
const dragToY = conv!.y + conv!.height * 0.35
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2)
await page.mouse.down()
await page.mouse.move(dragToX, dragToY, { steps: 12 })
await page.mouse.up()
await expect
.poll(async () => (
await floatWindow.evaluate((node) => Number.parseFloat((node as HTMLElement).style.left))
), { timeout: 10_000 })
.not.toBe(leftBefore)
await assertNoCrash(page)
// 3. A reload restores the window from the persisted session state. Give
// the store's debounced persist a beat first: reloading the instant the
// drag poll resolves can beat the 200ms flush timer.
await page.waitForTimeout(500)
await page.reload({ waitUntil: 'domcontentloaded' })
await dismissTakeovers(page)
const sidebar2 = page.locator('[data-dsh-better-sidebar]')
await expect(sidebar2.locator('[data-dsh-float-window]')).toHaveCount(1, { timeout: 90_000 })
await assertNoCrash(page)
// 4. The header context menu docks the tab back into the sidebar.
const header2 = sidebar2.locator('[data-dsh-float-window] [class*="floatHeader"]')
await header2.click({ button: 'right' })
const dockItem = page.getByRole('menuitem', { name: 'Dock Back to Sidebar' }).first()
await expect(dockItem, 'the float header context menu must offer "Dock Back to Sidebar"').toHaveCount(1)
await dockItem.click()
await expect(sidebar2.locator('[data-dsh-float-window]')).toHaveCount(0, { timeout: 10_000 })
await expect(sidebar2.locator('[title="Files"][draggable="true"]').first()).toHaveCount(1)
await assertNoCrash(page)
})
test('dragging a tab onto the conversation area floats it (drag-out gesture)', async ({ page }) => {
const sidebar = await bootExpanded(page)
// Open a terminal through the + menu (a second tab to drag out; the Files
// home tab keeps the pane non-empty afterwards). The terminal tab's title
// is the host shell name once resolved (fallback "Terminal"), so address
// it by POSITION: the strip's second tab.
await sidebar.getByRole('button', { name: 'New tab' }).first().click()
const terminalItem = page.getByRole('menuitem', { name: 'Terminal' }).first()
await expect(terminalItem).toHaveCount(1)
await terminalItem.click()
const stripTabs = sidebar.locator('[class*="tabList"] > [draggable="true"]')
await expect(stripTabs, 'the strip must hold the seeded Files tab plus the terminal').toHaveCount(2, { timeout: 30_000 })
const terminalTab = stripTabs.nth(1)
const terminalTitle = (await terminalTab.getAttribute('title')) ?? 'Terminal'
// Drag it onto the conversation column: the hint overlay appears mid-drag
// and the drop floats the tab. ([class*="floatDropHint"] matches BOTH the
// overlay and its label span — expect the pair.)
const tabBox = await terminalTab.boundingBox()
const conv = await conversationBox(page)
expect(tabBox, 'the terminal tab must have geometry').not.toBeNull()
expect(conv, 'the conversation column must have geometry').not.toBeNull()
const hint = sidebar.locator('[class*="floatDropHint"]')
await page.mouse.move(tabBox!.x + tabBox!.width / 2, tabBox!.y + tabBox!.height / 2)
await page.mouse.down()
await page.mouse.move(conv!.x + conv!.width / 2, conv!.y + conv!.height / 2, { steps: 12 })
await expect(hint, 'the drag-out hint must mark the conversation column mid-drag').toHaveCount(2, { timeout: 10_000 })
await page.mouse.up()
const floatWindow = sidebar.locator('[data-dsh-float-window]')
await expect(floatWindow, 'the drop must float the tab').toHaveCount(1, { timeout: 10_000 })
await expect(hint).toHaveCount(0)
// The terminal tab left the strip (only Files remains); the window shows
// its title.
await expect(stripTabs).toHaveCount(1)
await expect(floatWindow.locator('[class*="floatTitle"]')).toHaveText(terminalTitle)
await assertNoCrash(page)
})
+370
View File
@@ -0,0 +1,370 @@
/**
* Free-window interaction tests, against the REAL Sidebar shell + real
* store/service (the sidebar-crash spec's mount pattern).
*
* 1. Drag-out detection: while a tab drag (the body flag the tab strips
* maintain) hovers the conversation column, a hint overlay marks the drop
* zone; the drop floats the tab at the release point and the pane loses
* it. Targets inside the panel host stay the panes' own business.
* 2. The tab context menu's "move to free window" floats the tab at the
* conversation column's center (no drop point exists).
* 3. The window itself: header drag moves (rAF direct writes, store commit
* on release), the SE corner resizes, releasing the header over a pane
* docks the tab back (the pane highlights live), and the X closes the tab
* with its window.
*/
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
// The act() environment flag (React 18.2 reads it before flushing effects).
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
import { Sidebar } from '../src/client/Sidebar.tsx'
import { createSidebarStore, type SidebarStore, floatTab as floatTabReducer } from '../src/client/state.ts'
import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts'
import { serializeDrag } from '../src/client/TabBar.tsx'
/** jsdom has no WebSocket; the agent-terminals push effect constructs one on mount. */
class FakeWebSocket {
onmessage: ((event: { data: unknown }) => void) | null = null
onclose: (() => void) | null = null
onerror: (() => void) | null = null
close = (): void => {}
constructor(_url: string) {}
}
/** The app shell's center column: #root > col > [data-slot="conversation"],
* with a real-looking rect so the drag-out zone can be hit-tested. */
const CONV_RECT = { left: 100, right: 700, top: 50, bottom: 600, width: 600, height: 550 }
function fakeConversationColumn(): HTMLElement {
const rootEl = document.createElement('div')
rootEl.id = 'root'
const col = document.createElement('div')
const conv = document.createElement('div')
conv.setAttribute('data-slot', 'conversation')
col.append(conv)
rootEl.append(col)
document.body.append(rootEl)
col.getBoundingClientRect = () => CONV_RECT as DOMRect
return col
}
interface Mounted {
container: HTMLDivElement
store: SidebarStore
service: BetterSidebarService
unmount: () => void
}
function mountSidebar(): Mounted {
vi.stubGlobal('WebSocket', FakeWebSocket)
if (!('ResizeObserver' in globalThis)) {
vi.stubGlobal('ResizeObserver', class {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
})
}
const container = document.createElement('div')
document.body.append(container)
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'notes', title: 'Notes', single: true, component: () => createElement('div', null, 'notes body') })
store.setPrefs({ ...store.getPrefs(), openByDefault: true })
store.setSession('s1')
const localeSnapshot = { active: 'en' }
const sessionsSnapshot = { current: 's1', byId: { s1: { cwd: '/tmp' } } }
const ctx = {
locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot },
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } },
betterSidebar: service,
}
const root: Root = createRoot(container)
act(() => { root.render(createElement(Sidebar, { ctx: ctx as never, store })) })
return {
container,
store,
service,
unmount: () => {
act(() => { root.unmount() })
container.remove()
},
}
}
/** A document-level drag event with a payload-carrying dataTransfer (jsdom
* has no DragEvent/DataTransfer). Dispatched AT document, where the shell's
* capture listeners live. */
function dispatchDrag(type: 'dragover' | 'drop', x: number, y: number, payload?: string): void {
const event = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperty(event, 'clientX', { value: x })
Object.defineProperty(event, 'clientY', { value: y })
Object.defineProperty(event, 'dataTransfer', { value: { getData: () => payload ?? '' } })
document.dispatchEvent(event)
}
const flushFrame = async (): Promise<void> => {
await act(async () => { await new Promise<void>(resolve => requestAnimationFrame(() => resolve())) })
}
const paneTabs = (store: SidebarStore): string[] => {
const state = store.getSnapshot().state!
const leaves = [state.splits, state.bottomSplits]
return leaves.flatMap(tree => (tree.kind === 'leaf' ? [tree] : allLeafList(tree)))
.flatMap(leaf => leaf.tabs.map(tab => tab.id))
}
const allLeafList = (node: import('../src/client/state.ts').SplitNode): Array<{ tabs: Array<{ id: string }> }> =>
node.kind === 'leaf' ? [node] : node.children.flatMap(allLeafList)
afterEach(() => {
document.body.innerHTML = ''
document.documentElement.style.cssText = ''
vi.unstubAllGlobals()
})
describe('free windows: drag-out detection', () => {
it('hovering the conversation column shows the hint; the drop floats the tab there', () => {
fakeConversationColumn()
const { container, store, service, unmount } = mountSidebar()
try {
act(() => { service.openTab({ type: 'notes', title: 'Notes' }) })
const before = paneTabs(store)
expect(before).toContain('notes')
// A tab drag is active (the tab strips maintain this body flag).
document.body.setAttribute('data-dsh-tab-dragging', '')
// Outside the column: no hint.
act(() => { dispatchDrag('dragover', 50, 20) })
expect(container.querySelector('[class*="floatDropHint"]')).toBeNull()
// Over the column: the hint overlay appears at the column's rect.
act(() => { dispatchDrag('dragover', 400, 300) })
const hint = container.querySelector<HTMLElement>('[class*="floatDropHint"]')!
expect(hint).not.toBeNull()
expect(hint.style.left).toBe('100px')
expect(hint.style.width).toBe('600px')
// The drop floats the tab at the release point.
act(() => {
dispatchDrag('drop', 400, 300, serializeDrag({ tabId: 'notes', paneId: 'pane:1' }))
})
const state = store.getSnapshot().state!
expect(state.floats).toHaveLength(1)
expect(state.floats[0]!.tab.id).toBe('notes')
expect(paneTabs(store)).not.toContain('notes')
// The hint is gone and the window is rendered.
expect(container.querySelector('[class*="floatDropHint"]')).toBeNull()
expect(container.querySelector('[data-dsh-float-window]')).not.toBeNull()
// dragend clears any lingering state.
document.body.setAttribute('data-dsh-tab-dragging', '')
act(() => { dispatchDrag('dragover', 400, 300) })
expect(container.querySelector('[class*="floatDropHint"]')).not.toBeNull()
act(() => { window.dispatchEvent(new Event('dragend')) })
expect(container.querySelector('[class*="floatDropHint"]')).toBeNull()
document.body.removeAttribute('data-dsh-tab-dragging')
} finally {
unmount()
}
})
it('drops without the hint armed are left to the panes (no float)', () => {
fakeConversationColumn()
const { store, service, unmount } = mountSidebar()
try {
act(() => { service.openTab({ type: 'notes', title: 'Notes' }) })
document.body.setAttribute('data-dsh-tab-dragging', '')
// A drop with NO preceding dragover over the column (e.g. directly on
// a pane) must not float anything.
act(() => {
dispatchDrag('drop', 400, 300, serializeDrag({ tabId: 'notes', paneId: 'pane:1' }))
})
expect(store.getSnapshot().state!.floats).toHaveLength(0)
expect(paneTabs(store)).toContain('notes')
document.body.removeAttribute('data-dsh-tab-dragging')
} finally {
unmount()
}
})
})
describe('free windows: the window', () => {
it('the tab context menu floats the tab at the conversation center', () => {
fakeConversationColumn()
const { container, store, service, unmount } = mountSidebar()
try {
act(() => { service.openTab({ type: 'notes', title: 'Notes' }) })
// The strip holds the seeded home tab first; Notes is the LAST tab.
const tabs = [...container.querySelectorAll<HTMLElement>('[class*="tabList"] > [class*="tab"]')]
const tab = tabs.at(-1)!
act(() => { tab.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 })) })
// The float entry is the FIRST menu row.
const items = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
expect(items.length).toBeGreaterThan(0)
act(() => { items[0]!.click() })
const state = store.getSnapshot().state!
expect(state.floats).toHaveLength(1)
expect(state.floats[0]!.tab.id).toBe('notes')
// Born at the conversation column's center, minus half the default
// size (480x360), clamped into the 1024x768 viewport.
expect(state.floats[0]).toMatchObject({ x: 400 - 240, y: 325 - 180, w: 480, h: 360 })
expect(container.querySelector('[data-dsh-float-window]')).not.toBeNull()
} finally {
unmount()
}
})
it('header drag moves the window (rAF direct writes, store commit on release)', async () => {
fakeConversationColumn()
const { container, store, service, unmount } = mountSidebar()
try {
act(() => { service.openTab({ type: 'notes', title: 'Notes' }) })
act(() => { store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) })
const win = container.querySelector<HTMLElement>('[data-dsh-float-window]')!
const header = win.querySelector<HTMLElement>('[class*="floatHeader"]')!
expect(win.style.left).toBe('272px')
// Grab at (300, 220) — the drag records the window's own origin, so
// moving the pointer to (200, 250) lands the window at (172, 234).
act(() => {
header.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 300, clientY: 220, button: 0 }))
header.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 200, clientY: 250 }))
})
await flushFrame()
expect(win.style.left).toBe('172px')
expect(win.style.top).toBe('234px')
// The store is untouched mid-drag (DOM is the only record).
expect(store.getSnapshot().state!.floats[0]!.x).toBe(272)
act(() => { header.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 200, clientY: 250, button: 0 })) })
expect(store.getSnapshot().state!.floats[0]).toMatchObject({ x: 172, y: 234 })
} finally {
unmount()
}
})
it('the SE corner resizes the window and persists on release', async () => {
fakeConversationColumn()
const { container, store, service, unmount } = mountSidebar()
try {
act(() => { service.openTab({ type: 'notes', title: 'Notes' }) })
act(() => { store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) })
const win = container.querySelector<HTMLElement>('[data-dsh-float-window]')!
const handle = win.querySelector<HTMLElement>('[class*="floatResize"]')!
act(() => {
handle.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 752, clientY: 564, button: 0 }))
handle.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 852, clientY: 614 }))
})
await flushFrame()
expect(win.style.width).toBe('580px')
expect(win.style.height).toBe('410px')
act(() => { handle.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 852, clientY: 614, button: 0 })) })
expect(store.getSnapshot().state!.floats[0]).toMatchObject({ w: 580, h: 410 })
} finally {
unmount()
}
})
it('a pointerdown on the portaled header menu must not start a header drag', async () => {
fakeConversationColumn()
const { container, store, service, unmount } = mountSidebar()
try {
act(() => { service.openTab({ type: 'notes', title: 'Notes' }) })
act(() => { store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) })
const win = container.querySelector<HTMLElement>('[data-dsh-float-window]')!
const header = win.querySelector<HTMLElement>('[class*="floatHeader"]')!
expect(win.style.left).toBe('272px')
act(() => { header.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 300, clientY: 220 })) })
// The menu list is PORTALED to document.body, yet its events still
// bubble through the React tree into the header's onPointerDown —
// without the containment guard (real-browser regression, e2e-caught:
// the hijacked drag preventDefaults the row's pointerdown and captures
// the pointer, so the row's click never fires) this starts a move.
const dockRow = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
.find(row => /回到侧边栏|Dock Back to Sidebar/.test(row.textContent ?? ''))!
act(() => {
dockRow.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 300, clientY: 220, button: 0 }))
header.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 200, clientY: 250 }))
})
await flushFrame()
expect(win.style.left).toBe('272px')
act(() => { dockRow.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
expect(store.getSnapshot().state!.floats).toHaveLength(0)
expect(paneTabs(store)).toContain('notes')
} finally {
unmount()
}
})
it('releasing the header over a pane docks the tab back (live pane highlight)', async () => {
fakeConversationColumn()
const { container, store, service, unmount } = mountSidebar()
try {
act(() => { service.openTab({ type: 'notes', title: 'Notes' }) })
act(() => { store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) })
const win = container.querySelector<HTMLElement>('[data-dsh-float-window]')!
const header = win.querySelector<HTMLElement>('[class*="floatHeader"]')!
// The workbench pane covers (600, 400) for the hit-test.
const pane = container.querySelector<HTMLElement>('[data-dsh-pane]')!
pane.getBoundingClientRect = () => ({ left: 550, right: 700, top: 350, bottom: 500, width: 150, height: 150 }) as DOMRect
act(() => {
header.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 300, clientY: 220, button: 0 }))
header.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 600, clientY: 400 }))
})
await flushFrame()
// The dock target highlights while hovered.
expect(pane.hasAttribute('data-dsh-float-dock-over')).toBe(true)
act(() => { header.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 600, clientY: 400, button: 0 })) })
const state = store.getSnapshot().state!
expect(state.floats).toHaveLength(0)
expect(paneTabs(store)).toContain('notes')
expect(pane.hasAttribute('data-dsh-float-dock-over')).toBe(false)
expect(container.querySelector('[data-dsh-float-window]')).toBeNull()
} finally {
unmount()
}
})
it('the X button closes the tab with its window', () => {
fakeConversationColumn()
const { container, store, service, unmount } = mountSidebar()
try {
act(() => { service.openTab({ type: 'notes', title: 'Notes' }) })
act(() => { store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) })
const win = container.querySelector<HTMLElement>('[data-dsh-float-window]')!
const close = win.querySelector<HTMLElement>('[class*="floatClose"]')!
act(() => { close.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
const state = store.getSnapshot().state!
expect(state.floats).toHaveLength(0)
expect(paneTabs(store)).not.toContain('notes')
expect(container.querySelector('[data-dsh-float-window]')).toBeNull()
} finally {
unmount()
}
})
it('a reload round-trip restores the window (localStorage persistence)', () => {
fakeConversationColumn()
const saved = new Map<string, string>()
const getItem = vi.fn((key: string): string | null => saved.get(key) ?? null)
const setItem = vi.fn((key: string, value: string): void => { saved.set(key, value) })
vi.stubGlobal('localStorage', { getItem, setItem, removeItem: () => {} })
const first = mountSidebar()
try {
act(() => { first.service.openTab({ type: 'notes', title: 'Notes' }) })
act(() => { first.store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) })
// The debounced persist fires on a later tick — run it synchronously.
const state = first.store.getSnapshot().state!
saved.set('dsh-sidebar:v1:s1', JSON.stringify(state))
} finally {
first.unmount()
}
const second = mountSidebar()
try {
const state = second.store.getSnapshot().state!
expect(state.floats).toHaveLength(1)
expect(state.floats[0]!.tab.id).toBe('notes')
expect(second.container.querySelector('[data-dsh-float-window]')).not.toBeNull()
} finally {
second.unmount()
}
})
})
+68 -2
View File
@@ -2,7 +2,7 @@
* Tests for the BetterSidebar service registry: register/dispose lifecycle,
* matchFileViewer priority/exts/detect algorithm, and openTab dedupe.
*/
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
// Mock browser globals (SidebarStore.reduce → schedulePersist uses window.setTimeout)
const g = globalThis as Record<string, unknown>
@@ -21,7 +21,7 @@ if (g.localStorage === undefined) {
}
import { createBetterSidebarService, matchUrlTarget, SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from '../src/client/service.ts'
import { createSidebarStore, allLeaves, makeDefaultState, openDiffTab, openTabInActivePane, sanitizeState } from '../src/client/state.ts'
import { createSidebarStore, allLeaves, floatTab, makeDefaultState, openDiffTab, openTabInActivePane, sanitizeState } from '../src/client/state.ts'
describe('BetterSidebar service', () => {
it('registerTab adds to the registry and dispose removes it', () => {
@@ -1055,4 +1055,70 @@ describe('independent CR follow-up fixes', () => {
service.registerFileViewer({ id: 'csv', exts: ['csv'], fetchStrategy: 'custom', component: () => null })
expect(() => service.registerFileViewer({ id: 'csv', exts: ['csv'], fetchStrategy: 'custom', component: () => null })).toThrow(/already registered/)
})
describe('free windows (v0.16.0)', () => {
it('openTab dedupe focuses a FLOATING tab by raising its window (no duplicate, no panel expansion)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'singleton', title: 'S', dedupeKey: () => 'singleton', component: () => null })
store.setSession('s1')
service.openTab({ type: 'singleton', title: 'S' })
// Float the singleton out, then float a second tab above it and
// collapse the panel — the focus must raise the window in place
// without reopening a tab or expanding anything.
store.reduce(s => floatTab(s, 'singleton', 100, 100))
service.openTab({ type: 'singleton', title: 'S' })
store.reduce(s => floatTab(s, (s.splits as { tabs: Array<{ id: string }> }).tabs[0]!.id, 100, 100))
const before = store.getSnapshot().state!
expect(before.floats).toHaveLength(2)
store.reduce(s => ({ ...s, panelOpen: false }))
service.openTab({ type: 'singleton', title: 'S' })
const after = store.getSnapshot().state!
// Raised to the top, not duplicated.
expect(after.floats).toHaveLength(2)
expect(after.floats.at(-1)!.tab.type).toBe('singleton')
// The panel stays collapsed (a floating tab is already in sight).
expect(after.panelOpen).toBe(false)
expect(allLeaves(after.splits).some(l => l.tabs.some(t => t.type === 'singleton'))).toBe(false)
})
it('closeTab on a floating tab closes it WITH the window and fires onClose', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const onClose = vi.fn()
service.registerTab({ id: 'notes', title: 'Notes', single: true, onClose, component: () => null })
store.setSession('s1')
service.openTab({ type: 'notes', title: 'Notes' })
store.reduce(s => floatTab(s, 'notes', 50, 50))
expect(store.getSnapshot().state!.floats).toHaveLength(1)
service.closeTab('notes', { sessionId: 's1' })
const after = store.getSnapshot().state!
expect(after.floats).toHaveLength(0)
expect(onClose).toHaveBeenCalledTimes(1)
// Unknown ids stay a strict no-op.
service.closeTab('notes')
expect(store.getSnapshot().state).toBe(after)
})
it('activateTab on a floating tab raises the window and fires onActivate', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const onActivate = vi.fn()
service.registerTab({ id: 'notes', title: 'Notes', single: true, onActivate, component: () => null })
store.setSession('s1')
service.openTab({ type: 'notes', title: 'Notes' })
service.openTab({ type: 'notes', title: 'Notes' })
store.reduce(s => floatTab(s, 'notes', 50, 50))
store.reduce(s => floatTab(s, (s.splits as { tabs: Array<{ id: string }> }).tabs[0]!.id, 60, 60))
const before = store.getSnapshot().state!
expect(before.floats).toHaveLength(2)
expect(before.floats.at(-1)!.tab.type).not.toBe('notes')
service.activateTab('notes')
const after = store.getSnapshot().state!
expect(after.floats.at(-1)!.tab.type).toBe('notes')
// Two activations total: the second openTab's dedupe focus (before the
// float) plus THIS explicit activateTab — both legitimate focuses.
expect(onActivate).toHaveBeenCalledTimes(2)
})
})
})
+289 -4
View File
@@ -1,10 +1,12 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
activateTab, allLeaves, BOTTOM_DEFAULT, BOTTOM_MIN, closeTab, createSidebarStore,
insertLeafAt, makeDefaultState, migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab,
openTabInActivePane, patchTab, resizeSplit, resizeSplitIn, sanitizeState, setBottomHeight,
activateTab, allLeaves, BOTTOM_DEFAULT, BOTTOM_MIN, closeFloatByTab, closeTab, createSidebarStore,
dockFloat, FLOAT_MIN_H, FLOAT_MIN_W, floatTab, floatWithTab, insertLeafAt, makeDefaultState,
migrateBottomTabs, moveFloat, moveTab, moveTabToEdge, openDiffTab,
openTabInActivePane, patchTab, raiseFloat, reconcileAgentTerminals, resizeFloat, resizeSplit,
resizeSplitIn, sanitizeState, setBottomHeight,
splitPane, tabOpenIn, toggleBottomPanel, toggleExpanded, togglePanel,
type SidebarState, type SidebarTab, type SplitNode,
type SidebarLeaf, type SidebarState, type SidebarTab, type SplitNode,
} from '../src/client/state.ts'
describe('sidebar state', () => {
@@ -896,3 +898,286 @@ describe('v0.12.0 store additions', () => {
})
})
})
describe('free windows (v0.16.0)', () => {
// Node environment: no window → the viewport clamps are Infinity and only
// the size floors apply; the geometry tests stub a window where clamping
// is the point under test.
const state = (): SidebarState => makeDefaultState()
it('floatTab moves a docked tab into floats (pane keeps its other tabs)', () => {
let s = state()
const git = { id: 'git', type: 'git' as const, title: 'Git' }
const term = { id: 'term:1', type: 'terminal' as const, title: 'Term' }
s = openTabInActivePane(s, git)
s = openTabInActivePane(s, term)
const floated = floatTab(s, git.id, 300, 200)
const leaf = floated.splits as SidebarLeaf
expect(leaf.tabs).toHaveLength(2)
expect(leaf.tabs.some(tab => tab.id === 'git')).toBe(false)
// The pane's active pointer fell to the last remaining tab.
expect(leaf.active).toBe('term:1')
expect(floated.floats).toHaveLength(1)
expect(floated.floats[0]!.tab).toEqual(git)
// Default size centered on the drop point (no viewport → floors only).
expect(floated.floats[0]).toMatchObject({ x: 300 - 240, y: 200 - 180, w: 480, h: 360 })
expect(floated.activePane).toBe(leaf.id)
})
it('floatTab collapses a pane it empties and repoints the active pane', () => {
let s = state()
const paneId = (s.splits as SidebarLeaf).id
s = splitPane(s, 'row')
const fresh = allLeaves(s.splits).find(leaf => leaf.id !== paneId)!
s = { ...s, activePane: fresh.id }
s = openTabInActivePane(s, { id: 'term:1', type: 'terminal', title: 'T' })
expect(allLeaves(s.splits).find(leaf => leaf.id === fresh.id)!.tabs).toHaveLength(1)
const floated = floatTab(s, 'term:1', 100, 100)
expect(floated.floats).toHaveLength(1)
expect(allLeaves(floated.splits).some(leaf => leaf.id === fresh.id)).toBe(false)
expect(floated.activePane).toBe(paneId)
})
it('floatTab with an unknown or already-floating tab id is a strict no-op', () => {
let s = state()
const git = { id: 'git', type: 'git' as const, title: 'Git' }
s = openTabInActivePane(s, git)
expect(floatTab(s, 'nope', 0, 0)).toBe(s)
const floated = floatTab(s, 'git', 50, 50)
expect(floatTab(floated, 'git', 60, 60)).toBe(floated)
})
it('moveFloat / resizeFloat clamp to the viewport (floors always)', () => {
const g = globalThis as Record<string, unknown>
const previous = g.window
g.window = { innerWidth: 1024, innerHeight: 768 }
try {
let s = state()
const git = { id: 'git', type: 'git' as const, title: 'Git' }
s = floatTab(openTabInActivePane(s, git), 'git', 512, 384)
const id = s.floats[0]!.id
// Negative position clamps to 0; the window stays fully inside.
let moved = moveFloat(s, id, -50, -50)
expect(moved.floats[0]).toMatchObject({ x: 0, y: 0 })
// Beyond the right/bottom edge clamps the top-left so the window fits.
moved = moveFloat(moved, id, 5000, 5000)
expect(moved.floats[0]!.x).toBe(1024 - 480)
expect(moved.floats[0]!.y).toBe(768 - 360)
// No-op move returns the same reference (no persist churn).
expect(moveFloat(moved, id, moved.floats[0]!.x, moved.floats[0]!.y)).toBe(moved)
// Resize: floors, viewport ceiling, and the SE-corner anchor (x/y keep).
let resized = resizeFloat(moved, id, 10, 10)
expect(resized.floats[0]).toMatchObject({ w: FLOAT_MIN_W, h: FLOAT_MIN_H })
resized = resizeFloat(resized, id, 5000, 5000)
expect(resized.floats[0]!.w).toBe(1024 - resized.floats[0]!.x)
expect(resized.floats[0]!.h).toBe(768 - resized.floats[0]!.y)
// No-op resize and unknown ids return the same reference.
expect(resizeFloat(resized, id, resized.floats[0]!.w, resized.floats[0]!.h)).toBe(resized)
expect(resizeFloat(resized, 'nope', 400, 400)).toBe(resized)
} finally {
if (previous === undefined) delete g.window
else g.window = previous
}
})
it('raiseFloat moves a window to the top and is idempotent at the top', () => {
let s = state()
s = floatTab(openTabInActivePane(s, { id: 'a', type: 'db', title: 'a' }), 'a', 100, 100)
s = floatTab(openTabInActivePane(s, { id: 'b', type: 'db', title: 'b' }), 'b', 100, 100)
s = floatTab(openTabInActivePane(s, { id: 'c', type: 'db', title: 'c' }), 'c', 100, 100)
expect(s.floats.map(f => f.tab.id)).toEqual(['a', 'b', 'c'])
const raised = raiseFloat(s, s.floats[0]!.id)
expect(raised.floats.map(f => f.tab.id)).toEqual(['b', 'c', 'a'])
// Already topmost / unknown id → same reference (no notify churn).
expect(raiseFloat(raised, raised.floats[2]!.id)).toBe(raised)
expect(raiseFloat(raised, 'float:none')).toBe(raised)
// A single window has nothing to raise: same reference.
let single = state()
single = floatTab(single, (single.splits as SidebarLeaf).tabs[0]!.id, 0, 0)
expect(single.floats).toHaveLength(1)
expect(raiseFloat(single, single.floats[0]!.id)).toBe(single)
})
it('dockFloat lands the tab in the target pane (or the active pane) and activates it', () => {
let s = state()
const git = { id: 'git', type: 'git' as const, title: 'Git' }
s = floatTab(openTabInActivePane(s, git), 'git', 100, 100)
const floatId = s.floats[0]!.id
// Explicit target pane.
s = splitPane(s, 'row')
const target = allLeaves(s.splits).find(leaf => leaf.tabs.length === 0)!
const docked = dockFloat(s, floatId, target.id)
expect(docked.floats).toHaveLength(0)
expect(allLeaves(docked.splits).find(leaf => leaf.id === target.id)!.tabs.map(tab => tab.id)).toEqual(['git'])
expect(docked.activePane).toBe(target.id)
// Default target: the active pane.
let s2 = state()
const home = (s2.splits as SidebarLeaf).tabs[0]!
s2 = splitPane(s2, 'row')
s2 = floatTab(s2, home.id, 100, 100)
const active = s2.activePane!
const docked2 = dockFloat(s2, s2.floats[0]!.id)
expect(docked2.floats).toHaveLength(0)
expect(allLeaves(docked2.splits).find(leaf => leaf.id === active)!.tabs.some(tab => tab.id === home.id)).toBe(true)
expect(docked2.activePane).toBe(active)
// Stale target falls back to the right tree's first leaf.
let s3 = state()
const home3 = (s3.splits as SidebarLeaf).tabs[0]!
s3 = floatTab(s3, home3.id, 0, 0)
const docked3 = dockFloat(s3, s3.floats[0]!.id, 'pane:gone')
expect(docked3.floats).toHaveLength(0)
expect(allLeaves(docked3.splits).some(leaf => leaf.tabs.some(tab => tab.id === home3.id))).toBe(true)
// Unknown float id is a strict no-op.
expect(dockFloat(docked3, 'float:gone')).toBe(docked3)
})
it('tabOpenIn / patchTab / floatWithTab see floating tabs', () => {
let s = state()
const git = { id: 'git', type: 'git' as const, title: 'Git' }
s = floatTab(openTabInActivePane(s, git), 'git', 100, 100)
expect(tabOpenIn(s, 'git')).toBe(true)
expect(floatWithTab(s, 'git')!.id).toBe(s.floats[0]!.id)
const patched = patchTab(s, 'git', { title: 'Git 2', path: '/x' })
expect(patched.floats[0]!.tab).toMatchObject({ title: 'Git 2', path: '/x' })
expect(allLeaves(patched.splits).some(leaf => leaf.tabs.some(tab => tab.id === 'git'))).toBe(false)
})
it('closeFloatByTab removes exactly the window holding the tab', () => {
let s = state()
const git = { id: 'git', type: 'git' as const, title: 'Git' }
const term = { id: 'term:1', type: 'terminal' as const, title: 'T' }
s = openTabInActivePane(s, git)
s = openTabInActivePane(s, term)
s = floatTab(s, 'git', 50, 50)
s = floatTab(s, 'term:1', 60, 60)
expect(s.floats).toHaveLength(2)
const after = closeFloatByTab(s, 'git')
expect(after.floats.map(f => f.tab.id)).toEqual(['term:1'])
expect(closeFloatByTab(after, 'git')).toBe(after)
})
it('openTabInActivePane focuses a floating tab by raising (id safety net)', () => {
let s = state()
const git = { id: 'git', type: 'git' as const, title: 'Git' }
const term = { id: 'term:1', type: 'terminal' as const, title: 'T' }
s = floatTab(openTabInActivePane(s, git), 'git', 50, 50)
s = floatTab(openTabInActivePane(s, term), 'term:1', 60, 60)
// Reopening the floated id must NOT duplicate: it raises the window.
const after = openTabInActivePane(s, { id: 'git', type: 'git', title: 'Git' })
expect(after.floats.map(f => f.tab.id)).toEqual(['term:1', 'git'])
expect(allLeaves(after.splits).some(leaf => leaf.tabs.some(tab => tab.id === 'git'))).toBe(false)
})
it('reconcileAgentTerminals removes a vanished FLOATED agent terminal with its window', () => {
let s = state()
s = openTabInActivePane(s, { id: 'agent:u1', type: 'terminal', title: 'A1' })
s = floatTab(s, 'agent:u1', 50, 50)
s = openTabInActivePane(s, { id: 'agent:u2', type: 'terminal', title: 'A2' })
expect(s.floats).toHaveLength(1)
const after = reconcileAgentTerminals(s, [{ uuid: 'u2', title: 'A2' }])
expect(after.floats).toHaveLength(0)
expect(allLeaves(after.splits).flatMap(leaf => leaf.tabs).some(tab => tab.id === 'agent:u2')).toBe(true)
})
describe('sanitizeState (floats)', () => {
const g = globalThis as Record<string, unknown>
beforeEach(() => {
g.window = { clearTimeout: () => {}, setTimeout: () => 0, innerWidth: 1024, innerHeight: 768 }
g.localStorage = { getItem: () => null, setItem: () => {} }
})
afterEach(() => {
delete g.window
delete g.localStorage
})
const base = (): Record<string, unknown> => ({
panelOpen: true,
width: 400,
nextTerminal: 1,
activePane: 'pane:1',
expanded: [],
splits: { kind: 'leaf', id: 'pane:1', active: null, tabs: [] },
})
it('a missing floats field defaults to none (older persisted states load)', () => {
const restored = sanitizeState(base())!
expect(restored.floats).toEqual([])
})
it('drops malformed entries individually and keeps the layout (unlike tree corruption)', () => {
const parsed = base()
parsed.floats = [
'garbage',
{ id: 'float:1' }, // no tab
{ id: 'float:2', tab: { id: 't', type: 'git', title: 'G' }, x: 10, y: 10 }, // no w/h
{ id: 'float:3', tab: { id: 't3', type: 'git', title: 'G3' }, x: 10, y: 10, w: 500, h: 400 },
{ id: 'float:3', tab: { id: 't3b', type: 'git', title: 'G3b' }, x: 0, y: 0, w: 400, h: 300 }, // duplicate id
]
const restored = sanitizeState(parsed)!
expect(restored.floats).toHaveLength(1)
expect(restored.floats[0]).toMatchObject({ id: 'float:3', tab: { id: 't3' }, x: 10, y: 10 })
expect(restored.splits).toBeDefined()
})
it('drops ephemeral diff tabs and migrates explorer tabs inside floats', () => {
const parsed = base()
parsed.floats = [
{ id: 'float:1', tab: { id: 'd', type: 'diff', title: 'D' }, x: 0, y: 0, w: 400, h: 300 },
{ id: 'float:2', tab: { id: 'ex', type: 'explorer', title: 'Explorer' }, x: 0, y: 0, w: 400, h: 300 },
]
const restored = sanitizeState(parsed)!
expect(restored.floats.map(f => f.tab.type)).toEqual(['editor'])
expect(restored.floats[0]!.tab).toMatchObject({ title: 'Files', meta: { treeOpen: true } })
})
it('clamps stale off-screen geometry into the current viewport', () => {
const parsed = base()
parsed.floats = [
{ id: 'float:1', tab: { id: 't', type: 'git', title: 'G' }, x: -200, y: 900, w: 5000, h: 5000 },
]
const restored = sanitizeState(parsed)!
// Sizes cap at the viewport, and the capped window lands at 0,0 (the
// position clamp sees no room beyond it).
expect(restored.floats[0]).toMatchObject({ x: 0, y: 0, w: 1024, h: 768 })
})
it('the uid counter seeds past persisted float ids (no collision on re-float)', () => {
// Persisted: the pane max is 1, but floats reach float:2 — the counter
// must seed to 2 so a fresh float mints float:3+, never a duplicate
// float:2. (Intermediate mints during sanitize may raise it further;
// the guarantee under test is uniqueness, not an exact value.)
const parsed = base()
parsed.floats = [
{ id: 'float:2', tab: { id: 't2', type: 'git', title: 'G2' }, x: 0, y: 0, w: 400, h: 300 },
]
const saved = new Map<string, string>()
saved.set('dsh-sidebar:v1:seedtest', JSON.stringify(parsed))
g.localStorage = {
getItem: (key: string) => saved.get(key) ?? null,
setItem: (key: string, value: string) => { saved.set(key, value) },
}
const store = createSidebarStore()
store.setSession('seedtest')
const s = store.getSnapshot().state!
expect(s.floats[0]!.id).toBe('float:2')
store.reduce(cur => openTabInActivePane(cur, { id: 'tab:9', type: 'git', title: 'G9' }))
store.reduce(cur => floatTab(cur, 'tab:9', 30, 30))
const next = store.getSnapshot().state!
expect(next.floats).toHaveLength(2)
expect(new Set(next.floats.map(f => f.id)).size).toBe(2)
const freshId = next.floats.map(f => f.id).find(id => id !== 'float:2')!
expect(freshId).toMatch(/^float:\d+$/)
expect(Number(freshId.slice('float:'.length))).toBeGreaterThan(2)
})
it('floats survive a full persist round-trip (geometry verbatim when in-viewport)', () => {
const parsed = base()
parsed.floats = [
{ id: 'float:1', tab: { id: 't', type: 'git', title: 'G', meta: { k: 1 } }, x: 12, y: 34, w: 480, h: 360 },
]
const restored = sanitizeState(parsed)!
const again = sanitizeState(JSON.parse(JSON.stringify(restored)))!
expect(again.floats).toEqual(restored.floats)
})
})
})
+36 -17
View File
@@ -1,13 +1,13 @@
/**
* Tab-strip right-click context-menu tests. Right-clicking a tab takes over
* the browser menu (preventDefault) and shows the tab context menu with
* exactly four items: close / close others / close to the left / close to
* the right. All bulk operations are scoped to the CURRENT pane (the render
* time tab snapshot) and reuse the per-tab onClose path, so the target tab
* is never closed and the pane never empties mid-loop. The menu items gray
* out when there is nothing to close (single tab → close others; leftmost →
* close left; rightmost → close right). Opening the menu must not activate
* the right-clicked tab.
* exactly five items: move to free window / close / close others / close to
* the left / close to the right. All bulk operations are scoped to the
* CURRENT pane (the render time tab snapshot) and reuse the per-tab onClose
* path, so the target tab is never closed and the pane never empties
* mid-loop. The menu items gray out when there is nothing to close (single
* tab → close others; leftmost → close left; rightmost → close right).
* Opening the menu must not activate the right-clicked tab.
*/
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -29,18 +29,20 @@ function stubZh(): void {
})
}
const MENU_LABELS = ['关闭', '关闭其他页签', '关闭左侧页签', '关闭右侧页签']
const MENU_LABELS = ['移动到自由窗口', '关闭', '关闭其他页签', '关闭左侧页签', '关闭右侧页签']
function mountBar(tabs: SidebarTab[]): {
tabEls: HTMLElement[]
onClose: ReturnType<typeof vi.fn>
onActivate: ReturnType<typeof vi.fn>
onFloatTab: ReturnType<typeof vi.fn>
unmount: () => void
} {
const container = document.createElement('div')
document.body.append(container)
const onClose = vi.fn()
const onActivate = vi.fn()
const onFloatTab = vi.fn()
const root: Root = createRoot(container)
act(() => {
root.render(createElement(TabBar, {
@@ -51,6 +53,7 @@ function mountBar(tabs: SidebarTab[]): {
onClose,
onNewTab: () => {},
newTabOptions: [],
onFloatTab,
onDropTab: () => {},
}))
})
@@ -59,6 +62,7 @@ function mountBar(tabs: SidebarTab[]): {
tabEls,
onClose,
onActivate,
onFloatTab,
unmount: () => {
act(() => { root.unmount() })
container.remove()
@@ -93,7 +97,7 @@ afterEach(() => {
})
describe('TabBar right-click context menu', () => {
it('opens the four-item menu at the cursor, prevents the browser menu, and does not activate', () => {
it('opens the five-item menu at the cursor, prevents the browser menu, and does not activate', () => {
stubZh()
const { tabEls, onActivate, unmount } = mountBar(fourTabs())
try {
@@ -107,12 +111,27 @@ describe('TabBar right-click context menu', () => {
}
})
it('move to free window floats the target tab without closing it', () => {
stubZh()
const { tabEls, onClose, onFloatTab, unmount } = mountBar(fourTabs())
try {
act(() => { rightClick(tabEls[1]!) })
act(() => { menuItems()[0]!.click() })
expect(onFloatTab).toHaveBeenCalledTimes(1)
expect(onFloatTab).toHaveBeenCalledWith('t2')
expect(onClose).not.toHaveBeenCalled()
expect(menuItems()).toHaveLength(0)
} finally {
unmount()
}
})
it('close closes only the target tab and closes the menu', () => {
stubZh()
const { tabEls, onClose, unmount } = mountBar(fourTabs())
try {
act(() => { rightClick(tabEls[1]!) })
act(() => { menuItems()[0]!.click() })
act(() => { menuItems()[1]!.click() })
expect(onClose).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalledWith('t2')
expect(menuItems()).toHaveLength(0)
@@ -126,7 +145,7 @@ describe('TabBar right-click context menu', () => {
const { tabEls, onClose, unmount } = mountBar(fourTabs())
try {
act(() => { rightClick(tabEls[1]!) })
act(() => { menuItems()[1]!.click() })
act(() => { menuItems()[2]!.click() })
expect(onClose.mock.calls.map(call => call[0])).toEqual(['t1', 't3', 't4'])
expect(onClose).not.toHaveBeenCalledWith('t2')
expect(menuItems()).toHaveLength(0)
@@ -140,7 +159,7 @@ describe('TabBar right-click context menu', () => {
const { tabEls, onClose, unmount } = mountBar(fourTabs())
try {
act(() => { rightClick(tabEls[2]!) })
act(() => { menuItems()[2]!.click() })
act(() => { menuItems()[3]!.click() })
expect(onClose.mock.calls.map(call => call[0])).toEqual(['t1', 't2'])
expect(menuItems()).toHaveLength(0)
} finally {
@@ -153,7 +172,7 @@ describe('TabBar right-click context menu', () => {
const { tabEls, onClose, unmount } = mountBar(fourTabs())
try {
act(() => { rightClick(tabEls[1]!) })
act(() => { menuItems()[3]!.click() })
act(() => { menuItems()[4]!.click() })
expect(onClose.mock.calls.map(call => call[0])).toEqual(['t3', 't4'])
expect(menuItems()).toHaveLength(0)
} finally {
@@ -170,9 +189,9 @@ describe('TabBar right-click context menu', () => {
act(() => { rightClick(single.tabEls[0]!) })
const items = menuItems()
// The Menu renders each row as a disabled <button role="menuitem">.
expect(items.map(item => (item as HTMLButtonElement).disabled)).toEqual([false, true, true, true])
expect(items.map(item => (item as HTMLButtonElement).disabled)).toEqual([false, false, true, true, true])
// Clicking the disabled row must not close anything.
act(() => { items[1]!.click() })
act(() => { items[2]!.click() })
expect(single.onClose).not.toHaveBeenCalled()
} finally {
single.unmount()
@@ -181,9 +200,9 @@ describe('TabBar right-click context menu', () => {
const four = mountBar(fourTabs())
try {
act(() => { rightClick(four.tabEls[0]!) })
expect(menuItems().map(item => (item as HTMLButtonElement).disabled)).toEqual([false, false, true, false])
expect(menuItems().map(item => (item as HTMLButtonElement).disabled)).toEqual([false, false, false, true, false])
act(() => { rightClick(four.tabEls[3]!) })
expect(menuItems().map(item => (item as HTMLButtonElement).disabled)).toEqual([false, false, false, true])
expect(menuItems().map(item => (item as HTMLButtonElement).disabled)).toEqual([false, false, false, false, true])
} finally {
four.unmount()
}
+1
View File
@@ -47,6 +47,7 @@ function mountBar(): {
onClose,
onNewTab: () => {},
newTabOptions: [],
onFloatTab: () => {},
onDropTab: () => {},
}))
})
+1
View File
@@ -36,6 +36,7 @@ function mountBar(overflow: boolean): { list: HTMLElement; unmount: () => void }
onClose: () => {},
onNewTab: () => {},
newTabOptions: [],
onFloatTab: () => {},
onDropTab: () => {},
}))
})