test(desktop): cover webview and sidebar retention lifetimes

This commit is contained in:
imccyu committed 2026-09-21 03:13:15 +08:00
1 parent 0e652514ea
commit 94bc5b25b8
24 files changed
+831 -240

No files matched your search

+1 -1
View File
@@ -54,7 +54,7 @@ export class DesktopBrowserGuests {
this.leases.delete(key)
const guest = lease.guest
if (guest !== undefined && !guest.isDestroyed()) {
const destroyed = new Promise<void>(resolve => { guest.once('destroyed', resolve) })
const destroyed = new Promise<void>((resolve) => { guest.once('destroyed', resolve) })
guest.close({ waitForBeforeUnload: false })
await destroyed
}
+2 -1
View File
@@ -27,7 +27,8 @@ it('limits product documents to update status and a native confirmation action',
expect(api.updates).not.toHaveProperty('install')
const listener = vi.fn()
const dispose = api.updates.subscribe(listener)
const handler = electron.ipcRenderer.on.mock.calls.find(([channel]) => channel === DESKTOP_IPC.updatesPresentation)?.[1] as (event: unknown, state: unknown) => void
const handler = electron.ipcRenderer.on.mock.calls.find(([channel]) => channel === DESKTOP_IPC.updatesPresentation)?.[1] as
(event: unknown, state: unknown) => void
handler({}, { visible: false })
expect(listener).toHaveBeenCalledWith({ visible: false })
dispose()
@@ -124,7 +124,7 @@ export function FloatHeader({ paneId, tab, labels, intents, renderTabTitle, canC
<div className={css.stripFill} />
<Tooltip label={labels.dockFloat} side="bottom" delayMs={500}>
<button type="button" className={css.iconButton} aria-label={labels.dockFloat}
data-dockkit-float-dock={paneId} onPointerDown={event => { event.stopPropagation() }}
data-dockkit-float-dock={paneId} onPointerDown={(event) => { event.stopPropagation() }}
onClick={() => { intents.unfloatPane(paneId) }}>
<IconPanelLeftOutlineRegular className={css.dockGlyph} />
</button>
@@ -132,7 +132,7 @@ export function FloatHeader({ paneId, tab, labels, intents, renderTabTitle, canC
{(canCloseTab?.(tab.id) ?? true) && (
<Tooltip label={labels.closeFloat} side="bottom" delayMs={500}>
<button type="button" className={css.iconButton} aria-label={labels.closeFloat}
data-dockkit-float-close={paneId} onPointerDown={event => { event.stopPropagation() }}
data-dockkit-float-close={paneId} onPointerDown={(event) => { event.stopPropagation() }}
onClick={() => { intents.closeTab(tab.id) }}>
<IconCloseOutlineRegular />
</button>
@@ -35,7 +35,8 @@ interface TabHostProps extends LayoutProps {
}
/** A tab's ancestors stay identical across selection, pane moves and floating. */
function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRequest, keepMounted, active = true }: TabHostProps): ReactNode {
function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRequest, keepMounted,
active = true }: TabHostProps): ReactNode {
const floating = pane.host === 'float'
const selected = floating || pane.activeTabId === tab.id
const visible = active && selected && (floating || state.expanded)
@@ -77,32 +78,32 @@ function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRe
<div className={clsx(css.tabCell, floating && css.floatingCell)} hidden={!selected}
data-dockkit-host={floating ? 'float' : 'dock'} data-dockkit-column={floating ? undefined : column}
style={{ gridColumn: floating ? 1 : column * 2 + 1, gridRow: 1, order: floating ? depth : 0 }}>
<section ref={host} className={clsx(css.tabHost, floating ? css.float : css.pane)}
aria-hidden={!visible || undefined}
data-dockkit-content={tab.id}
data-dockkit-pane={!floating && selected ? pane.id : undefined}
data-dockkit-pane-active={!floating && state.activePaneId === pane.id || undefined}
data-dockkit-float={floating ? pane.id : undefined}
data-dockkit-float-active={floating && state.activePaneId === pane.id || undefined}
data-dockkit-column={!floating ? column : undefined}
style={rect === undefined ? undefined : {
left: rect.x, top: rect.y, width: rect.width, height: rect.height,
}}
onPointerDown={() => { if (floating) floats.raise(pane.id) }}
onClick={() => { if (!floating && state.activePaneId !== pane.id) callbacks.onFocusPane(pane.id) }}>
<div className={css.tabHostHeader}>
{selected && (floating
? <FloatHeader paneId={pane.id} tab={tab} labels={callbacks.labels} intents={intents}
renderTabTitle={callbacks.renderTabTitle} canCloseTab={callbacks.canCloseTab} drag={floats.drag} />
: <TabStrip state={state} pane={pane} callbacks={callbacks} />)}
</div>
<div ref={body} className={clsx(css.tabHostBody, floating ? css.floatBody : css.paneBody)}>
{visited && (retained || (active && selected)) ? callbacks.renderTab(tab) : null}
{!floating && <PaneDropHints pane={pane} callbacks={callbacks} />}
</div>
{floating && <div className={css.floatResize} data-dockkit-float-resize={pane.id}
onPointerDown={event => { floats.drag('resize', pane.id, event) }} />}
</section>
<section ref={host} className={clsx(css.tabHost, floating ? css.float : css.pane)}
aria-hidden={!visible || undefined}
data-dockkit-content={tab.id}
data-dockkit-pane={!floating && selected ? pane.id : undefined}
data-dockkit-pane-active={!floating && state.activePaneId === pane.id || undefined}
data-dockkit-float={floating ? pane.id : undefined}
data-dockkit-float-active={floating && state.activePaneId === pane.id || undefined}
data-dockkit-column={!floating ? column : undefined}
style={rect === undefined ? undefined : {
left: rect.x, top: rect.y, width: rect.width, height: rect.height,
}}
onPointerDown={() => { if (floating) floats.raise(pane.id) }}
onClick={() => { if (!floating && state.activePaneId !== pane.id) callbacks.onFocusPane(pane.id) }}>
<div className={css.tabHostHeader}>
{selected && (floating
? <FloatHeader paneId={pane.id} tab={tab} labels={callbacks.labels} intents={intents}
renderTabTitle={callbacks.renderTabTitle} canCloseTab={callbacks.canCloseTab} drag={floats.drag} />
: <TabStrip state={state} pane={pane} callbacks={callbacks} />)}
</div>
<div ref={body} className={clsx(css.tabHostBody, floating ? css.floatBody : css.paneBody)}>
{visited && (retained || (active && selected)) ? callbacks.renderTab(tab) : null}
{!floating && <PaneDropHints pane={pane} callbacks={callbacks} />}
</div>
{floating && <div className={css.floatResize} data-dockkit-float-resize={pane.id}
onPointerDown={(event) => { floats.drag('resize', pane.id, event) }} />}
</section>
</div>
)
}
@@ -118,7 +119,7 @@ export function TabLayout(props: LayoutProps): ReactNode {
const focusRequest = useRef<{ readonly tabId: TabId; readonly origin: Element | null }>()
const tabCallbacks: PaneCallbacks = {
...callbacks,
onFocusTab: tabId => {
onFocusTab: (tabId) => {
// Only changing the selected body replaces its strip; pane-only focus does not.
focusRequest.current = findTabPane(state, tabId).activeTabId === tabId
? undefined : { tabId, origin: document.activeElement }
@@ -137,7 +138,7 @@ export function TabLayout(props: LayoutProps): ReactNode {
<div className={css.tabLayout} data-dockkit-split={root.kind === 'split' ? root.id : undefined}
style={{ gridTemplateColumns: columns }}>
{/* Logical order changes must not make React move a connected webview's ancestor. */}
{Object.values(state.tabs).sort((a, b) => a.id.localeCompare(b.id)).map(tab => {
{Object.values(state.tabs).sort((a, b) => a.id.localeCompare(b.id)).map((tab) => {
const pane = findTabPane(state, tab.id)
return <TabHost key={tab.id} {...props} callbacks={tabCallbacks} tab={tab} pane={pane}
column={panes.findIndex(candidate => candidate.id === pane.id)} floats={floats} focusRequest={focusRequest} />
@@ -149,7 +150,7 @@ export function TabLayout(props: LayoutProps): ReactNode {
))}
{root.kind === 'split' && <div className={clsx(css.divider, css.tabLayoutDivider)}
data-dockkit-divider={`${root.id}:0`} style={{ gridColumn: 2, gridRow: 1 }}
onPointerDown={event => { callbacks.onDividerPressed(root.id, 0, event) }} />}
onPointerDown={(event) => { callbacks.onDividerPressed(root.id, 0, event) }} />}
</div>
)
}
@@ -238,158 +238,158 @@ export function TabStrip({ state, pane, callbacks }: TabPanelProps): ReactNode {
}
return (
<div className={css.tabStrip} role="tablist" data-dockkit-strip={pane.id}>
<div ref={stripTabs} className={css.stripTabs} role="presentation" data-dockkit-strip-tabs={pane.id}>
{pane.tabs.map((tabId, index) => {
const tab = getTab(state, tabId)
const selected = tabId === pane.activeTabId
const closable = callbacks.canCloseTab(tabId)
// A pane's lone unclosable chip is a label, not a choice: there is
// no other tab to select against and nothing to do to it.
const quiet = !closable && pane.tabs.length === 1
return (
<Fragment key={tabId}>
{(index > 0 || stripIndex === index) && (
<div
className={clsx(css.slot, stripIndex === index && css.slotCaret)}
data-dockkit-caret={stripIndex === index ? index : undefined}
<div className={css.tabStrip} role="tablist" data-dockkit-strip={pane.id}>
<div ref={stripTabs} className={css.stripTabs} role="presentation" data-dockkit-strip-tabs={pane.id}>
{pane.tabs.map((tabId, index) => {
const tab = getTab(state, tabId)
const selected = tabId === pane.activeTabId
const closable = callbacks.canCloseTab(tabId)
// A pane's lone unclosable chip is a label, not a choice: there is
// no other tab to select against and nothing to do to it.
const quiet = !closable && pane.tabs.length === 1
return (
<Fragment key={tabId}>
{(index > 0 || stripIndex === index) && (
<div
className={clsx(css.slot, stripIndex === index && css.slotCaret)}
data-dockkit-caret={stripIndex === index ? index : undefined}
/>
)}
<div
role="tab"
aria-selected={selected}
tabIndex={selected ? 0 : -1}
className={clsx(
css.tab,
selected && css.tabActive,
quiet && css.tabQuiet,
callbacks.draggingTabId === tabId && css.tabDragging,
)}
data-dockkit-tab={tabId}
data-dockkit-tab-quiet={quiet || undefined}
ref={(element) => {
if (element === null) chips.delete(tabId)
else chips.set(tabId, element)
}}
// Focus lands on click, not on press: a state change between
// pointerdown and the first pointermove rebuilds this subtree,
// and Chromium cancels the pointer when the pressed element is
// replaced — which would abandon every drag. A drag that ends
// elsewhere fires no click, and its own operation carries focus.
onPointerDown={(event) => {
// A secondary press is the menu, never a drag.
if (event.button === 2) return
callbacks.onTabPressed(tabId, event)
}}
onClick={(event) => {
event.stopPropagation()
activate(tabId)
}}
onKeyDown={(event) => {
// Keys on the chip's nested close control are that control's.
if (event.target !== event.currentTarget) return
const next = chipToFocus(event.key, pane.tabs, tabId)
if (next !== undefined) {
event.preventDefault()
focusChip(next)
return
}
if (selects(event.key)) {
event.preventDefault()
activate(tabId)
}
}}
onContextMenu={(event) => {
event.preventDefault()
const anchor = event.currentTarget
setMenu(current => current?.tabId === tabId ? undefined : { tabId, anchor })
}}
>
<TabTitle>{callbacks.renderTabTitle?.(tab) ?? tab.title}</TabTitle>
{closable && (
<button
type="button"
className={css.tabClose}
aria-label={callbacks.labels.closeTab}
data-dockkit-tab-close={tabId}
// A nested control stops its own press: otherwise the press
// starts a drag, captures the pointer, and this click never lands.
onPointerDown={(event) => { event.stopPropagation() }}
onClick={(event) => {
event.stopPropagation()
callbacks.onCloseTab(tabId)
}}
>
<IconCloseFillRegular size={14} />
</button>
)}
{menu?.tabId === tabId && (
<TabMenu
labels={callbacks.labels}
anchor={menu.anchor}
onClose={closable ? () => { setMenu(undefined); callbacks.onCloseTab(tabId) } : undefined}
onDismiss={() => { setMenu(undefined) }}
extras={callbacks.renderTabMenuItems?.(tab, () => { setMenu(undefined) })}
/>
)}
<div
role="tab"
aria-selected={selected}
tabIndex={selected ? 0 : -1}
className={clsx(
css.tab,
selected && css.tabActive,
quiet && css.tabQuiet,
callbacks.draggingTabId === tabId && css.tabDragging,
)}
data-dockkit-tab={tabId}
data-dockkit-tab-quiet={quiet || undefined}
ref={(element) => {
if (element === null) chips.delete(tabId)
else chips.set(tabId, element)
}}
// Focus lands on click, not on press: a state change between
// pointerdown and the first pointermove rebuilds this subtree,
// and Chromium cancels the pointer when the pressed element is
// replaced — which would abandon every drag. A drag that ends
// elsewhere fires no click, and its own operation carries focus.
onPointerDown={(event) => {
// A secondary press is the menu, never a drag.
if (event.button === 2) return
callbacks.onTabPressed(tabId, event)
}}
onClick={(event) => {
event.stopPropagation()
activate(tabId)
}}
onKeyDown={(event) => {
// Keys on the chip's nested close control are that control's.
if (event.target !== event.currentTarget) return
const next = chipToFocus(event.key, pane.tabs, tabId)
if (next !== undefined) {
event.preventDefault()
focusChip(next)
return
}
if (selects(event.key)) {
event.preventDefault()
activate(tabId)
}
}}
onContextMenu={(event) => {
event.preventDefault()
const anchor = event.currentTarget
setMenu(current => current?.tabId === tabId ? undefined : { tabId, anchor })
}}
>
<TabTitle>{callbacks.renderTabTitle?.(tab) ?? tab.title}</TabTitle>
{closable && (
<button
type="button"
className={css.tabClose}
aria-label={callbacks.labels.closeTab}
data-dockkit-tab-close={tabId}
// A nested control stops its own press: otherwise the press
// starts a drag, captures the pointer, and this click never lands.
onPointerDown={(event) => { event.stopPropagation() }}
onClick={(event) => {
event.stopPropagation()
callbacks.onCloseTab(tabId)
}}
>
<IconCloseFillRegular size={14} />
</button>
)}
{menu?.tabId === tabId && (
<TabMenu
labels={callbacks.labels}
anchor={menu.anchor}
onClose={closable ? () => { setMenu(undefined); callbacks.onCloseTab(tabId) } : undefined}
onDismiss={() => { setMenu(undefined) }}
extras={callbacks.renderTabMenuItems?.(tab, () => { setMenu(undefined) })}
/>
)}
</div>
</Fragment>
)
})}
{stripIndex === pane.tabs.length && <div className={clsx(css.slot, css.slotCaret)} data-dockkit-caret={stripIndex} />}
</div>
{callbacks.canAddTab(pane.id) && (
<Tooltip label={callbacks.labels.addTab} side="bottom" delayMs={500}>
<button
type="button"
className={css.addTab}
aria-label={callbacks.labels.addTab}
data-dockkit-add-tab={pane.id}
onClick={(event) => {
event.stopPropagation()
callbacks.onAddTab(pane.id)
}}
>
<IconPlusOutlineRegular size={14} />
</button>
</Tooltip>
)}
<div className={css.stripFill} data-dockkit-strip-fill />
{!(callbacks.hideSplitWhenBlocked && block !== undefined) && (
<Tooltip label={callbacks.labels.splitPane} side="bottom" delayMs={500} disabled={block !== undefined}>
<button
type="button"
className={css.iconButton}
aria-label={callbacks.labels.splitPane}
// Disabled buttons fire no hover events, so the blocked reason
// stays a native title.
title={block === undefined ? undefined : splitBlockedTitle(callbacks.labels, block)}
disabled={block !== undefined}
data-dockkit-split-button={pane.id}
data-dockkit-split-blocked={block}
onClick={(event) => {
event.stopPropagation()
callbacks.onSplitPane(pane.id)
}}
>
<SplitGlyph />
</button>
</Tooltip>
)}
{/* The embedder's surface-wide controls, in the top-right pane only: the
strip is the surface's top edge, and this pane's end is its corner. */}
{pane.id === callbacks.chromePaneId && callbacks.chrome !== undefined && (
// The embedder's controls report their own intents; the pane's
// click-to-focus must not add a focus entry to each of them.
<div
className={css.stripChrome}
data-dockkit-strip-chrome
onClick={(event) => { event.stopPropagation() }}
>
{callbacks.chrome}
</div>
)}
</div>
</Fragment>
)
})}
{stripIndex === pane.tabs.length && <div className={clsx(css.slot, css.slotCaret)} data-dockkit-caret={stripIndex} />}
</div>
{callbacks.canAddTab(pane.id) && (
<Tooltip label={callbacks.labels.addTab} side="bottom" delayMs={500}>
<button
type="button"
className={css.addTab}
aria-label={callbacks.labels.addTab}
data-dockkit-add-tab={pane.id}
onClick={(event) => {
event.stopPropagation()
callbacks.onAddTab(pane.id)
}}
>
<IconPlusOutlineRegular size={14} />
</button>
</Tooltip>
)}
<div className={css.stripFill} data-dockkit-strip-fill />
{!(callbacks.hideSplitWhenBlocked && block !== undefined) && (
<Tooltip label={callbacks.labels.splitPane} side="bottom" delayMs={500} disabled={block !== undefined}>
<button
type="button"
className={css.iconButton}
aria-label={callbacks.labels.splitPane}
// Disabled buttons fire no hover events, so the blocked reason
// stays a native title.
title={block === undefined ? undefined : splitBlockedTitle(callbacks.labels, block)}
disabled={block !== undefined}
data-dockkit-split-button={pane.id}
data-dockkit-split-blocked={block}
onClick={(event) => {
event.stopPropagation()
callbacks.onSplitPane(pane.id)
}}
>
<SplitGlyph />
</button>
</Tooltip>
)}
{/* The embedder's surface-wide controls, in the top-right pane only: the
strip is the surface's top edge, and this pane's end is its corner. */}
{pane.id === callbacks.chromePaneId && callbacks.chrome !== undefined && (
// The embedder's controls report their own intents; the pane's
// click-to-focus must not add a focus entry to each of them.
<div
className={css.stripChrome}
data-dockkit-strip-chrome
onClick={(event) => { event.stopPropagation() }}
>
{callbacks.chrome}
</div>
)}
</div>
)
}
@@ -0,0 +1,162 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type { ComponentProps } from 'react'
import { TabLayout } from '../src/components/TabLayout.tsx'
import { DockController } from '../src/engine/controller.ts'
import { getNode, getPane } from '../src/engine/tree.ts'
import type { PaneCallbacks } from '../src/components/render.ts'
import type { TabId } from '../src/contract/types.ts'
import { followPointer } from '../src/components/pointer.ts'
import { seededController, TEST_LABELS } from './fixtures.client.ts'
afterEach(() => { cleanup(); vi.restoreAllMocks() })
function mounted(controller = seededController(), options: Partial<ComponentProps<typeof TabLayout>> = {}) {
const callbacks: PaneCallbacks = {
onFocusTab: (id) => { controller.focusTab(id); redraw() },
onFocusPane: (id) => { controller.focusPane(id); redraw() },
onSplitPane: vi.fn(), onAddTab: vi.fn(), onCloseTab: vi.fn(), onTabPressed: vi.fn(), onDividerPressed: vi.fn(),
splitBlock: () => undefined, canAddTab: () => true, canCloseTab: () => true,
dropTarget: undefined, draggingTabId: undefined, labels: TEST_LABELS,
renderTab: tab => <input data-body={tab.id} defaultValue={tab.title} />,
renderTabTitle: undefined, renderTabMenuItems: undefined,
chromePaneId: getPane(controller.getSnapshot().state, controller.getSnapshot().state.rootId).id,
chrome: undefined,
}
const props = () => ({ state: controller.getSnapshot().state, callbacks, intents: controller, preview: undefined, ...options })
const view = render(<TabLayout {...props()} />)
function redraw(next: typeof options = {}) {
options = { ...options, ...next }
view.rerender(<TabLayout {...props()} />)
}
const node = (selector: string): HTMLElement => {
const element = view.container.querySelector<HTMLElement>(selector)
if (element === null) throw new Error(`missing ${selector}`)
return element
}
return { controller, view, redraw, callbacks, node, body: (id: TabId) => node(`[data-body="${id}"]`) }
}
it('retains visited bodies and hands keyboard focus to the newly selected strip', () => {
const controller = seededController()
controller.setExpanded(true)
const first = getPane(controller.getSnapshot().state, controller.getSnapshot().state.rootId).tabs[0]!
const second = controller.openContent({ kind: 'file', contentId: 'file:b', title: 'B' })
controller.focusTab(first)
const h = mounted(controller, { keepMounted: () => true })
const original = h.body(first)
expect(h.view.container.querySelector(`[data-body="${second}"]`)).toBeNull()
const chip = h.node(`[data-dockkit-tab="${second}"]`)
chip.focus()
fireEvent.click(chip)
expect(document.activeElement).toBe(h.node(`[data-dockkit-content="${second}"] [data-dockkit-tab="${second}"]`))
expect(h.body(first)).toBe(original)
expect(original.closest('[data-dockkit-host]')).toHaveProperty('hidden', true)
fireEvent.click(h.node(`[data-dockkit-content="${second}"] [data-dockkit-tab="${second}"]`))
const body = h.body(second)
body.focus()
h.redraw({ active: false })
expect(body.closest('[data-dockkit-content]')).toHaveProperty('inert', true)
expect(document.activeElement).not.toBe(body)
fireEvent.focus(body)
h.redraw({ active: true })
expect(h.body(second)).toBe(body)
expect(body.closest('[data-dockkit-content]')).toHaveProperty('inert', false)
act(() => { controller.setExpanded(false); h.redraw() })
expect(h.body(second)).toBe(body)
})
it('keeps body ancestors through pane moves, floating, raising and docking', () => {
const controller = seededController()
controller.setExpanded(true)
const tab = controller.openContent({ kind: 'file', contentId: 'file:a', title: 'A' })
const h = mounted(controller, { keepMounted: () => true })
const body = h.body(tab)
const host = body.closest('[data-dockkit-host]')!
act(() => { controller.splitPane(); h.redraw() })
expect(h.view.container.querySelectorAll('[data-dockkit-pane]')).toHaveLength(2)
const state = controller.getSnapshot().state
const split = getNode(state, state.rootId)
if (split.kind !== 'split') throw new Error('expected a split')
h.redraw({ preview: { splitId: split.id, sizes: [0.3, 0.7] } })
expect(h.node('[data-dockkit-split]').style.gridTemplateColumns).toContain('0.3fr')
fireEvent.pointerDown(h.node('[data-dockkit-divider]'), { button: 0, pointerId: 1 })
expect(h.callbacks.onDividerPressed).toHaveBeenCalledOnce()
h.redraw({ preview: undefined })
fireEvent.focus(body)
expect(controller.getSnapshot().state.activePaneId).toBe(getPane(state, split.children[0]!).id)
const secondPane = getPane(state, split.children[1]!).id
act(() => { controller.placeTab(tab, secondPane, 0); h.redraw() })
expect(h.body(tab)).toBe(body)
expect(body.closest('[data-dockkit-host]')).toBe(host)
act(() => { controller.floatTab(tab); h.redraw() })
const floating = controller.getSnapshot().state.floats[0]!
expect(body.closest('[data-dockkit-host]')).toBe(host)
expect(host.getAttribute('data-dockkit-host')).toBe('float')
act(() => { controller.setExpanded(false); h.redraw() })
expect(body.closest('[data-dockkit-content]')?.getAttribute('aria-hidden')).toBeNull()
fireEvent.pointerDown(h.node(`[data-dockkit-float="${floating}"]`), { pointerId: 1, button: 0 })
fireEvent.focus(body)
const resize = h.node('[data-dockkit-float-resize]')
fireEvent.pointerDown(resize, { pointerId: 2, button: 0, clientX: 500, clientY: 400 })
fireEvent.pointerMove(window, { pointerId: 2, clientX: 540, clientY: 440 })
fireEvent.pointerUp(window, { pointerId: 2, clientX: 540, clientY: 440 })
act(() => { h.redraw(); controller.unfloatPane(floating); h.redraw() })
expect(h.body(tab)).toBe(body)
expect(body.closest('[data-dockkit-host]')).toBe(host)
})
it('unmounts unretained bodies and restores focus to their replacement strip', () => {
const controller = seededController()
controller.setExpanded(true)
const first = getPane(controller.getSnapshot().state, controller.getSnapshot().state.rootId).tabs[0]!
const second = controller.openContent({ kind: 'file', contentId: 'file:b', title: 'B' })
controller.focusTab(first)
const h = mounted(controller)
const original = h.body(first)
fireEvent.click(h.node(`[data-dockkit-tab="${second}"]`))
expect(original.isConnected).toBe(false)
h.redraw({ active: false })
expect(h.view.container.querySelector('[data-body]')).toBeNull()
h.redraw({ active: true })
const focus = vi.spyOn(HTMLElement.prototype, 'focus')
fireEvent.click(h.node(`[data-dockkit-tab="${first}"]`))
expect(focus).toHaveBeenCalled()
})
it('renders an empty pane and rejects unsupported docked trees', () => {
const controller = new DockController()
controller.setExpanded(true)
const h = mounted(controller)
expect(h.node('[data-dockkit-empty]')).toBeDefined()
controller.openContent({ kind: 'file', contentId: 'file:a', title: 'A' })
controller.splitPane()
const state = controller.getSnapshot().state
const root = getNode(state, state.rootId)
if (root.kind !== 'split') throw new Error('expected a split')
vi.spyOn(console, 'error').mockImplementation(() => {})
const suppress = (event: ErrorEvent): void => {
if (event.error instanceof Error && event.error.message === 'DockLayout requires one pane or two horizontally split panes') {
event.preventDefault()
}
}
window.addEventListener('error', suppress)
try {
expect(() =>{ h.redraw({ state: { ...state, nodes: { ...state.nodes, [root.id]: { ...root, axis: 'column' } } } }) })
.toThrow('two horizontally split panes')
} finally {
window.removeEventListener('error', suppress)
}
})
it('keeps a superseding gesture marker when an older follower detaches', () => {
const element = document.createElement('div')
const followers = { move: vi.fn(), up: vi.fn(), cancel: vi.fn() }
const first = followPointer(element, 1, followers)
const second = followPointer(element, 2, followers)
first()
expect(element.dataset.dockkitPointer).toBe('2')
second()
expect(element.dataset.dockkitPointer).toBeUndefined()
})
@@ -46,12 +46,12 @@ export class BrowserController implements HostObservable<BrowserControllerState>
this.checkpoint = options.initial
this.page = options.createPage({
initial: options.initial,
persist: state => {
persist: (state) => {
if (this.disposed) return
this.checkpoint = state
this.actions.replace(options.tabId, state)
},
openRequested: value => {
openRequested: (value) => {
if (this.disposed) return
const result = parseBrowserAddress(value, options.applicationOrigin)
if (!result.ok) { this.addressFailed(result.reason); return }
@@ -254,15 +254,15 @@ export function createBrowserControllers(actions: BoundActions<BrowserStore>, cr
controllers.clear()
await Promise.all(pending)
},
rebind: actions => {
rebind: (actions) => {
currentActions = actions
for (const { controller } of controllers.values()) controller.rebind(actions)
},
loadUrl: (id, value) => { controller(id)?.loadUrl(value) },
restore: id => { controller(id)?.restore() },
goBack: id => { controller(id)?.goBack() },
goForward: id => { controller(id)?.goForward() },
reload: id => { controller(id)?.reload() },
restore: (id) => { controller(id)?.restore() },
goBack: (id) => { controller(id)?.goBack() },
goForward: (id) => { controller(id)?.goForward() },
reload: (id) => { controller(id)?.reload() },
setSandbox: (id, enabled) => { controller(id)?.setSandbox(enabled) },
}
}
@@ -145,13 +145,14 @@ export class ElectronWebViewImpl implements BrowserFrame {
this.workspaceKey ??= await this.workspace(attachmentSignal)
if (attachmentSignal.aborted) return
const reservation = await this.bridge.acquire(this.workspaceKey)
// oxlint-disable-next-line typescript/no-unnecessary-condition -- The signal can abort while acquire is pending.
if (attachmentSignal.aborted) { await this.release(reservation.lease); return }
this.lease = reservation.lease
this.guestLifetime = new AbortController()
const signal = AbortSignal.any([attachmentSignal, this.guestLifetime.signal])
const element = this.presentation.createElement(reservation)
this.element = element
const unsubscribeOpen = this.bridge.onOpenRequested(reservation.lease, url => {
const unsubscribeOpen = this.bridge.onOpenRequested(reservation.lease, (url) => {
if (this.element === element && !signal.aborted) this.options.openRequested(url)
})
signal.addEventListener('abort', unsubscribeOpen, { once: true })
@@ -161,10 +162,10 @@ export class ElectronWebViewImpl implements BrowserFrame {
this.loadPending()
}, { signal })
element.addEventListener('did-navigate', () => { this.observe(true) }, { signal })
element.addEventListener('did-navigate-in-page', event => {
element.addEventListener('did-navigate-in-page', (event) => {
if ((event as NavigationEvent).isMainFrame) this.observe(true)
}, { signal })
element.addEventListener('did-start-navigation', event => {
element.addEventListener('did-start-navigation', (event) => {
if ((event as NavigationEvent).isMainFrame) {
this.store.set({ ...this.store.getSnapshot(), loading: true, error: undefined })
}
@@ -174,7 +175,7 @@ export class ElectronWebViewImpl implements BrowserFrame {
this.observe(name === 'page-title-updated' && this.store.getSnapshot().address === 'observed')
}, { signal })
}
element.addEventListener('did-fail-load', event => {
element.addEventListener('did-fail-load', (event) => {
const failure = event as LoadFailureEvent
if (failure.isMainFrame && failure.errorCode !== -3) {
this.failed({ code: failure.errorCode, description: failure.errorDescription })
@@ -8,7 +8,7 @@ import type { BrowserTarget } from './url.ts'
/** Owns iframe navigation; the view reports loads without reading cross-origin content. */
export class IframeImpl implements BrowserFrame {
readonly sandbox: BrowserSandboxControl = { setEnabled: enabled => { this.setSandbox(enabled) } }
readonly sandbox: BrowserSandboxControl = { setEnabled: (enabled) => { this.setSandbox(enabled) } }
private readonly navigation: BrowserNavigation
private readonly store: SnapshotStore<BrowserFrameState>
private sandboxed = true
@@ -12,7 +12,12 @@ export async function browserWorkspace(source: WorkspaceSource, sessionId: strin
signal.throwIfAborted()
if (source.getSnapshot().phase !== 'ready') {
await new Promise<void>((resolve, reject) => {
const abort = (): void => { stop(); reject(signal.reason) }
const abort = (): void => {
stop()
const reason: unknown = signal.reason
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- Preserve the caller-owned AbortSignal reason.
reject(reason)
}
const stop = source.subscribe(() => {
if (source.getSnapshot().phase !== 'ready') return
stop()
@@ -72,7 +72,7 @@ export function apply(ctx: Context): void {
}, BrowserBody)), 'ui-sidebar-browser.body')
}
if (desktop === undefined) installFrames(ctx, () => createIframePage)
else ctx.inject(['workspaces'], scope => {
else ctx.inject(['workspaces'], (scope) => {
installFrames(scope, sessionId => options => createElectronPage(options, desktop,
signal => browserWorkspace(scope.workspaces.list, sessionId, signal)))
})
@@ -13,8 +13,8 @@ import { ElectronWebviewPresentation } from './view/ElectronWebviewPresentation.
*/
export function createIframePage(options: BrowserPageOptions): BrowserPage {
const presentation = new IframePresentation({
loaded: revision => { frame.handleLoaded(revision) },
failed: revision => { frame.handleLoadFailed(revision) },
loaded: (revision) => { frame.handleLoaded(revision) },
failed: (revision) => { frame.handleLoadFailed(revision) },
remounted: () => { frame.reload() },
})
const frame = new IframeImpl(options, presentation)
@@ -30,11 +30,10 @@ export function createIframePage(options: BrowserPageOptions): BrowserPage {
*/
export function createElectronPage(options: BrowserPageOptions, bridge: DesktopBrowserBridge,
workspace: (signal: AbortSignal) => Promise<string>): BrowserPage {
let frame: ElectronWebViewImpl
const presentation = new ElectronWebviewPresentation({
mounted: () => { frame.attach() },
unmounted: () => { frame.detach() },
})
frame = new ElectronWebViewImpl(options, bridge, workspace, presentation)
const frame = new ElectronWebViewImpl(options, bridge, workspace, presentation)
return { frame, presentation }
}
@@ -40,7 +40,7 @@ export type BrowserBodyProps = PropsRuntime<'sidebar.right.pane.tab'>
function useBrowserDraft(url: string | undefined, revision: number): readonly [string, (value: string) => void] {
const [edit, setEdit] = useState<{ readonly revision: number; readonly value: string }>()
return [edit?.revision === revision ? edit.value : url ?? '',
value => { setEdit({ revision, value }) }]
(value) => { setEdit({ revision, value }) }]
}
/** Render provider-neutral navigation state and optional controls. */
@@ -62,7 +62,7 @@ export function BrowserBody(props: BrowserBodyProps): ReactNode {
const hide = mount({
tabId: tab.id, signal: tab.signal, viewportId, applicationOrigin: window.location.origin,
initial: initial.current, initialUrl: initialUrl.current,
openTab: url => { tab.actions.openTab('browser', { params: { url }, revealIfOpened: false }) },
openTab: (url) => { tab.actions.openTab('browser', { params: { url }, revealIfOpened: false }) },
})
setMountEpoch(value => value + 1)
return hide
@@ -88,7 +88,7 @@ export function BrowserBody(props: BrowserBodyProps): ReactNode {
aria-label={t('address.placeholder')}
placeholder={t('address.placeholder')}
spellCheck={false}
onChange={event => { setDraft(event.currentTarget.value) }}
onChange={(event) => { setDraft(event.currentTarget.value) }}
/>
{unknown && <span className={css.addressChanged}>{t('address.changed')}</span>}
<button type="submit" className={[css.tool, css.addressGo].join(' ')} aria-label={t('go')} title={t('go')}><IconLinkOutlineRegular /></button>
@@ -43,28 +43,28 @@ function mountBrowser(navigation?: { readonly url?: string }) {
const tabActions = { openResource: vi.fn(), openTab: vi.fn(), close: vi.fn() }
const props: Pick<BrowserBodyProps, 'sessionId' | 'useTabInfo' | 'useStore' | 'actions' | 't' | 'useBrowserState'>
& Omit<BrowserInjected, 'keyedHooks'> = {
sessionId: SESSION,
useTabInfo: () => ({
sidebar: { expanded: true, fullscreen: false }, panel: { id: 'pane' as PaneId },
tab: {
id: TAB, kind: 'browser', title: 'Browser', contentId: 'sidebar://browser/1', visible: true,
navigation: { address: 'sidebar://browser/1', params: navigation, revision: 0 },
signal: lifetime.signal,
actions: tabActions,
sessionId: SESSION,
useTabInfo: () => ({
sidebar: { expanded: true, fullscreen: false }, panel: { id: 'pane' as PaneId },
tab: {
id: TAB, kind: 'browser', title: 'Browser', contentId: 'sidebar://browser/1', visible: true,
navigation: { address: 'sidebar://browser/1', params: navigation, revision: 0 },
signal: lifetime.signal,
actions: tabActions,
},
}),
useStore: hookOf(store),
actions: store.actions,
t: (key, params) => {
const template = messages[key] ?? key
return params === undefined ? template : template.replace(/\{(\w+)\}/g, (_match, name: string) => String(params[name]))
},
}),
useStore: hookOf(store),
actions: store.actions,
t: (key, params) => {
const template = messages[key] ?? key
return params === undefined ? template : template.replace(/\{(\w+)\}/g, (_match, name: string) => String(params[name]))
},
...commands,
useBrowserState: (key: string) => {
const state = keyedHooks.browserState(key) ?? absentState
return useSyncExternalStore(state.subscribe, state.getSnapshot)
},
}
...commands,
useBrowserState: (key: string) => {
const state = keyedHooks.browserState(key) ?? absentState
return useSyncExternalStore(state.subscribe, state.getSnapshot)
},
}
const renderBody = () => render(<BrowserBody {...props as BrowserBodyProps} />)
return {
view: renderBody(), remount: renderBody, store, lifetime, injected,
@@ -16,7 +16,7 @@ it('clears a failed load when the main page retries without a toolbar command',
acquire: vi.fn(async () => ({ lease: 'lease' as DesktopBrowserLeaseId, partition: 'partition' })),
release: vi.fn(async () => {}), onOpenRequested: () => () => {},
}
const presentation = new ElectronWebviewPresentation({ mounted: () => frame.attach(), unmounted: () => frame.detach() })
const presentation = new ElectronWebviewPresentation({ mounted: () =>{ frame.attach() }, unmounted: () =>{ frame.detach() } })
const frame = new ElectronWebViewImpl({ initial: undefined, persist: vi.fn(), openRequested: vi.fn() },
bridge, async () => 'cwd:/workspace', presentation)
const createElement = vi.spyOn(presentation, 'createElement').mockReturnValue(element)
@@ -0,0 +1,66 @@
/** Native webview events controlled by each test; presentation and navigation stay real. */
import { vi } from 'vitest'
import type { DesktopBrowserBridge, DesktopBrowserLeaseId, DesktopBrowserReservation } from '../src/types.ts'
import type { BrowserTabState } from '../src/client/browser/BrowserPersistence.ts'
import { createElectronPage } from '../src/client/pages.ts'
import { ElectronWebviewPresentation } from '../src/client/view/ElectronWebviewPresentation.ts'
let sequence = 0
/** @returns one isolated, explicitly mounted page with native operations replaced by spies. */
export function electronFixture(initial?: BrowserTabState) {
const opens = new Set<(url: string) => void>()
const reservation: DesktopBrowserReservation = { lease: `lease-${++sequence}` as DesktopBrowserLeaseId, partition: 'partition' }
const bridge = {
acquire: vi.fn(async (_workspace: string) => reservation),
release: vi.fn(async (_lease: DesktopBrowserLeaseId) => {}),
onOpenRequested: vi.fn((_lease: DesktopBrowserLeaseId, listener: (url: string) => void) => {
opens.add(listener)
return () => { opens.delete(listener) }
}),
} satisfies DesktopBrowserBridge
const workspace = vi.fn(async (_signal: AbortSignal) => 'cwd:/workspace')
const persist = vi.fn()
const openRequested = vi.fn()
const page = createElectronPage({ initial, persist, openRequested }, bridge, workspace)
const presentation = page.presentation
if (!(presentation instanceof ElectronWebviewPresentation)) throw new Error('expected the Electron presentation')
const create = presentation.createElement.bind(presentation)
const guests: ReturnType<typeof prepareGuest>[] = []
function prepareGuest(approved: DesktopBrowserReservation) {
const element = create(approved)
const state = { url: 'about:blank', title: '', loading: true, back: false, forward: false }
const methods = {
loadURL: vi.fn(async (_url: string) => {}), getURL: vi.fn(() => state.url), getTitle: vi.fn(() => state.title),
canGoBack: () => state.back, canGoForward: () => state.forward, clearHistory: vi.fn(),
goBack: vi.fn(), goForward: vi.fn(), reload: vi.fn(), isLoading: () => state.loading,
}
Object.assign(element, methods)
const emit = (type: string, fields: object = {}): void => { element.dispatchEvent(Object.assign(new Event(type), fields)) }
return { element, state, emit, ...methods }
}
const createElement = vi.spyOn(presentation, 'createElement').mockImplementation((approved) => {
const guest = prepareGuest(approved)
guests.push(guest)
return guest.element
})
const host = document.createElement('div')
host.id = `electron-fixture-${sequence}`
document.body.append(host)
return {
...page, presentation, bridge, workspace, persist, openRequested, opens, guests, host, reservation,
mount: () => presentation.mount(host.id),
async guest() {
await vi.waitFor(() => { expectGuest() })
return guests.at(-1)!
},
async dispose() {
await page.frame.dispose()
createElement.mockRestore()
host.remove()
},
}
function expectGuest(): void {
if (host.firstElementChild === null || guests.length === 0) throw new Error('guest has not attached')
}
}
@@ -0,0 +1,179 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from 'vitest'
import type { DesktopBrowserReservation } from '../src/types.ts'
import { electronFixture } from './electron-harness.client.ts'
const target = { kind: 'https' as const, url: 'https://example.test/', title: 'Example' }
const fixtures: ReturnType<typeof electronFixture>[] = []
function fixture() {
const h = electronFixture()
fixtures.push(h)
return h
}
afterEach(async () => {
for (const h of fixtures.splice(0)) await h.dispose()
vi.restoreAllMocks()
})
it('waits for a mounted container and observes native history, titles and new-tab requests', async () => {
const h = fixture()
h.frame.goBack()
h.frame.goForward()
h.frame.reload()
h.frame.loadUrl(target)
expect(h.bridge.acquire).not.toHaveBeenCalled()
h.mount()
const guest = await h.guest()
expect(guest.element.getAttribute('partition')).toBe(h.reservation.partition)
expect(guest.element.getAttribute('src')).toBe(`about:blank#${h.reservation.lease}`)
guest.emit('dom-ready')
expect(guest.loadURL).toHaveBeenCalledWith(target.url)
guest.state.url = target.url
guest.state.title = 'Title'
guest.state.back = true
guest.state.forward = true
guest.emit('did-navigate')
expect(guest.clearHistory).toHaveBeenCalledOnce()
expect(h.frame.getSnapshot()).toMatchObject({ address: 'observed', target: { ...target, title: 'Title' } })
expect(guest.element.getAttribute('aria-label')).toBe('Title')
const writes = h.persist.mock.calls.length
guest.emit('page-title-updated')
expect(h.persist).toHaveBeenCalledTimes(writes)
h.frame.loadUrl(target)
h.frame.goBack()
h.frame.goForward()
expect(guest.reload).toHaveBeenCalledOnce()
expect(guest.goBack).toHaveBeenCalledOnce()
expect(guest.goForward).toHaveBeenCalledOnce()
guest.state.url = `${target.url}next`
guest.emit('did-navigate-in-page', { isMainFrame: false })
expect(h.frame.getSnapshot().target?.url).toBe(target.url)
guest.emit('did-navigate-in-page', { isMainFrame: true })
guest.state.title = ''
guest.state.loading = false
guest.emit('page-title-updated')
guest.emit('did-stop-loading')
expect(h.frame.getSnapshot()).toMatchObject({ loading: false, target: { url: guest.state.url, title: 'example.test' } })
const open = h.bridge.onOpenRequested.mock.calls[0]![1]
open('https://new.example/')
expect(h.openRequested).toHaveBeenCalledOnce()
await h.frame.dispose()
open('https://late.example/')
guest.emit('did-navigate')
h.frame.loadUrl(target)
h.frame.reload()
h.frame.goBack()
expect(h.openRequested).toHaveBeenCalledOnce()
expect(h.opens.size).toBe(0)
expect(h.bridge.release).toHaveBeenCalledExactlyOnceWith(h.reservation.lease)
})
it('releases an acquisition that finishes after attachment cancellation', async () => {
const h = fixture()
const pending = Promise.withResolvers<DesktopBrowserReservation>()
h.bridge.acquire.mockReturnValueOnce(pending.promise)
const unmount = h.mount()
h.frame.loadUrl(target)
try {
await vi.waitFor(() => { expect(h.bridge.acquire).toHaveBeenCalledOnce() })
unmount()
const disposed = h.frame.dispose()
expect(h.frame.dispose()).toBe(disposed)
pending.resolve(h.reservation)
await disposed
expect(h.guests).toHaveLength(0)
expect(h.bridge.release).toHaveBeenCalledExactlyOnceWith(h.reservation.lease)
} finally {
pending.resolve(h.reservation)
}
})
it('does not acquire a guest when Workspace resolution finishes after disposal', async () => {
const h = fixture()
const pending = Promise.withResolvers<string>()
h.workspace.mockReturnValueOnce(pending.promise)
h.mount()
h.frame.loadUrl(target)
try {
const disposed = h.frame.dispose()
pending.resolve('cwd:/late')
await disposed
expect(h.bridge.acquire).not.toHaveBeenCalled()
h.mount()
expect(h.guests).toHaveLength(0)
} finally {
pending.resolve('cwd:/late')
}
})
it('recreates the guest after physical remount or crash without resolving Workspace again', async () => {
const h = fixture()
const unmount = h.mount()
h.frame.loadUrl(target)
const first = await h.guest()
first.emit('dom-ready')
unmount()
h.mount()
const second = await h.guest()
expect(second.element).not.toBe(first.element)
expect(h.workspace).toHaveBeenCalledOnce()
second.emit('dom-ready')
expect(second.loadURL).toHaveBeenCalledWith(target.url)
second.emit('render-process-gone')
expect(h.frame.getSnapshot().error).toBeDefined()
h.frame.reload()
const third = await h.guest()
third.emit('dom-ready')
expect(third.loadURL).toHaveBeenCalledWith(target.url)
expect(h.frame.getSnapshot().error).toBeUndefined()
third.emit('destroyed')
expect(h.frame.getSnapshot().error).toBeDefined()
})
it('contains acquisition, native command and release failures and keeps retry available', async () => {
const h = fixture()
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
h.bridge.acquire.mockRejectedValueOnce(new Error('acquire failed'))
h.mount()
h.frame.loadUrl(target)
await vi.waitFor(() => { expect(h.frame.getSnapshot().error).toBeDefined() })
h.frame.reload()
const guest = await h.guest()
guest.emit('dom-ready')
guest.state.url = target.url
guest.emit('did-navigate')
guest.reload.mockImplementationOnce(() => { throw new Error('command failed') })
h.frame.reload()
expect(h.frame.getSnapshot().error).toBeDefined()
h.frame.reload()
expect(h.frame.getSnapshot().error).toBeUndefined()
guest.getURL.mockImplementationOnce(() => { throw new Error('observation failed') })
guest.emit('did-navigate')
expect(h.frame.getSnapshot().error).toBeDefined()
h.bridge.release.mockRejectedValueOnce(new Error('release failed'))
await h.frame.dispose()
expect(error.mock.calls.some(call => call[0] === 'Desktop browser guest release failed')).toBe(true)
})
it('ignores superseded and aborted load promises, but publishes an active navigation rejection', async () => {
const h = fixture()
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
h.mount()
h.frame.loadUrl(target)
const guest = await h.guest()
const stale = Promise.withResolvers<undefined>()
guest.loadURL.mockReturnValueOnce(stale.promise)
guest.emit('dom-ready')
h.frame.loadUrl({ ...target, url: 'https://example.test/new' })
stale.reject(new Error('old navigation failed'))
await expect(stale.promise).rejects.toThrow('old navigation')
expect(h.frame.getSnapshot().error).toBeUndefined()
guest.loadURL.mockRejectedValueOnce(Object.assign(new Error('aborted'), { code: 'ERR_ABORTED' }))
h.frame.loadUrl({ ...target, url: 'https://example.test/cancelled' })
await Promise.resolve()
expect(h.frame.getSnapshot().error).toBeUndefined()
guest.loadURL.mockRejectedValueOnce(new Error('active navigation failed'))
h.frame.loadUrl(target)
await vi.waitFor(() => { expect(h.frame.getSnapshot().error).toBeDefined() })
expect(error).toHaveBeenCalledOnce()
})
@@ -0,0 +1,57 @@
import { afterEach, expect, it, vi } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { WorkspaceId, WorkspaceSnapshot, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { browserWorkspace } from '../src/client/browser/workspace.ts'
const SESSION = 'session' as SessionId
const workspace = (id: string, path: string, sessionIds: readonly SessionId[]): WorkspaceView => ({
workspaceId: id as WorkspaceId, path, title: path, sessionIds, createdAt: '', updatedAt: '',
})
const snapshot = (phase: WorkspaceSnapshot['phase'], items: readonly WorkspaceView[] = []): WorkspaceSnapshot => ({
phase, items, state: 'idle', error: null, archivedSessionIds: [], pinnedSessionIds: [],
})
afterEach(() => { vi.restoreAllMocks() })
it('groups resolved Sessions by canonical CWD rather than Workspace identity', async () => {
const source = createSnapshotStore(snapshot('ready', [
workspace('other', '/other', ['other' as SessionId]), workspace('first', '/canonical', [SESSION]),
]))
const signal = new AbortController().signal
await expect(browserWorkspace(source, SESSION, signal)).resolves.toBe('cwd:/canonical')
source.set(snapshot('ready', [workspace('renamed', '/canonical', [SESSION])]))
await expect(browserWorkspace(source, SESSION, signal)).resolves.toBe('cwd:/canonical')
await expect(browserWorkspace(source, 'unaccounted', signal)).resolves.toBe('session:unaccounted')
})
it('waits for an authoritative baseline and removes the readiness subscription', async () => {
const source = createSnapshotStore(snapshot('pending'))
const subscribe = source.subscribe.bind(source)
const stopped = vi.fn()
vi.spyOn(source, 'subscribe').mockImplementation((listener) => {
const stop = subscribe(listener)
return () => { stop(); stopped() }
})
const lifetime = new AbortController()
const result = browserWorkspace(source, SESSION, lifetime.signal)
source.set(snapshot('pending', [workspace('partial', '/partial', [SESSION])]))
expect(stopped).not.toHaveBeenCalled()
source.set(snapshot('ready', [workspace('ready', '/ready', [SESSION])]))
await expect(result).resolves.toBe('cwd:/ready')
expect(stopped).toHaveBeenCalledOnce()
lifetime.abort()
expect(stopped).toHaveBeenCalledOnce()
})
it('rejects cancellation before and during readiness without retaining a listener', async () => {
const source = createSnapshotStore(snapshot('pending'))
const lifetime = new AbortController()
const failure = new Error('tab closed')
const result = browserWorkspace(source, SESSION, lifetime.signal)
const rejected = expect(result).rejects.toBe(failure)
lifetime.abort(failure)
await rejected
source.set(snapshot('ready'))
await expect(browserWorkspace(source, SESSION, lifetime.signal)).rejects.toBe(failure)
})
@@ -39,11 +39,15 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/** Session content selected by the root-scoped right Sidebar controller. */
'rightbar.session': { kind: 'single'; scope: 'session'; owner: RightbarOwnerProps & {
readonly active: boolean
/** @param tabId - retained body. @param signal - tab occurrence lifetime. @returns releases the View-owned hold. */
readonly retainTab: (tabId: TabId, signal: AbortSignal) => () => void
} }
'rightbar.session': {
kind: 'single'
scope: 'session'
owner: RightbarOwnerProps & {
readonly active: boolean
/** @param tabId - retained body. @param signal - tab occurrence lifetime. @returns releases the View-owned hold. */
readonly retainTab: (tabId: TabId, signal: AbortSignal) => () => void
}
}
/**
* One tab's body, dispatched with the `id` of the type in force for
* `tab.kind`. A tab type registers here under its definition's `id` and
@@ -31,7 +31,7 @@ export class SidebarSessionView {
private readonly onTabRelease: (view: SidebarSessionView) => void,
) {
this.reference = sessions.retain(sessionId, { source: 'sidebarView' })
void this.reference.ready.catch(error => { console.error('Sidebar Session opening failed:', error) })
void this.reference.ready.catch((error: unknown) => { console.error('Sidebar Session opening failed:', error) })
}
/** Whether an initialized retained body still needs this view. */
@@ -32,9 +32,9 @@ export class SidebarSessionViews {
if (this.closed || this.selected === sessionId) return
this.selected = sessionId
if (sessionId !== undefined && !this.views.has(sessionId)) {
const view = new SidebarSessionView(sessionId, this.sessions, disposed => {
const view = new SidebarSessionView(sessionId, this.sessions, (disposed) => {
this.viewsByReference.delete(disposed.reference)
}, released => { this.prune(released) })
}, (released) => { this.prune(released) })
this.views.set(sessionId, view)
this.viewsByReference.set(view.reference, view)
}
@@ -421,7 +421,9 @@ export function RightbarSeat({
// Republished on every committed change: the service's readers answer from the
// last commit, and its commands act on the session actually on screen.
useEffect(
() => active ? bindService({ sessionId, actions, surfaces, canSplitPane: paneId => room.current.get(paneId)?.row !== false }) : undefined,
() => active
? bindService({ sessionId, actions, surfaces, canSplitPane: paneId => room.current.get(paneId)?.row !== false })
: undefined,
[bindService, sessionId, actions, surfaces, active],
)
// The Tab domain is not synced here: the controller adopted this session's
@@ -0,0 +1,114 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from 'vitest'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
import { SidebarSessionView } from '../src/client/session-view.ts'
import { SidebarSessionViews } from '../src/client/session-views.ts'
const A = 'a' as SessionId
const B = 'b' as SessionId
const TAB = 'tab' as TabId
const runtimes: SlotTestRuntime[] = []
const owners: { dispose(): void }[] = []
afterEach(async () => {
for (const owner of owners.splice(0)) owner.dispose()
for (const runtime of runtimes.splice(0)) await runtime.dispose()
vi.restoreAllMocks()
})
async function runtime() {
const runtime = await SlotTestRuntime.create()
runtimes.push(runtime)
await runtime.sessions.add({ id: A })
await runtime.sessions.add({ id: B })
return runtime
}
it('keeps one reference per View until the final committed root of a retired View unmounts', async () => {
const r = await runtime()
const disposed = vi.fn()
const changed = vi.fn()
const view = new SidebarSessionView(A, r.sessions, disposed, changed)
owners.push(view)
await view.reference.ready
const release = vi.spyOn(view.reference, 'release')
const firstMount = view.mount()
const secondMount = view.mount()
const first = new AbortController()
const second = new AbortController()
const firstHold = view.retainTab(TAB, first.signal)
const secondHold = view.retainTab(TAB, second.signal)
firstHold()
firstHold()
expect(view.hasRetainedTabs).toBe(true)
expect(changed).toHaveBeenCalledOnce()
second.abort()
secondHold()
expect(view.hasRetainedTabs).toBe(false)
view.retainTab(TAB, second.signal)()
view.retire()
firstMount()
expect(release).not.toHaveBeenCalled()
secondMount()
expect(release).toHaveBeenCalledOnce()
view.retainTab(TAB, first.signal)()
view.dispose()
expect(disposed).toHaveBeenCalledExactlyOnceWith(view)
})
it('retires only unselected Views without retained bodies and rejects released reference identities', async () => {
const r = await runtime()
const views = new SidebarSessionViews(r.sessions)
owners.push(views)
views.select(A)
const first = views.source.getSnapshot()
const a = first[0]!
views.select(A)
expect(views.source.getSnapshot()).toBe(first)
const unmount = views.mount(a.reference)
const lifetime = new AbortController()
const release = vi.spyOn(a.reference, 'release')
a.retainTab(TAB, lifetime.signal)
views.select(B)
expect(views.source.getSnapshot().map(view => [view.sessionId, view.selected])).toEqual([[A, false], [B, true]])
lifetime.abort()
expect(views.source.getSnapshot().map(view => view.sessionId)).toEqual([B])
expect(release).not.toHaveBeenCalled()
unmount()
expect(release).toHaveBeenCalledOnce()
expect(() => views.mount(a.reference)).toThrow('no longer owned')
views.select(undefined)
expect(views.source.getSnapshot()).toEqual([])
views.dispose()
views.select(A)
views.mount(a.reference)()
expect(views.source.getSnapshot()).toEqual([])
})
it('reports opening failure and releases Views even when committed roots are still present', async () => {
const r = await runtime()
const reference = r.sessions.retain(A, { source: 'sidebarView' })
const failure = new Error('opening failed')
const release = vi.fn(() => { reference.release() })
vi.spyOn(r.sessions, 'retain').mockReturnValueOnce({
sessionId: reference.sessionId,
get binding() { return reference.binding },
ready: Promise.reject(failure),
release,
[Symbol.dispose]: () => { reference.release() },
})
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const views = new SidebarSessionViews(r.sessions)
owners.push(views)
views.select(A)
const view = views.source.getSnapshot()[0]!
const unmount = views.mount(view.reference)
await expect(view.reference.ready).rejects.toBe(failure)
expect(error).toHaveBeenCalledWith('Sidebar Session opening failed:', failure)
views.dispose()
expect(release).toHaveBeenCalledOnce()
unmount()
expect(release).toHaveBeenCalledOnce()
})
@@ -2664,7 +2664,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.pane.tab\', () => ctx.slots.register(\n { name: \'sidebar.right.pane.tab\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:54',
source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:58',
},
{
key: 'sidebar.right.pane.tab.title',
@@ -2713,7 +2713,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.pane.tab.title\', () => ctx.slots.register(\n { name: \'sidebar.right.pane.tab.title\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:68',
source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:72',
},
{
key: 'sidebar.right.tab.document',
@@ -3017,7 +3017,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.tab.guide\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.guide\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:79',
source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:83',
},
{
key: 'sidebar.right.tab.guide.entry',
@@ -3063,7 +3063,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.tab.guide.entry\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.guide.entry\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:86',
source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:90',
},
{
key: 'sidebar.right.tab.menu.item',
@@ -3121,7 +3121,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.tab.menu.item\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.menu.item\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:98',
source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:102',
},
{
key: 'sidebar.settings',