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:
@@ -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)
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ function mountBar(): {
|
||||
onClose,
|
||||
onNewTab: () => {},
|
||||
newTabOptions: [],
|
||||
onFloatTab: () => {},
|
||||
onDropTab: () => {},
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -36,6 +36,7 @@ function mountBar(overflow: boolean): { list: HTMLElement; unmount: () => void }
|
||||
onClose: () => {},
|
||||
onNewTab: () => {},
|
||||
newTabOptions: [],
|
||||
onFloatTab: () => {},
|
||||
onDropTab: () => {},
|
||||
}))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user