Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

// A `placement: 'floating'` pane must never enter the layout tree. Adoption is
// what turns a contribution into a track (and therefore into something that
// steals width from a zone), so this exercises the real `adoptContributedPanes`
// path via `watchContributedPanes` rather than asserting on the filter itself.

describe('floating panes stay out of the layout tree', () => {
beforeEach(() => {
window.localStorage.clear()
vi.resetModules()
})

afterEach(() => {
vi.resetModules()
})

async function setup() {
const tree = await import('@/components/pane-shell/tree/store')
const model = await import('@/components/pane-shell/tree/model')
const { registry } = await import('@/contrib/registry')

registry.register({ id: 'workspace', area: 'panes', title: 'chat', data: { placement: 'main' }, render: () => null })

tree.declareDefaultTree(model.group(['workspace'], { id: 'grp-main' }))

return { model, registry, tree }
}

it('does not adopt a floating pane, while still adopting a docked one', async () => {
const { model, registry, tree } = await setup()

registry.register({
id: 'hud',
area: 'panes',
title: 'HUD',
data: { placement: 'floating', anchor: 'top-right', width: '240px' },
render: () => null
})
registry.register({
id: 'files',
area: 'panes',
title: 'Files',
data: { placement: 'right' },
render: () => null
})

tree.watchContributedPanes()

const ids = model.allPaneIds(tree.$layoutTree.get()!)

expect(ids).not.toContain('hud')
// Control: a normal placement DOES get adopted through the same pass, so
// the exclusion is specific to 'floating', not a broken adoption run.
expect(ids).toContain('files')
})

it('keeps the main zone unsplit when only a floating pane registers', async () => {
const { model, registry, tree } = await setup()

registry.register({
id: 'hud',
area: 'panes',
title: 'HUD',
data: { placement: 'floating' },
render: () => null
})

tree.watchContributedPanes()

const root = tree.$layoutTree.get()!

// Still the single group it was declared as — no track was created.
expect(root.type).toBe('group')
expect(model.allPaneIds(root)).toEqual(['workspace'])
})

it('survives a registry change without ever adopting the floating pane', async () => {
const { model, registry, tree } = await setup()

registry.register({ id: 'hud', area: 'panes', data: { placement: 'floating' }, render: () => null })
tree.watchContributedPanes()

// A later registration re-runs adoption via the registry subscription.
registry.register({ id: 'terminal', area: 'panes', data: { placement: 'bottom' }, render: () => null })

const ids = model.allPaneIds(tree.$layoutTree.get()!)

expect(ids).toContain('terminal')
expect(ids).not.toContain('hud')
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/**
* Live-behavior test for FloatingPanes: mounts the REAL component into a real
* DOM and drives real pointer/resize events. This is the closest thing to
* "running it" without an Electron main process — it exercises the rendered
* element's computed geometry, not the pure geometry module (that's
* floating-rect.test.ts).
*/

import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { registry } from '@/contrib/registry'

import { FloatingPanes } from './floating-panes'

let root: null | Root = null
let container: HTMLDivElement | null = null
let disposers: (() => void)[] = []

function render(ui: ReactNode) {
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)

act(() => {
root!.render(ui)
})
}

const card = () => document.querySelector<HTMLElement>('[data-floating-pane="hud"]')

const grab = () => card()!.querySelector('header')!

/** jsdom has no real pointer events; PointerEvent falls back to MouseEvent. */
function pointer(target: Element, type: string, x: number, y: number) {
const event = new MouseEvent(type, { bubbles: true, clientX: x, clientY: y })

Object.defineProperty(event, 'pointerId', { value: 1 })

act(() => {
target.dispatchEvent(event)
})
}

function resizeWindow(width: number, height: number) {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width, writable: true })
Object.defineProperty(window, 'innerHeight', { configurable: true, value: height, writable: true })

act(() => {
window.dispatchEvent(new Event('resize'))
})
}

function registerHud(data: Record<string, unknown>) {
disposers.push(
registry.register({
area: 'panes',
data,
id: 'hud',
render: () => <p data-testid="hud-body">live</p>,
title: 'HUD'
})
)
}

describe('FloatingPanes (live DOM)', () => {
beforeEach(() => {
window.localStorage.clear()
resizeWindow(1440, 900)
// setPointerCapture / releasePointerCapture don't exist in jsdom.
Element.prototype.setPointerCapture = vi.fn()
Element.prototype.releasePointerCapture = vi.fn()
})

afterEach(() => {
if (root) {
act(() => root!.unmount())
}

container?.remove()
root = null
container = null
disposers.forEach(dispose => dispose())
disposers = []
})

it('mounts a fixed card in the anchored corner with the pane body inside', () => {
registerHud({ anchor: 'top-right', height: '132px', placement: 'floating', width: '224px' })
render(<FloatingPanes />)

const el = card()!

expect(el).toBeTruthy()
expect(el.className).toContain('fixed')
// 1440 - 224 - 12 margin = 1204; titlebar 34 + 12 = 46.
expect(el.style.left).toBe('1204px')
expect(el.style.top).toBe('46px')
expect(el.style.width).toBe('224px')
expect(document.querySelector('[data-testid="hud-body"]')?.textContent).toBe('live')
})

it('renders nothing for a non-floating placement', () => {
registerHud({ placement: 'right', width: '224px' })
render(<FloatingPanes />)

expect(card()).toBeNull()
})

it('moves with a real pointer drag on the header', () => {
registerHud({ anchor: 'top-left', height: '132px', placement: 'floating', width: '224px' })
render(<FloatingPanes />)

expect(card()!.style.left).toBe('12px')

pointer(grab(), 'pointerdown', 100, 100)
pointer(grab(), 'pointermove', 260, 240)
pointer(grab(), 'pointerup', 260, 240)

expect(card()!.style.left).toBe('172px')
expect(card()!.style.top).toBe('186px')
})

it('persists the dragged position across a remount', () => {
registerHud({ anchor: 'top-left', height: '132px', placement: 'floating', width: '224px' })
render(<FloatingPanes />)

pointer(grab(), 'pointerdown', 100, 100)
pointer(grab(), 'pointermove', 300, 300)
pointer(grab(), 'pointerup', 300, 300)

const moved = card()!.style.left

act(() => root!.unmount())
container!.remove()
render(<FloatingPanes />)

expect(card()!.style.left).toBe(moved)
})

it('rides the right edge when the window shrinks', () => {
registerHud({ anchor: 'top-right', height: '132px', placement: 'floating', width: '224px' })
render(<FloatingPanes />)

expect(card()!.style.left).toBe('1204px')

resizeWindow(1000, 700)

// Tracks the edge: 1000 - 224 - 12 = 764.
expect(card()!.style.left).toBe('764px')
})

it('never lets a drag push the card under the titlebar', () => {
registerHud({ anchor: 'top-left', height: '132px', placement: 'floating', width: '224px' })
render(<FloatingPanes />)

pointer(grab(), 'pointerdown', 100, 100)
pointer(grab(), 'pointermove', 100, -900)
pointer(grab(), 'pointerup', 100, -900)

expect(Number.parseFloat(card()!.style.top)).toBeGreaterThanOrEqual(34)
})

it('collapses to the header and drops the body, and does not drag from the button', () => {
registerHud({ anchor: 'top-left', height: '132px', placement: 'floating', width: '224px' })
render(<FloatingPanes />)

const before = card()!.style.left
const toggle = card()!.querySelector('button')!

// The button is inside the drag handle — [data-floating-no-drag] must
// stop it starting a drag.
pointer(toggle, 'pointerdown', 100, 100)
pointer(grab(), 'pointermove', 400, 400)
pointer(grab(), 'pointerup', 400, 400)

expect(card()!.style.left).toBe(before)

act(() => {
toggle.click()
})

expect(document.querySelector('[data-testid="hud-body"]')).toBeNull()
expect(card()!.style.height).toBe('')
})

it('renders one card per floating contribution', () => {
registerHud({ anchor: 'top-left', placement: 'floating', width: '224px' })
disposers.push(
registry.register({
area: 'panes',
data: { anchor: 'bottom-right', placement: 'floating', width: '200px' },
id: 'hud2',
render: () => null,
title: 'HUD 2'
})
)

render(<FloatingPanes />)

expect(document.querySelectorAll('[data-floating-pane]').length).toBe(2)
})
})
Loading
Loading