Merge pull request #354 from omdsh-dev/feat/free-window

feat(ui): 自由窗口——tab 拖出侧边栏成为可移动/缩放/置顶的浮动窗口 (v0.16.0)
This commit is contained in:
Atlas Zeng
2026-08-24 01:12:37 +08:00
committed by GitHub
39 changed files with 2196 additions and 104 deletions
+15 -1
View File
@@ -537,7 +537,7 @@ interface BetterSidebarService {
readonly version: string
/** 单调能力清单(只增不删):'badge' | 'tabLifecycle' | 'updateTab' |
* 'openFile' | 'targetedOpen' | 'stateSubscription' | 'tabMeta' |
* 'pluginSettings' | 'urlTarget' | 'settingSelect'——消费插件用 `features.includes('xxx')` 按能力 gate。 */
* 'pluginSettings' | 'urlTarget' | 'settingSelect' | 'floatWindows'——消费插件用 `features.includes('xxx')` 按能力 gate。 */
readonly features: readonly string[]
/** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。
* session 未激活时 state/sessionId 为 undefined。 */
@@ -601,6 +601,20 @@ interface OpenTabSeed {
---
## 7.5 自由窗口(v0.16.0)
任意 tab(内置或插件注册的)可拖出侧边栏,成为悬浮在主会话区域上的**自由窗口**(free window):
- **拖出**:把标签栏的 tab 拖到**主会话区域**(conversation 列)上——虚线提示浮层标记落区,松开即浮动(`floatTab` reducer:tab 从 pane **移动**进 `SidebarState.floats`,窗口以默认尺寸 390×780(手机竖屏比例,创建时先按视口减 24px 钳高)居中于松点、钳入视口;清空的 pane 折叠)。检测在 `src/client/Sidebar.tsx`:document 捕获 `dragover`/`drop`,以 `body[data-dsh-tab-dragging]`(TabBar 维护)门控,目标矩形取 `#root [data-slot="conversation"]` 的父列;窄视口(合并抽屉)禁用手势。tab 右键菜单的**「移动到自由窗口」**(两面板共用 TabBar,一处生效)始终可用,窗口落在会话列中心。
- **窗口操作**(`src/client/FreeWindow.tsx`,渲染在 `[data-dsh-panel-host]` 内、z-index 42):头部 pointer 拖动移动(rAF 直写 DOM + 松手提交 store 的面板拖拽模式);拖动中指针落在 pane(`[data-dsh-pane]`)上时该 pane 高亮(`data-dsh-float-dock-over`),松开即**停靠**(center 合并进该 pane,`dockFloat`);右下角缩放(`FLOAT_MIN_W/H` = 320/200,SE 角锚定);点击任意处置顶(层叠顺序 = `floats` 数组序,`raiseFloat`);头部右键菜单「回到侧边栏 / 关闭」;X = `closeTab`(关闭 tab 与窗口,正常触发 `onClose` 生命周期、释放终端)。
- **持久化**:`floats` 随会话进 localStorage(`dsh-sidebar:v1:<sessionId>`),刷新原样恢复;`sanitizeState` 对每条 float **宽容校验**(非法条目单独丢弃不动整体布局,几何钳入当前视口,diff/ephemeral tab 不持久化,旧文档缺字段 = `[]`);`maxCounterId` 的 uid 播种覆盖 `float:N` 前缀。
- **服务语义**(消费插件可见,`features` 含 `'floatWindows'`):`openTab` 的 dedupeKey/id 聚焦命中**浮动中的 tab = 置顶其窗口**(不重复开、不展开面板);`closeTab`/`activateTab` 对浮动 tab 分别关窗/置顶并照常触发 `onClose`/`onActivate`;agent 终端 reconcile 会随宿主列表移除已消失的浮动终端窗口。tab 内容复用常规 `renderTab`(`TabContent`),浮窗对 `visible` 恒为 true,所有 tab 类型无差别浮动。
- **稳定寻址面**(皮肤/CSS 可用):`[data-dsh-float-window]`(窗口本体)、`[data-dsh-float-id]`(窗口 id)、`[data-dsh-pane]`(每个 pane)、`[data-dsh-float-dock-over]`(停靠高亮)、`[class*='floatDropHint']`(拖出提示)。视觉全令牌驱动(`--dsw-alias-bg-layer-1` 表面、`--dsw-shadow-lv3` 阴影、无硬编码色),头部带 `-webkit-app-region: no-drag`(§8 拖拽区退出规则)。
- **i18n**:`moveToFreeWindow` / `floatDropHint` / `dockToSidebar`(19 语言词典已全量同步)。
- **⚠️ portal 事件劫持陷阱**:浮窗头部/缩放把手是 pointer 拖拽表面,但其 React 子树含 portal 覆盖层(头部 `Menu` portal 到 `document.body`)——portal 后代的合成事件**沿 React 树冒泡**回拖拽表面的 `onPointerDown`,会被误判为拖拽开始(preventDefault 吞点击 + setPointerCapture 抢走 pointer,菜单项/X 按钮在真实浏览器中失灵;jsdom 合成事件不走此路径,只有 e2e 能抓)。因此 `FreeWindow.tsx` 的拖拽起点必须带**同源守卫**:`event.currentTarget.contains(event.target)` 为假或目标在 `button` 内时直接返回。任何「拖拽表面内嵌 portal 覆盖层」的新组件都要同样设防(回归测试:`tests/free-window.spec.tsx` 的 portaled-menu 用例)。
---
## 8. 皮肤兼容(令牌驱动)
> 侧边栏的皮肤兼容是**令牌驱动**的:所有视觉值都消费 DSH 的 `--dsw-alias-*` / `--dsw-font-*` / `--ds-*` 令牌(无硬编码颜色),皮肤系统覆盖什么令牌我们就跟随什么——**不做任何每皮肤适配**。当前已与 dsh-web-ui 皮肤中心兼容:其 10 款皮肤全部覆盖 `--dsw-alias-*` 层,换肤后面板自动跟随(测试:`tests/e2e/mount.e2e.ts` 断言布局变量随面板挂载生效;`tests/theme.spec.ts` 守护令牌读取)。
@@ -0,0 +1,82 @@
# 自由窗口(Free Window)设计文档
- 日期:2026-08-23
- 版本:v0.16.0
- 分支:`feat/free-window`
- 状态:已实现(本文含实施偏差记录)
## 1. 背景与目标
侧边栏的 tab 只能活在右面板 / 底面板的 pane 里。用户在主会话区域工作的同时想盯一个终端、一个文件或一个插件页面时,只能挤压面板宽度或来回切换 tab。
**目标**:把任一 tab 拖到主会话区域(conversation 列)上松开,它变成一个可自由移动/缩放的**自由窗口**,悬浮在会话区之上;随时可拖回侧边栏停靠或一键返回。
**非目标(KISS 排除项)**:
- 无四边/四角缩放(只有 SE 角)、无双击最大化;
- 浮窗单 tab(无内嵌迷你标签栏);
- 停靠不支持 edge split(只 center 合并进目标 pane);
- 无窗口数量上限、无磁吸/网格对齐;
- 窄视口(合并抽屉)禁用拖出手势(抽屉覆盖会话区,无处可放);右键菜单入口仍可用。
## 2. 交互设计(用户视角)
| 手势/入口 | 行为 |
|---|---|
| 按住 tab 拖到主会话区域 | 会话列出现虚线提示浮层「松开以在自由窗口中打开」;松开 → tab **移动**(pane 中移除)为浮窗,默认 390×780(手机竖屏比例;创建时按视口钳制)居中于松点、钳入视口 |
| tab 右键菜单「移动到自由窗口」 | 同上,落点为会话列中心(右面板与底面板的 TabBar 共用组件,一处生效) |
| 浮窗头部按住拖动 | 移动窗口(pointer capture + rAF 直写 DOM + 松手提交 store);拖到侧边栏 pane 上方时该 pane 高亮,松开即停靠(center 合并) |
| 浮窗头部右键 | 菜单:「回到侧边栏」(停靠到激活 pane)/「关闭」 |
| 浮窗右下角拖动 | SE 角缩放(≥320×200,锚定左上角,钳入视口) |
| 点击浮窗任意处 | 置顶(层叠顺序 = `floats` 数组序,末位最上) |
| 浮窗头部 X | 关闭该 tab(走 `closeTab` 正常流程:触发 `onClose`、释放终端 pty) |
刷新页面:浮窗随会话状态从 localStorage 原样恢复(tab + 几何)。
## 3. 状态模型(`src/client/state.ts`)
```ts
interface FloatWindow {
id: string // uid('float'),与 pane/tab/split 共享计数器
tab: SidebarTab // 移动语义:浮窗拥有 tab,pane 不再持有
x: number; y: number // 视口坐标(左上角)
w: number; h: number
}
// SidebarState.floats: FloatWindow[](数组序 = 层叠序)
```
新增纯 reducer:`floatTab`(两棵树定位 + 移除 + 清空折叠 + activePane 回落)、`moveFloat` / `resizeFloat`(钳制,no-op 返回原引用避免持久化搅动)、`raiseFloat`(移末尾,幂等)、`dockFloat`(目标 pane 缺省 activePane,stale 回落右树首叶)、`closeFloatByTab`、查找助手 `floatWithTab` / `floatById`。
**既有查找路径全部扩展到 floats**(一致性关键):`tabOpenIn`、`patchTab`(updateTab,浏览器浮窗导航持久化 URL)、`openTabInActivePane` 的 id 安全网(命中 → raise)、`reconcileAgentTerminals`(浮窗 agent 终端随宿主列表消失则整窗移除)、`maxCounterId`(`float:N` 前缀参与 uid 播种,防 reload 碰撞)。
**sanitize**:`floats` 缺省 → `[]`;逐条宽容校验(id/tab 复用与叶子共享的 `sanitizePersistedTab`;几何非有限数或重复 id → 丢弃该条,不动整体布局);几何钳入当前视口;diff/ephemeral tab 不持久化;`explorer` 旧类型在浮窗内同样迁移为 editor home。
## 4. 实现要点
- **拖出检测**(`Sidebar.tsx`,document 捕获层):`dragover`/`drop`/`dragend` 捕获监听,以 `body[data-dsh-tab-dragging']`(TabBar 维护的 tab 拖拽标记)门控;目标在 `[data-dsh-panel-host]` 内则无视(pane 的 split/merge 自治);在 conversation 列(`#root [data-slot="conversation"]` 父列实时 `getBoundingClientRect()`)内 `preventDefault()`(武装 drop)+ 显示提示浮层(同值 rect 跳过 setState,避免 60Hz 重渲染);`drop` 读 `application/x-dsh-tab` payload 调 `floatTab(tabId, clientX, clientY)`。
- **FreeWindow**(新文件 `FreeWindow.tsx`):头部/SE 角 pointer capture 拖拽——照抄面板拖拽的「rAF 直写 `style.left/top/width/height` + 松手提交 store + pointercancel/lostpointercapture 取 last-applied 兜底」模式;拖动中每帧对 `[data-dsh-pane]` 矩形命中检测,命中 pane 直接 DOM 打 `data-dsh-float-dock-over` 高亮(不经 React state,避免拖拽期全树重渲染);松手命中 → `dockFloat(paneId)`,未命中 → `moveFloat`。jsdom 无 pointer capture API,取 `setPointerCapture?.()` 容忍模式(与 `EditorHost.tsx` 同款)。
- **服务层**(`service.ts`):`closeTab`/`activateTab` 的浮窗分支;`openTab` 的 dedupeKey/id 匹配与生命周期分类纳入 floats——**聚焦浮动 tab = 置顶窗口**,且内容型打开不触发面板自动展开(浮窗与面板开合无关,恒可见);`features` 追加 `'floatWindows'`。
- **渲染**:浮窗渲染在 `[data-dsh-panel-host]` 内(视口坐标、免疫壳层 transform 劫持),z-index 42(面板 40 之上、开关簇 45 与 DSH 浮层之下);内容复用 `renderTab`/`TabContent`(每 tab RenderBoundary、memo),`visible` 恒 true。
- **样式**(`sidebar.module.css`):全令牌驱动(`--dsw-alias-bg-layer-1` 表面、`--dsw-alias-border-l2` 边、`--dsw-shadow-lv3` 阴影、accent 虚线提示);窗口盒无 transition(拖拽逐帧直写);头部 `-webkit-app-region: no-drag`。
- **i18n**:`moveToFreeWindow` / `floatDropHint` / `dockToSidebar` 三键,19 语言词典全量同步(zh/en/ja 一致性由 `tests/locales.spec.ts` 守护)。
## 5. 测试
- `tests/state.spec.ts`(free windows 块):reducer 语义(移动/清空折叠/钳制/幂等/停靠回退/未知 id 严格 no-op)、查找路径扩展、agent 终端浮窗移除、sanitize(缺省/逐条丢弃/几何钳制/uid 播种/round-trip)。
- `tests/service.spec.ts`(free windows 块):dedupe 聚焦浮窗 = 置顶不展开面板、closeTab 关窗 + onClose、activateTab 置顶 + onActivate。
- `tests/free-window.spec.tsx`(新,jsdom 全 Sidebar 挂载):拖出检测(提示出现/消失、drop 落位、无武装 drop 不浮动)、右键菜单浮动落点、头部拖动(rAF 直写 + 提交)、SE 缩放、拖到 pane 停靠(高亮断言)、X 关闭、reload 恢复。
- `tests/e2e/float-window.e2e.ts`(新,Playwright 真实 `dsh web`):右键浮动 → 拖动移动 → reload 恢复 → 右键停靠回归;HTML5 拖出(提示浮层中拖断言)→ 终端浮窗 + 标签栏移除;全程无 crash 标记。
## 6. 已知取舍
- 浮窗头部同时承担「移动」与「拖回停靠」——统一为一种 pointer 手势(落点命中 pane 即停靠,否则移动),未用 HTML5 DnD(浏览器接管后 pointer 流中断,与顺滑移动冲突)。
- 停靠只支持 center 合并:从浮窗拖回不提供 edge split(KISS;pane 内拖拽仍支持全部分区操作)。
- 浮窗内 tab 被 `openTab` 再次打开时聚焦的是**浮窗**(置顶),不会把 tab 拉回 pane——窗口就是 tab 的「pane」。
- 窄视口下拖出手势禁用,但右键菜单入口保留(窗口钳入小视口仍可用)。
## 7. 实施偏差记录
- **portal 事件劫持拖拽(e2e 发现,jsdom 不复现)**:浮窗头部内嵌 `Menu`(portal 渲染到 `document.body`),但其 React 合成事件仍沿 React 树冒泡进头部的 `onPointerDown`——右键菜单行的**左键点击**会被误判为头部拖拽:`preventDefault` 吞掉行的兼容 mousedown,`setPointerCapture` 把后续 pointer 事件重定向到头部,行的 click 永不触发(「回到侧边栏」与 X 关闭在真实浏览器中双双失效)。修复:头部/缩放把手的 `onPointerDown` 加**同源守卫**——`event.currentTarget.contains(event.target)` 为假(portal 后代)或目标在按钮内(X)时直接返回。回归测试:`tests/free-window.spec.tsx`「a pointerdown on the portaled header menu must not start a header drag」。教训:任何承载 pointer 拖拽的表面,若其 React 子树里有 portal 覆盖层(菜单/弹窗),必须做 DOM 包含守卫——jsdom 合成事件不走这条路径,只有真实浏览器 e2e 能抓到。
- **e2e tarball 选择修复**(`scripts/e2e-mount.sh`):缺省 tarball 解析原为 `ls | head -1`(字典序),仓库根残留旧版本号 tarball 时会把冒烟挂到**过期产物**上(本次 0.15.0 劫持了 0.16.0 的 lane,菜单项整体消失)。改为 `ls -t` 取 mtime 最新并在多候选时告警。
- e2e 细节修正:conversation 列须取 `[data-slot="conversation"]` 的**父列**(slot 包裹器零尺寸,`boundingBox()` 返回 null);reload 后需重跑 DSH 首启 takeover 清场(takeover 在 settings join 后才挂载,须先有界等待再清);提示浮层断言按 2 计(`[class*="floatDropHint"]` 子串命中浮层与其文案 label 两个节点);拖拽后 reload 前留 500ms 让 store 的 200ms 防抖持久化落盘。
- 其余与本文一致。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"id": "dsh-external/dsh-better-sidebar",
"version": "0.15.2",
"version": "0.16.0",
"main": "./lib/index.js",
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
"engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dsh-better-sidebar",
"version": "0.15.2",
"version": "0.16.0",
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.",
"type": "module",
"repository": {
+5 -2
View File
@@ -51,9 +51,12 @@ if ! command -v "$DSH_CMD" >/dev/null 2>&1; then
fi
fi
# tarball 解析
# tarball 解析(多个候选时取 mtime 最新——`ls | head -1` 的字典序会拿到
# 旧版本号的历史 tarball,把冒烟挂到过期产物上)
if [ -z "$TARBALL" ]; then
TARBALL="$(ls "$ROOT"/dsh-better-sidebar-*.tgz 2>/dev/null | head -1 || true)"
TARBALL="$(ls -t "$ROOT"/dsh-better-sidebar-*.tgz 2>/dev/null | head -1 || true)"
COUNT="$(ls "$ROOT"/dsh-better-sidebar-*.tgz 2>/dev/null | wc -l | tr -d ' ')"
[ "$COUNT" -le 1 ] || warn "发现 $COUNT 个 tarball,按 mtime 选用最新:$(basename "$TARBALL")(建议清理其余)"
fi
[ -n "$TARBALL" ] && [ -f "$TARBALL" ] || die "找不到 tarball(TARBALL 或 \$ROOT/dsh-better-sidebar-*.tgz)——先运行 pnpm build && pnpm pack"
TARBALL="$(cd "$(dirname "$TARBALL")" && pwd)/$(basename "$TARBALL")"
+337
View File
@@ -0,0 +1,337 @@
/**
* One free window: a tab dragged out of the workbench floating over the
* conversation area at viewport coordinates (rendered inside the panel host,
* so desktop-shell transforms can never hijack its fixed containing block).
*
* The header drags the window with the panel-resize pattern — pointer
* capture + per-frame direct DOM writes + a store commit on release — and
* doubling as the DOCK-BACK gesture: while the pointer is over a workbench
* pane ([data-dsh-pane], either panel), that pane highlights live and
* releasing docks the tab into it (center merge); releasing anywhere else
* just moves the window. The SE corner resizes, any press raises (the
* floats array's order is the stacking order), the header right-click menu
* and the X button dock / close. The tab content reuses the regular tab
* renderer, so every tab type (terminal, editor, plugin tabs) floats
* unchanged.
*/
import { useEffect, useRef, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import { IconCloseFill14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
import type { FloatWindow, SidebarTab } from './state.ts'
import { FLOAT_MIN_H, FLOAT_MIN_W } from './state.ts'
import { t } from './locales.ts'
import css from './sidebar.module.css'
/** A drag's mutable bookkeeping (start geometry + the last applied values). */
interface DragState {
/** Which handle owns the drag (the abort path reads it from this ref —
* the React `dragging` state lags a render behind pointerdown). */
mode: 'move' | 'resize'
pointerX: number
pointerY: number
startX: number
startY: number
startW: number
startH: number
/** Last geometry actually written to the DOM (the cancel-path fallback). */
applied: { x: number; y: number; w: number; h: number }
/** Settled after the final pointer event (the up position wins over the
* rAF-pending value — a fast flick coalesces its tail into the up event). */
committed: boolean
}
/** The pane under a viewport point, if any (rect hit-test; the dragged
* window itself is not a pane, so it cannot shadow the targets). */
function paneAt(x: number, y: number): HTMLElement | null {
for (const pane of document.querySelectorAll<HTMLElement>('[data-dsh-pane]')) {
const rect = pane.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) continue
if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) return pane
}
return null
}
/** Pointer-capture helpers tolerant of environments without the API (jsdom
* lacks setPointerCapture — component tests dispatch plain MouseEvents, so
* the optional calls keep them driving the drag; real browsers always have
* it and a missing pointerId can never occur there). */
const capturePointer = (element: HTMLElement, pointerId: number): void => {
element.setPointerCapture?.(pointerId)
}
const releasePointer = (element: HTMLElement, pointerId: number): void => {
element.releasePointerCapture?.(pointerId)
}
/** Whether the element holds the pointer (assumed true without the API). */
const holdsPointer = (element: HTMLElement, pointerId: number): boolean => {
return element.hasPointerCapture?.(pointerId) !== false
}
export function FreeWindow(props: {
float: FloatWindow
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
getTabIcon?: (tab: SidebarTab) => ReactNode
onRaise: () => void
onMove: (x: number, y: number) => void
onResize: (w: number, h: number) => void
/** Dock the tab back; the pane id, or null for the active pane. */
onDock: (paneId: string | null) => void
onClose: () => void
}) {
const { float, renderTab, getTabIcon, onRaise, onMove, onResize, onDock, onClose } = props
const rootRef = useRef<HTMLDivElement>(null)
const dragRef = useRef<DragState | null>(null)
// The pane currently highlighted as the dock target (a direct DOM write:
// per-frame React state would re-render every mounted tab at drag cadence).
const dockTargetRef = useRef<HTMLElement | null>(null)
// rAF coalescing: pointermove outruns the display, one write per frame.
const frameRef = useRef<number | null>(null)
const pendingRef = useRef<{ x: number; y: number; w: number; h: number } | null>(null)
const [dragging, setDragging] = useState<'move' | 'resize' | null>(null)
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null)
useEffect(() => () => {
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current)
dockTargetRef.current?.removeAttribute('data-dsh-float-dock-over')
}, [])
/** Apply the pending geometry to the DOM (one rAF per frame at most). */
const scheduleApply = (geo: { x: number; y: number; w: number; h: number }): void => {
pendingRef.current = geo
if (frameRef.current !== null) return
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null
const pending = pendingRef.current
const drag = dragRef.current
const root = rootRef.current
if (pending === null || drag === null || root === null) return
drag.applied = pending
root.style.left = `${pending.x}px`
root.style.top = `${pending.y}px`
root.style.width = `${pending.w}px`
root.style.height = `${pending.h}px`
})
}
/** Flush the pending frame synchronously (the release path's last write). */
const flushNow = (): void => {
const pending = pendingRef.current
const drag = dragRef.current
const root = rootRef.current
if (pending === null || drag === null || root === null) return
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current)
frameRef.current = null
}
pendingRef.current = null
drag.applied = pending
root.style.left = `${pending.x}px`
root.style.top = `${pending.y}px`
root.style.width = `${pending.w}px`
root.style.height = `${pending.h}px`
}
const clearDockHighlight = (): void => {
dockTargetRef.current?.removeAttribute('data-dsh-float-dock-over')
dockTargetRef.current = null
}
/** Release a drag: `pane` (when set) docks instead of moving. */
const finishDrag = (mode: 'move' | 'resize', geo: { x: number; y: number; w: number; h: number }, pane: HTMLElement | null): void => {
const drag = dragRef.current
if (drag === null || drag.committed) return
drag.committed = true
dragRef.current = null
setDragging(null)
const target = pane ?? dockTargetRef.current
clearDockHighlight()
if (mode === 'move' && target !== null) {
onDock(target.getAttribute('data-dsh-pane'))
} else if (mode === 'move') {
onMove(geo.x, geo.y)
} else {
onResize(geo.w, geo.h)
}
}
/** Cancel-path settle (pointercancel / lostpointercapture): the last
* APPLIED geometry is the user-visible truth — commit it, never roll back
* (the panel drags' issue-#247 semantics). The mode comes from the ref —
* the React `dragging` state can still be null when the cancel lands
* before the pointerdown re-render commits. */
const abortDrag = (): void => {
const drag = dragRef.current
if (drag === null || drag.committed) return
flushNow()
finishDrag(drag.mode, drag.applied, null)
}
const clampMove = (x: number, y: number): { x: number; y: number; w: number; h: number } => {
const vw = window.innerWidth
const vh = window.innerHeight
return {
x: Math.min(Math.max(x, 0), Math.max(0, vw - float.w)),
y: Math.min(Math.max(y, 0), Math.max(0, vh - float.h)),
w: float.w,
h: float.h,
}
}
const clampResize = (w: number, h: number): { x: number; y: number; w: number; h: number } => {
const vw = window.innerWidth
const vh = window.innerHeight
return {
x: float.x,
y: float.y,
w: Math.round(Math.min(Math.max(w, FLOAT_MIN_W), Math.max(FLOAT_MIN_W, vw - float.x))),
h: Math.round(Math.min(Math.max(h, FLOAT_MIN_H), Math.max(FLOAT_MIN_H, vh - float.y))),
}
}
return (
<div
ref={rootRef}
className={clsx(css.floatWindow, dragging !== null && css.floatWindowDragging)}
data-dsh-float-window
data-dsh-float-id={float.id}
style={{ left: float.x, top: float.y, width: float.w, height: float.h }}
onPointerDown={() => { onRaise() }}
>
<div
className={css.floatHeader}
onPointerDown={(event) => {
if (event.button !== 0) return
// Portaled overlays (the header Menu) live at document.body in the
// DOM, yet their events still bubble through the React tree into
// this handler — without the containment guard a menu-row click
// would start a drag, preventDefault (killing the row's compat
// mousedown) and capture the pointer (retargeting the release at
// the header, so the row's click never fires). Buttons inside the
// header (the close X) need the same exclusion: a capture here
// swallows their click.
if (!(event.target instanceof Node) || !event.currentTarget.contains(event.target)) return
if (event.target instanceof Element && event.target.closest('button') !== null) return
event.preventDefault()
capturePointer(event.currentTarget, event.pointerId)
dragRef.current = {
mode: 'move',
pointerX: event.clientX,
pointerY: event.clientY,
startX: float.x,
startY: float.y,
startW: float.w,
startH: float.h,
applied: { x: float.x, y: float.y, w: float.w, h: float.h },
committed: false,
}
setDragging('move')
}}
onPointerMove={(event) => {
const drag = dragRef.current
if (drag === null || !holdsPointer(event.currentTarget, event.pointerId)) return
const geo = clampMove(drag.startX + (event.clientX - drag.pointerX), drag.startY + (event.clientY - drag.pointerY))
scheduleApply(geo)
const target = paneAt(event.clientX, event.clientY)
if (target !== dockTargetRef.current) {
clearDockHighlight()
if (target !== null) target.setAttribute('data-dsh-float-dock-over', '')
dockTargetRef.current = target
}
}}
onPointerUp={(event) => {
if (dragRef.current === null || !holdsPointer(event.currentTarget, event.pointerId)) return
releasePointer(event.currentTarget, event.pointerId)
flushNow()
const geo = clampMove(
dragRef.current.startX + (event.clientX - dragRef.current.pointerX),
dragRef.current.startY + (event.clientY - dragRef.current.pointerY),
)
finishDrag('move', geo, paneAt(event.clientX, event.clientY))
}}
onPointerCancel={() => { abortDrag() }}
onLostPointerCapture={() => { abortDrag() }}
onContextMenu={(event) => {
event.preventDefault()
setMenu({ x: event.clientX, y: event.clientY })
}}
>
{getTabIcon?.(float.tab) ?? null}
<span className={css.floatTitle} title={float.tab.title}>{float.tab.title}</span>
<button
type="button"
className={css.floatClose}
aria-label={t('close')}
onClick={(event) => { event.stopPropagation(); onClose() }}
>
<IconCloseFill14 />
</button>
<Menu
open={menu !== null}
onClose={() => { setMenu(null) }}
items={[
{ id: 'dock', label: t('dockToSidebar') },
{ id: 'close', label: t('close') },
]}
onSelect={(id) => {
setMenu(null)
if (id === 'dock') onDock(null)
else if (id === 'close') onClose()
}}
portal
align="start"
getAnchorRect={() => (menu === null ? null : new DOMRect(menu.x, menu.y, 0, 0))}
anchor={<span />}
/>
</div>
{/* The tab cell reuses .paneTab (the pane's per-tab wrapper): the
column-flex chain (window → .floatContent → .paneTab) is what
gives the component a definite width via align-stretch — the exact
sizing contract a tab gets inside a pane. */}
<div className={css.floatContent}>
<div className={css.paneTab}>{renderTab(float.tab, true, float.id)}</div>
</div>
<div
className={css.floatResize}
onPointerDown={(event) => {
if (event.button !== 0) return
if (!(event.target instanceof Node) || !event.currentTarget.contains(event.target)) return
event.preventDefault()
capturePointer(event.currentTarget, event.pointerId)
dragRef.current = {
mode: 'resize',
pointerX: event.clientX,
pointerY: event.clientY,
startX: float.x,
startY: float.y,
startW: float.w,
startH: float.h,
applied: { x: float.x, y: float.y, w: float.w, h: float.h },
committed: false,
}
setDragging('resize')
}}
onPointerMove={(event) => {
const drag = dragRef.current
if (drag === null || !holdsPointer(event.currentTarget, event.pointerId)) return
scheduleApply(clampResize(
drag.startW + (event.clientX - drag.pointerX),
drag.startH + (event.clientY - drag.pointerY),
))
}}
onPointerUp={(event) => {
if (dragRef.current === null || !holdsPointer(event.currentTarget, event.pointerId)) return
releasePointer(event.currentTarget, event.pointerId)
flushNow()
const geo = clampResize(
dragRef.current.startW + (event.clientX - dragRef.current.pointerX),
dragRef.current.startH + (event.clientY - dragRef.current.pointerY),
)
finishDrag('resize', geo, null)
}}
onPointerCancel={() => { abortDrag() }}
onLostPointerCapture={() => { abortDrag() }}
/>
</div>
)
}
+146 -9
View File
@@ -28,16 +28,16 @@
* drawer floats). Widening does not migrate back: the tabs keep living in
* the right tree.
*/
import { createElement, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type ReactNode } from 'react'
import { createElement, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type ReactNode } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context, SidebarSessionList } from '../context-types.ts'
import { appendToDraft } from './conversation-draft.ts'
import {
BOTTOM_MIN, PANEL_MIN, agentUuidOf, firstLeaf, isAgentTabId, leafWithTab, migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab,
reconcileAgentTerminals,
resizeSplitIn, setBottomHeight, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
BOTTOM_MIN, PANEL_MIN, agentUuidOf, dockFloat, firstLeaf, floatTab, isAgentTabId, leafWithTab, migrateBottomTabs,
moveFloat, moveTab, moveTabToEdge, openDiffTab, raiseFloat, reconcileAgentTerminals,
resizeFloat, resizeSplitIn, setBottomHeight, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
} from './state.ts'
import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
@@ -49,7 +49,8 @@ import { getWcoSnapshot, subscribeWco } from './wco.ts'
import { getShellPreset } from './shell-presets.ts'
import { computeTitleBarStrip } from './titlebar-strip.ts'
import type { NewTabOption } from './TabBar.tsx'
import type { TabDragPayload } from './TabBar.tsx'
import { TAB_DRAG_TYPE, parseDrag, type TabDragPayload } from './TabBar.tsx'
import { FreeWindow } from './FreeWindow.tsx'
import { relativeTo } from './paths.ts'
import { OrphanedTab } from './OrphanedTab.tsx'
import { RenderBoundary } from './RenderBoundary.tsx'
@@ -87,7 +88,7 @@ const AUTO_OPEN_DEBOUNCE_MS = 500
* gated on the 'Files' type so in-app drags (tab reorder, split zones)
* propagate exactly as before.
*/
const swallowOsFileDrag = (event: DragEvent): void => {
const swallowOsFileDrag = (event: ReactDragEvent): void => {
if (!(event.dataTransfer?.types.includes('Files') ?? false)) return
event.preventDefault()
event.stopPropagation()
@@ -699,6 +700,85 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// above, this is the belt-and-braces retry for the open moment itself).
}, [measureCenter, state?.bottomOpen])
/**
* Free windows — drag-out detection. The tab strips already drive HTML5
* DnD (payload application/x-dsh-tab) with drops owned by the panes
* (split/merge); this shell watches the DOCUMENT (capture) for the same
* drag hovering OUTSIDE the panel host: while the pointer is over the
* conversation column it arms the drop (preventDefault) and shows a hint
* overlay there, and the drop floats the tab at the release point. Targets
* inside the host are ignored here, so pane drops keep their behavior
* untouched. Only OUR tab drags count (the body flag is the tab strip's;
* OS file drags and any DSH drags pass through). Narrow viewports skip
* the gesture — the merged drawer covers the conversation, leaving
* nothing to drop onto (the tab context menu entry still floats tabs).
*/
const [floatHint, setFloatHint] = useState<{ left: number; top: number; width: number; height: number } | null>(null)
const floatHintRef = useRef(false)
useEffect(() => {
if (narrow || sessionId === undefined) return
const inPanelHost = (target: EventTarget | null): boolean =>
target instanceof Element && target.closest('[data-dsh-panel-host]') !== null
/** The conversation column's rect when the pointer is over it (and not
* over our own surfaces); null otherwise. */
const overConversation = (event: DragEvent): DOMRect | null => {
if (inPanelHost(event.target)) return null
const col = centerColRef.current
if (col === null || !col.isConnected) return null
const rect = col.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) return null
const { clientX: x, clientY: y } = event
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return null
return rect
}
const onDragOver = (event: DragEvent): void => {
if (!document.body.hasAttribute('data-dsh-tab-dragging')) return
const rect = overConversation(event)
if (rect !== null) {
// preventDefault on dragover is what makes the browser deliver the
// drop (and drop the "no" cursor) over the conversation area.
event.preventDefault()
setFloatHint((prev) => {
const next = { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
if (prev !== null && prev.left === next.left && prev.top === next.top
&& prev.width === next.width && prev.height === next.height) return prev
return next
})
floatHintRef.current = true
} else if (floatHintRef.current) {
floatHintRef.current = false
setFloatHint(null)
}
}
const onDrop = (event: DragEvent): void => {
if (!floatHintRef.current) return
floatHintRef.current = false
setFloatHint(null)
const rect = overConversation(event)
if (rect === null) return
event.preventDefault()
event.stopPropagation()
const payload = parseDrag(event.dataTransfer?.getData(TAB_DRAG_TYPE) ?? '')
if (payload === null) return
store.reduce(s => floatTab(s, payload.tabId, event.clientX, event.clientY))
}
const clear = (): void => {
if (!floatHintRef.current) return
floatHintRef.current = false
setFloatHint(null)
}
document.addEventListener('dragover', onDragOver, true)
document.addEventListener('drop', onDrop, true)
window.addEventListener('dragend', clear, true)
window.addEventListener('blur', clear)
return () => {
document.removeEventListener('dragover', onDragOver, true)
document.removeEventListener('drop', onDrop, true)
window.removeEventListener('dragend', clear, true)
window.removeEventListener('blur', clear)
}
}, [narrow, sessionId, store])
/**
* Bottom-panel first-expansion auto terminal: the FIRST time the user
* expands the bottom panel in a session, try to open a fresh terminal tab
@@ -1040,6 +1120,17 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
resizeSplit: (splitId, index, deltaFrac) => {
store.reduce(s => resizeSplitIn(s, splitId, index, deltaFrac))
},
// The tab context menu's "move to free window": no drop point exists, so
// the window is born over the conversation column's center (the user's
// focus area; clamped into the viewport by the reducer) — the same
// landing the drag-out gesture produces.
floatTab: (tabId) => {
const col = centerColRef.current
const rect = col !== null && col.isConnected ? col.getBoundingClientRect() : null
const x = rect !== null ? (rect.left + rect.right) / 2 : window.innerWidth / 2
const y = rect !== null ? (rect.top + rect.bottom) / 2 : window.innerHeight / 2
store.reduce(s => floatTab(s, tabId, x, y))
},
}), [store, sessionId, cwd])
/**
@@ -1141,7 +1232,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
* polling while the page is not actually visible). The pane id travels
* with the tab so diff tabs can split below their source pane.
*/
const renderTab = (tab: SidebarTab, active: boolean, paneId: string, bottom = false) => (
// `placement` decides the visibility contract handed to the tab component:
// pane tabs are visible while their panel is open and they are active, but
// a free window is its own surface — its tab stays visible no matter what
// the panels do (the AGENTS §7.5 contract; plugin components honor
// `visible` to pause work, so tying floats to panelOpen would blank them
// the moment the sidebar collapses).
const renderTab = (tab: SidebarTab, active: boolean, paneId: string, placement: 'top' | 'bottom' | 'float' = 'top') => (
<TabContent
tab={tab}
paneId={paneId}
@@ -1152,7 +1249,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
onReferenceFile={referenceInChat}
ctx={ctx}
store={store}
visible={bottom ? state.bottomOpen && active : state.panelOpen && active}
visible={
placement === 'float'
? true
: placement === 'bottom'
? state.bottomOpen && active
: state.panelOpen && active
}
onSubagentJump={(childSessionId) => { subagentJumpRef.current = childSessionId }}
onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }}
localeRevision={localeRevision}
@@ -1419,13 +1522,47 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
actions={actions}
onNewTab={onNewTab}
renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, true)}
renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, 'bottom')}
getTabIcon={tabIconOf}
getTabBadge={tabBadgeOf}
/>
</div>
</div>
)}
{/*
Free windows: tabs dragged out onto the conversation area (or floated
from the tab context menu). They live in the panel host like the
panels (viewport coordinates, immune to desktop-shell transforms) but
are independent of panel state — a window stays up while panels
collapse. The floats array's order is the stacking order; the content
reuses the regular tab renderer, so every tab type floats unchanged.
*/}
{state.floats.map(float => (
<FreeWindow
key={float.id}
float={float}
renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, 'float')}
getTabIcon={tabIconOf}
onRaise={() => { store.reduce(s => raiseFloat(s, float.id)) }}
onMove={(x, y) => { store.reduce(s => moveFloat(s, float.id, x, y)) }}
onResize={(w, h) => { store.reduce(s => resizeFloat(s, float.id, w, h)) }}
onDock={(paneId) => { store.reduce(s => dockFloat(s, float.id, paneId ?? undefined)) }}
onClose={() => { ctx.betterSidebar?.closeTab(float.tab.id, sessionId === undefined ? undefined : { sessionId, cwd }) }}
/>
))}
{/*
The drag-out hint: while a tab drag hovers the conversation column,
a dashed overlay marks the drop zone there (pointer-transparent — it
must not disturb the drag it describes).
*/}
{floatHint !== null && (
<div
className={css.floatDropHint}
style={{ left: floatHint.left, top: floatHint.top, width: floatHint.width, height: floatHint.height }}
>
<span className={css.floatDropHintLabel}>{t('floatDropHint')}</span>
</div>
)}
</div>
)
}
+11 -4
View File
@@ -4,8 +4,9 @@
* button cluster, and the + menu that opens new tabs (explorer / git /
* terminal). Tabs are draggable; dropping onto another tab inserts before it,
* dropping on the strip background appends to this pane. Right-clicking a
* tab opens the tab context menu (close / close others / close to the left /
* close to the right, all scoped to this pane).
* tab opens the tab context menu (float as a free window / close / close
* others / close to the left / close to the right, the close ones scoped to
* this pane).
*/
import { useEffect, useRef, useState, type ReactNode } from 'react'
import clsx from 'clsx'
@@ -63,6 +64,9 @@ export function TabBar(props: {
newTabOptions: NewTabOption[]
/** Drop of a tab from any pane: (payload, insertBeforeTabId | null). */
onDropTab: (payload: TabDragPayload, before: string | null) => void
/** Float a tab out as a free window (the tab context menu's entry; the
* drag-to-conversation gesture is handled at the Sidebar shell level). */
onFloatTab: (tabId: string) => void
/** Icon resolver for tab labels (reads from the tab descriptor registry). */
getTabIcon?: (tab: SidebarTab) => ReactNode
/** Badge resolver for tab labels (reads the descriptor's `badge`; the
@@ -70,7 +74,7 @@ export function TabBar(props: {
getTabBadge?: (tab: SidebarTab) => ReactNode
}) {
const {
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon, getTabBadge,
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, onFloatTab, getTabIcon, getTabBadge,
} = props
const [menuOpen, setMenuOpen] = useState(false)
// The tab right-click context menu: the target tab plus the cursor
@@ -271,6 +275,7 @@ export function TabBar(props: {
open={tabMenu !== null && tabMenuIndex >= 0}
onClose={() => { setTabMenu(null) }}
items={[
{ id: 'float', label: t('moveToFreeWindow') },
{ id: 'close', label: t('close') },
{ id: 'closeOthers', label: t('closeOtherTabs'), ...(tabs.length <= 1 ? { disabled: true } : {}) },
{ id: 'closeLeft', label: t('closeLeftTabs'), ...(tabMenuIndex <= 0 ? { disabled: true } : {}) },
@@ -282,7 +287,9 @@ export function TabBar(props: {
setTabMenu(null)
const index = tabs.findIndex(tab => tab.id === target.tabId)
if (index < 0) return
if (id === 'close') {
if (id === 'float') {
onFloatTab(target.tabId)
} else if (id === 'close') {
onClose(target.tabId)
} else if (id === 'closeOthers') {
for (const tab of tabs) {
+3
View File
@@ -66,6 +66,9 @@ export const ar: Record<string, string> = {
closeOtherTabs: 'إغلاق التبويبات الأخرى',
closeLeftTabs: 'إغلاق التبويبات على اليسار',
closeRightTabs: 'إغلاق التبويبات على اليمين',
moveToFreeWindow: 'النقل إلى نافذة حرة',
floatDropHint: 'حرّك للفتح في نافذة حرة',
dockToSidebar: 'العودة إلى الشريط الجانبي',
collapse: 'طي الشريط الجانبي',
expand: 'توسيع الشريط الجانبي',
collapseBottomPanel: 'طي اللوحة السفلية',
+3
View File
@@ -51,6 +51,9 @@ export const de: Record<string, string> = {
closeOtherTabs: 'Andere Tabs schließen',
closeLeftTabs: 'Tabs links schließen',
closeRightTabs: 'Tabs rechts schließen',
moveToFreeWindow: 'In freies Fenster verschieben',
floatDropHint: 'Loslassen, um in einem freien Fenster zu öffnen',
dockToSidebar: 'Zurück zur Seitenleiste',
collapse: 'Seitenleiste einklappen',
expand: 'Seitenleiste ausklappen',
collapseBottomPanel: 'Unteres Panel einklappen',
+3
View File
@@ -58,6 +58,9 @@ export const fr: Record<string, string> = {
closeOtherTabs: 'Fermer les autres onglets',
closeLeftTabs: 'Fermer les onglets à gauche',
closeRightTabs: 'Fermer les onglets à droite',
moveToFreeWindow: 'Déplacer vers la fenêtre flottante',
floatDropHint: 'Relâchez pour ouvrir dans une fenêtre flottante',
dockToSidebar: 'Revenir à la barre latérale',
collapse: 'Réduire la barre latérale',
expand: 'Déployer la barre latérale',
collapseBottomPanel: 'Réduire le panneau inférieur',
+3
View File
@@ -65,6 +65,9 @@ export const hi: Record<string, string> = {
closeOtherTabs: 'अन्य टैब बंद करें',
closeLeftTabs: 'बाएँ टैब बंद करें',
closeRightTabs: 'दाएँ टैब बंद करें',
moveToFreeWindow: 'फ़्री विंडो में ले जाएँ',
floatDropHint: 'फ़्री विंडो में खोलने के लिए छोड़ें',
dockToSidebar: 'साइडबार पर वापस जाएँ',
collapse: 'साइडबार संक्षिप्त करें',
expand: 'साइडबार विस्तृत करें',
collapseBottomPanel: 'निचला पैनल संक्षिप्त करें',
+3
View File
@@ -63,6 +63,9 @@ export const id: Record<string, string> = {
closeOtherTabs: 'Tutup Tab Lain',
closeLeftTabs: 'Tutup Tab di Kiri',
closeRightTabs: 'Tutup Tab di Kanan',
moveToFreeWindow: 'Pindahkan ke jendela bebas',
floatDropHint: 'Lepaskan untuk membuka di jendela bebas',
dockToSidebar: 'Kembali ke bilah samping',
collapse: 'Lipat sidebar',
expand: 'Bentangkan sidebar',
collapseBottomPanel: 'Lipat panel bawah',
+3
View File
@@ -56,6 +56,9 @@ export const it: Record<string, string> = {
closeOtherTabs: 'Chiudi le altre schede',
closeLeftTabs: 'Chiudi le schede a sinistra',
closeRightTabs: 'Chiudi le schede a destra',
moveToFreeWindow: 'Sposta in finestra libera',
floatDropHint: 'Rilascia per aprire in una finestra libera',
dockToSidebar: 'Torna alla barra laterale',
collapse: 'Comprimi barra laterale',
expand: 'Espandi barra laterale',
collapseBottomPanel: 'Comprimi pannello inferiore',
+3
View File
@@ -65,6 +65,9 @@ export const ja: Record<string, string> = {
closeOtherTabs: '他のタブを閉じる',
closeLeftTabs: '左のタブを閉じる',
closeRightTabs: '右のタブを閉じる',
moveToFreeWindow: 'フリーウィンドウへ移動',
floatDropHint: '離すとフリーウィンドウで開きます',
dockToSidebar: 'サイドバーへ戻す',
collapse: 'サイドバーを折りたたむ',
expand: 'サイドバーを展開',
collapseBottomPanel: '下パネルを折りたたむ',
+3
View File
@@ -57,6 +57,9 @@ export const ko: Record<string, string> = {
closeOtherTabs: '다른 탭 닫기',
closeLeftTabs: '왼쪽 탭 닫기',
closeRightTabs: '오른쪽 탭 닫기',
moveToFreeWindow: '자유 창으로 이동',
floatDropHint: '놓으면 자유 창에서 열립니다',
dockToSidebar: '사이드바로 돌아가기',
collapse: '사이드바 접기',
expand: '사이드바 펼치기',
collapseBottomPanel: '하단 패널 접기',
+3
View File
@@ -63,6 +63,9 @@ export const nl: Record<string, string> = {
closeOtherTabs: 'Andere tabbladen sluiten',
closeLeftTabs: 'Tabbladen links sluiten',
closeRightTabs: 'Tabbladen rechts sluiten',
moveToFreeWindow: 'Naar vrij venster verplaatsen',
floatDropHint: 'Loslaten om in een vrij venster te openen',
dockToSidebar: 'Terug naar de zijbalk',
collapse: 'Zijbalk inklappen',
expand: 'Zijbalk uitklappen',
collapseBottomPanel: 'Onderpaneel inklappen',
+3
View File
@@ -67,6 +67,9 @@ export const pl: Record<string, string> = {
closeOtherTabs: 'Zamknij inne karty',
closeLeftTabs: 'Zamknij karty po lewej',
closeRightTabs: 'Zamknij karty po prawej',
moveToFreeWindow: 'Przenieś do wolnego okna',
floatDropHint: 'Puść, aby otworzyć w wolnym oknie',
dockToSidebar: 'Wróć do panelu bocznego',
collapse: 'Zwiń panel boczny',
expand: 'Rozwiń panel boczny',
collapseBottomPanel: 'Zwiń panel dolny',
+3
View File
@@ -48,6 +48,9 @@ export const pt: Record<string, string> = {
closeOtherTabs: 'Fechar outras abas',
closeLeftTabs: 'Fechar abas à esquerda',
closeRightTabs: 'Fechar abas à direita',
moveToFreeWindow: 'Mover para janela flutuante',
floatDropHint: 'Solte para abrir em uma janela flutuante',
dockToSidebar: 'Voltar para a barra lateral',
collapse: 'Recolher barra lateral',
expand: 'Expandir barra lateral',
collapseBottomPanel: 'Recolher painel inferior',
+3
View File
@@ -61,6 +61,9 @@ export const ru: Record<string, string> = {
closeOtherTabs: 'Закрыть остальные вкладки',
closeLeftTabs: 'Закрыть вкладки слева',
closeRightTabs: 'Закрыть вкладки справа',
moveToFreeWindow: 'Переместить в свободное окно',
floatDropHint: 'Отпустите, чтобы открыть в свободном окне',
dockToSidebar: 'Вернуть на боковую панель',
collapse: 'Свернуть боковую панель',
expand: 'Развернуть боковую панель',
collapseBottomPanel: 'Свернуть нижнюю панель',
+3
View File
@@ -48,6 +48,9 @@ export const sv: Record<string, string> = {
closeOtherTabs: 'Stäng andra flikar',
closeLeftTabs: 'Stäng flikar till vänster',
closeRightTabs: 'Stäng flikar till höger',
moveToFreeWindow: 'Flytta till fritt fönster',
floatDropHint: 'Släpp för att öppna i ett fritt fönster',
dockToSidebar: 'Tillbaka till sidopanelen',
collapse: 'Komprimera sidopanel',
expand: 'Expandera sidopanel',
collapseBottomPanel: 'Komprimera bottenpanel',
+3
View File
@@ -65,6 +65,9 @@ export const th: Record<string, string> = {
closeOtherTabs: 'ปิด Tab อื่น',
closeLeftTabs: 'ปิด Tab ด้านซ้าย',
closeRightTabs: 'ปิด Tab ด้านขวา',
moveToFreeWindow: 'ย้ายไปยังหน้าต่างอิสระ',
floatDropHint: 'ปล่อยเพื่อเปิดในหน้าต่างอิสระ',
dockToSidebar: 'กลับไปที่แถบข้าง',
collapse: 'ย่อแถบด้านข้าง',
expand: 'ขยายแถบด้านข้าง',
collapseBottomPanel: 'ย่อพาเนลด้านล่าง',
+3
View File
@@ -65,6 +65,9 @@ export const tr: Record<string, string> = {
closeOtherTabs: 'Diğer sekmeleri kapat',
closeLeftTabs: 'Soldaki sekmeleri kapat',
closeRightTabs: 'Sağdaki sekmeleri kapat',
moveToFreeWindow: 'Serbest pencereye taşı',
floatDropHint: 'Serbest pencerede açmak için bırakın',
dockToSidebar: 'Kenar çubuğuna geri dön',
collapse: 'Kenar çubuğunu daralt',
expand: 'Kenar çubuğunu genişlet',
collapseBottomPanel: 'Alt paneli daralt',
+3
View File
@@ -65,6 +65,9 @@ export const vi: Record<string, string> = {
closeOtherTabs: 'Đóng các tab khác',
closeLeftTabs: 'Đóng tab bên trái',
closeRightTabs: 'Đóng tab bên phải',
moveToFreeWindow: 'Chuyển sang cửa sổ tự do',
floatDropHint: 'Thả để mở trong cửa sổ tự do',
dockToSidebar: 'Quay lại thanh bên',
collapse: 'Thu gọn thanh bên',
expand: 'Mở rộng thanh bên',
collapseBottomPanel: 'Thu gọn panel dưới',
+3
View File
@@ -80,6 +80,9 @@ export const zhHK: Record<string, string> = {
closeOtherTabs: '關閉其他標籤',
closeLeftTabs: '關閉左側標籤',
closeRightTabs: '關閉右側標籤',
moveToFreeWindow: '移動到自由視窗',
floatDropHint: '放開以在自由視窗中開啟',
dockToSidebar: '返回側邊欄',
collapse: '收起側邊欄',
expand: '展開側邊欄',
collapseBottomPanel: '收起底部面板',
+3
View File
@@ -80,6 +80,9 @@ export const zhMO: Record<string, string> = {
closeOtherTabs: '關閉其他標籤',
closeLeftTabs: '關閉左側標籤',
closeRightTabs: '關閉右側標籤',
moveToFreeWindow: '移動到自由視窗',
floatDropHint: '放開以在自由視窗中開啟',
dockToSidebar: '返回側邊欄',
collapse: '收起側邊欄',
expand: '展開側邊欄',
collapseBottomPanel: '收起底部面板',
+3
View File
@@ -80,6 +80,9 @@ export const zhTW: Record<string, string> = {
closeOtherTabs: '關閉其他標籤',
closeLeftTabs: '關閉左側標籤',
closeRightTabs: '關閉右側標籤',
moveToFreeWindow: '移動到自由視窗',
floatDropHint: '放開以在自由視窗中開啟',
dockToSidebar: '返回側邊欄',
collapse: '收起側邊欄',
expand: '展開側邊欄',
collapseBottomPanel: '收起底部面板',
+6
View File
@@ -72,6 +72,9 @@ export const zh = {
closeOtherTabs: '关闭其他页签',
closeLeftTabs: '关闭左侧页签',
closeRightTabs: '关闭右侧页签',
moveToFreeWindow: '移动到自由窗口',
floatDropHint: '松开以在自由窗口中打开',
dockToSidebar: '回到侧边栏',
collapse: '折叠侧边栏',
expand: '展开侧边栏',
collapseBottomPanel: '折叠底部面板',
@@ -403,6 +406,9 @@ export const en: Record<keyof typeof zh, string> = {
closeOtherTabs: 'Close Other Tabs',
closeLeftTabs: 'Close Tabs to the Left',
closeRightTabs: 'Close Tabs to the Right',
moveToFreeWindow: 'Move to Free Window',
floatDropHint: 'Release to open in a free window',
dockToSidebar: 'Dock Back to Sidebar',
collapse: 'Collapse sidebar',
expand: 'Expand sidebar',
collapseBottomPanel: 'Collapse bottom panel',
+36 -3
View File
@@ -22,8 +22,8 @@
import type { ReactNode } from 'react'
import type { Context } from '../context-types.ts'
import {
activateTab as activateTabReducer, allLeaves, closeTab as closeTabReducer, leafWithTab,
openTabInActivePane, patchTab, tabOpenIn, togglePanel, treeOf,
activateTab as activateTabReducer, allLeaves, closeTab as closeTabReducer, closeFloatByTab, floatWithTab,
leafWithTab, openTabInActivePane, patchTab, raiseFloat, tabOpenIn, togglePanel, treeOf,
type SidebarSnapshot, type SidebarState, type SidebarStore, type SidebarTab,
} from './state.ts'
import { isNarrowWidth } from './breakpoints.ts'
@@ -470,7 +470,7 @@ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDes
* The plugin version this service instance reports. Keep in lockstep with
* `package.json`'s version — `tests/service.spec.ts` asserts the pair.
*/
export const SIDEBAR_SERVICE_VERSION = '0.15.2'
export const SIDEBAR_SERVICE_VERSION = '0.16.0'
/**
* Monotonic capability list consumers use to gate new API usage (features
@@ -485,6 +485,9 @@ export const SIDEBAR_SERVICE_VERSION = '0.15.2'
* - 'pluginSettings': SidebarSettingsDeclaration.pluginToggles/render
* - 'urlTarget' (v0.13.0): TabDescriptor.urlTarget (external-link claims)
* - 'settingSelect': SidebarSettingToggle type 'select' (options/multi)
* - 'floatWindows' (v0.16.0): tabs float as free windows — openTab's dedupe/
* id focus targets RAISE the floating window (never duplicate the tab or
* expand panels), closeTab on a floating tab closes it with its window.
*/
export const SIDEBAR_FEATURES = [
'badge',
@@ -497,6 +500,7 @@ export const SIDEBAR_FEATURES = [
'pluginSettings',
'urlTarget',
'settingSelect',
'floatWindows',
] as const
/** Run one plugin callback; a throw is logged and never breaks the caller. */
@@ -651,6 +655,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined)
const key = dedupeKey?.(tab)
const inputTabs = allLeaves(state.splits).concat(allLeaves(state.bottomSplits)).flatMap(leaf => leaf.tabs)
.concat(state.floats.map(f => f.tab))
const existedByKey = key !== undefined
&& inputTabs.some(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key)
const existedById = tabOpenIn(state, tab.id)
@@ -675,12 +680,24 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
} else {
// A focus happened: resolve the tab that is actually active now and
// report THAT to onActivate (never the caller's un-inserted seed).
// The pool covers free windows too — a floating instance focuses by
// raising, and the callback must see the real (patched) tab.
const candidates = allLeaves(landed.splits).concat(allLeaves(landed.bottomSplits)).flatMap(leaf => leaf.tabs)
.concat(landed.floats.map(f => f.tab))
activated = key !== undefined
? candidates.find(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key)
: candidates.find(candidate => candidate.id === tab.id)
activated ??= tab
}
// A CONTENT open that focuses an existing FLOATING tab is already in
// sight (free windows render regardless of panel state): expanding a
// panel for it would point the user at a pane the content is not in.
if (
!isCreation
&& floatWithTab(landed, activated?.id ?? tab.id) !== undefined
) {
return landed
}
// A CONTENT open (file / browser) must land in sight: when the panel
// hosting the landing pane is collapsed, expand it. On narrow
// viewports the two workbenches merge into one drawer, so the drawer
@@ -728,6 +745,12 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
// Unknown tab ids are a strict no-op: no state churn, no notify, no
// pointless localStorage rewrite (mirrors updateTab's short-circuit).
if (!tabOpenIn(state, tabId)) return state
// A floating tab closes WITH its window — the float is the tab's pane.
const float = floatWithTab(state, tabId)
if (float !== undefined) {
closed = float.tab
return closeFloatByTab(state, tabId)
}
const paneId = findPaneIdOf(state, tabId)
const leaf = leafWithTab(state[treeOf(state, paneId)], tabId)
closed = leaf?.tabs.find(tab => tab.id === tabId)
@@ -764,6 +787,12 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
store.reduce((state) => {
// Unknown tab ids are a strict no-op (no state churn / notify).
if (!tabOpenIn(state, tabId)) return state
// A floating tab "activates" by raising its window — no pane switch.
const float = floatWithTab(state, tabId)
if (float !== undefined) {
activated = float.tab
return raiseFloat(state, float.id)
}
const paneId = findPaneIdOf(state, tabId)
const leaf = leafWithTab(state[treeOf(state, paneId)], tabId)
activated = leaf?.tabs.find(tab => tab.id === tabId)
@@ -825,6 +854,10 @@ function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescri
const existing = leaf.tabs.find(t => t.type === tab.type && dedupeKey!(t) === key)
if (existing !== undefined) return activateTabReducer(state, leaf.id, existing.id)
}
// A floating instance focuses by raising its window (no duplicate tab,
// no panel expansion — the window is the tab's pane).
const floated = state.floats.find(f => f.tab.type === tab.type && dedupeKey!(f.tab) === key)
if (floated !== undefined) return raiseFloat(state, floated.id)
}
return openTabInActivePane(state, tab)
}
+156 -6
View File
@@ -304,6 +304,148 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
padding-right: 40px;
}
/* ── Free windows ──────────────────────────────────────────────────────── */
/* One floating tab: the same card surface as the panels, at viewport
coordinates inside the panel host (immune to shell transforms). z-index
42: above the panels (40) so a window drags across them, below the
toggle cluster (45) and the app's overlay stack (100+, issue #52).
Stacking among windows is the floats array order (siblings share the
z-index; DOM order wins). Deliberately NO box transition — the header/
resize drags write left/top/width/height per frame. Elevated surface,
so unlike the flat panels it earns a shadow token. */
.floatWindow {
position: absolute;
z-index: 42;
display: flex;
flex-direction: column;
/* Re-enable interaction inside the click-transparent panel host. */
pointer-events: auto;
background: var(--dsw-alias-bg-layer-1);
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
box-shadow: var(--dsw-shadow-lv3);
overflow: hidden;
contain: layout style;
}
.floatWindowDragging {
will-change: left, top, width, height;
}
/* The header: the move/dock-back drag handle, mirroring the tab strip's
chrome (34px, hairline bottom, secondary label). */
.floatHeader {
flex: none;
display: flex;
align-items: center;
gap: 4px;
height: 34px;
padding: 0 4px 0 10px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-layer-1);
border-bottom: 1px solid var(--dsw-alias-border-l1);
cursor: grab;
user-select: none;
}
.floatWindowDragging .floatHeader {
cursor: grabbing;
}
.floatTitle {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Mirrors .tabClose. */
.floatClose {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
flex: none;
}
.floatClose:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
/* Mirrors .paneContent's column-flex contract: the tab cell must hand its
child a definite WIDTH via align-stretch. A row flex (the default) sizes
the main axis (width) from content — intrinsic-zero hosts (e.g. a plugin
tab UI) collapse to a 0px-wide sliver and paint nothing (the blank
whale-report window). */
.floatContent {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* The SE resize corner (mirror of .cornerHandle). */
.floatResize {
position: absolute;
right: 0;
bottom: 0;
width: 14px;
height: 14px;
z-index: 2;
cursor: nwse-resize;
touch-action: none;
}
.floatResize:hover {
background: var(--dsw-alias-interactive-bg-hover-accent);
}
/* A pane highlighted as the dock target while a float header drag hovers it
(the attribute is written directly by the drag at pointer cadence;
skin-addressable via [data-dsh-pane][data-dsh-float-dock-over]). */
.pane[data-dsh-float-dock-over] {
outline: 2px dashed var(--dsw-alias-interactive-bg-hover-accent);
outline-offset: -2px;
}
/* The drag-out hint over the conversation column: a dashed frame marking
the drop zone, pointer-transparent so it never disturbs the drag it
describes. z-index 46: above the windows (42) for unambiguous feedback,
still below the app's overlay stack. */
.floatDropHint {
position: absolute;
z-index: 46;
pointer-events: none;
border: 2px dashed var(--dsw-alias-interactive-bg-hover-accent);
border-radius: 8px;
background: color-mix(in srgb, var(--dsw-alias-interactive-bg-hover-accent) 12%, transparent);
display: flex;
align-items: center;
justify-content: center;
}
.floatDropHintLabel {
padding: 4px 12px;
border-radius: 999px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-bg-layer-1);
border: 1px solid var(--dsw-alias-border-l2);
}
/* ── Desktop drag-region opt-out ──────────────────────────────────────── */
/* Frameless desktop shells make the top strip of the web content draggable
@@ -315,7 +457,8 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
browsers and in shells without drag regions — safe everywhere. */
.toggleCluster,
.toggleButton,
.tabBar {
.tabBar,
.floatHeader {
-webkit-app-region: no-drag;
}
@@ -755,7 +898,7 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
overflow-y: auto;
/* Deep trees truncate their labels instead of scrolling horizontally. */
overflow-x: hidden;
padding: 2px 6px 8px;
padding: 4px 8px 8px;
}
/* One tree row: the app's session-row cell (34px, radius 8, inset, hover
@@ -1075,7 +1218,7 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
padding: 6px 8px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
}
@@ -1094,6 +1237,7 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
.editorPathInput {
flex: 1;
min-width: 0;
box-sizing: border-box;
height: 28px;
padding: 0 10px;
border: 1px solid var(--dsw-alias-border-l1);
@@ -1176,7 +1320,7 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
flex: none;
display: flex;
align-items: center;
gap: 4px;
gap: 6px;
padding: 6px 8px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
}
@@ -1309,7 +1453,7 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
.editorBanner {
flex: none;
padding: 4px 12px;
padding: 4px 8px;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-state-warn-label);
background: var(--dsw-alias-state-warn-tertiary);
@@ -1512,6 +1656,12 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
height: 100%;
}
/* Code lines get the same 12/16 inset as the markdown preview, so toggling
preview/edit never jumps the text's resting position. */
.editorCm :global(.cm-scroller) {
padding: 12px 16px;
}
.editorCm :global(.cm-editor.cm-focused) {
outline: none;
}
@@ -1567,7 +1717,7 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 10px 14px;
padding: 12px 16px;
font: var(--dsw-font-xs-13);
}
+4
View File
@@ -29,6 +29,8 @@ export interface WorkbenchActions {
/** Reorder within a pane (drop onto another tab inserts before it). */
moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => void
resizeSplit: (splitId: string, index: number, deltaFrac: number) => void
/** Float a docked tab out as a free window (tab context menu entry). */
floatTab: (tabId: string) => void
}
/** One divider: pointer-capture drag translating px deltas into fractions.
@@ -167,6 +169,7 @@ function LeafView(props: {
return (
<div
className={clsx(css.pane, dropZone !== null && css.paneDrop)}
data-dsh-pane={leaf.id}
onPointerDown={() => { actions.focusPane(leaf.id) }}
onDragOver={(event) => {
event.preventDefault()
@@ -207,6 +210,7 @@ function LeafView(props: {
if (before === null) actions.moveTabToEdge(payload, leaf.id, 'center')
else actions.moveTabBefore(payload, leaf.id, before)
}}
onFloatTab={actions.floatTab}
/>
{leaf.tabs.length > 0 ? (
/*
+281 -54
View File
@@ -58,6 +58,23 @@ export interface SidebarSplit {
export type SplitNode = SidebarLeaf | SidebarSplit
/**
* One free window: a tab dragged out of the workbench onto the conversation
* area floats in the panel host at viewport coordinates. The tab is OWNED by
* the window exactly like a pane owns its tabs (moved, not copied); geometry
* persists with the session so a reload restores the window in place.
* Stacking order is the array order (last = topmost).
*/
export interface FloatWindow {
id: string
tab: SidebarTab
/** Viewport coordinates of the window's top-left corner. */
x: number
y: number
w: number
h: number
}
/** The full per-session state. */
export interface SidebarState {
panelOpen: boolean
@@ -87,6 +104,8 @@ export interface SidebarState {
/** The bottom panel's own split tree (panes/tabs live only in ONE tree;
* tabs never cross panels — the two panels only share panel-size drags). */
bottomSplits: SplitNode
/** Free windows (tabs dragged out onto the conversation area). */
floats: FloatWindow[]
}
export const PANEL_MIN = 280
@@ -94,9 +113,19 @@ export const PANEL_MAX = 640
export const PANEL_DEFAULT = 400
export const TAB_MAX_WIDTH = 160
/** Bottom panel geometry contract (mirrors the width contract; the upper
* bound is the viewport, enforced by {@link setBottomHeight}). */
* bound is the viewport, enforced by {@link setBottomHeight}). */
export const BOTTOM_MIN = 120
export const BOTTOM_DEFAULT = 220
/** Free-window geometry contract: the floor keeps the window usable (a
* header plus some content), the ceiling is the viewport. */
export const FLOAT_MIN_W = 320
export const FLOAT_MIN_H = 200
/** Geometry a fresh free window starts with: a phone-like portrait ratio
* (390×780 ≈ 1:2). The creation path additionally caps the size to the
* viewport (minus a 24px margin), so a short viewport gets a shorter —
* not overflowing — window instead of an exact ratio. */
export const FLOAT_DEFAULT_W = 390
export const FLOAT_DEFAULT_H = 780
let nextIdCounter = 0
/** Unique pane/tab id within one state instance. */
@@ -143,6 +172,14 @@ function maxCounterId(parsed: unknown): number {
}
walk((parsed as Record<string, unknown> | null)?.splits)
walk((parsed as Record<string, unknown> | null)?.bottomSplits)
// Free windows mint through the same shared counter; their ids must seed it
// too (a persisted float:N with a lower pane max would collide otherwise).
const floats = (parsed as Record<string, unknown> | null)?.floats
if (Array.isArray(floats)) {
for (const float of floats) {
if (float !== null && typeof float === 'object') consider((float as Record<string, unknown>).id)
}
}
return max
}
@@ -181,6 +218,7 @@ export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seed:
bottomHeight: BOTTOM_DEFAULT,
bottomOpenedOnce: false,
bottomSplits: bottomLeaf,
floats: [],
}
}
@@ -280,10 +318,22 @@ export function allLeaves(node: SplitNode): SidebarLeaf[] {
return node.children.flatMap(allLeaves)
}
/** Whether a tab exists anywhere in a state (either tree, any pane). */
/** Whether a tab exists anywhere in a state (either tree, any pane, or any
* free window — a floating tab is as open as a docked one). */
export function tabOpenIn(state: SidebarState, tabId: string): boolean {
return allLeaves(state.splits).some(leaf => leaf.tabs.some(tab => tab.id === tabId))
|| allLeaves(state.bottomSplits).some(leaf => leaf.tabs.some(tab => tab.id === tabId))
|| state.floats.some(float => float.tab.id === tabId)
}
/** The free window holding a tab id, if any. */
export function floatWithTab(state: SidebarState, tabId: string): FloatWindow | undefined {
return state.floats.find(float => float.tab.id === tabId)
}
/** The free window with the given window id, if any. */
export function floatById(state: SidebarState, floatId: string): FloatWindow | undefined {
return state.floats.find(float => float.id === floatId)
}
/** Replace a leaf with a split of it plus a fresh empty leaf. */
@@ -453,25 +503,26 @@ export function activateTab(state: SidebarState, paneId: string, tabId: string):
/** Update the display fields of one open tab (title / path / meta) without
* re-opening it. The browser tab persists its current URL and hostname
* title through this reducer so a reload restores the visited page. A
* missing tab id is a no-op. The tab may live in either tree. */
* missing tab id is a no-op. The tab may live in either tree or a free
* window. */
export function patchTab(
state: SidebarState,
tabId: string,
patch: { title?: string; path?: string; meta?: unknown },
): SidebarState {
let changed = false
const apply = (tab: SidebarTab): SidebarTab => {
changed = true
return {
...tab,
...(patch.title !== undefined ? { title: patch.title } : {}),
...(patch.path !== undefined ? { path: patch.path } : {}),
...(patch.meta !== undefined ? { meta: patch.meta } : {}),
}
}
const walk = (node: SplitNode): SplitNode => {
if (node.kind === 'leaf') {
const tabs = node.tabs.map(tab => {
if (tab.id !== tabId) return tab
changed = true
return {
...tab,
...(patch.title !== undefined ? { title: patch.title } : {}),
...(patch.path !== undefined ? { path: patch.path } : {}),
...(patch.meta !== undefined ? { meta: patch.meta } : {}),
}
})
const tabs = node.tabs.map(tab => (tab.id === tabId ? apply(tab) : tab))
return tabs === node.tabs ? node : { ...node, tabs }
}
const children = node.children.map(walk)
@@ -479,7 +530,8 @@ export function patchTab(
}
const splits = walk(state.splits)
const bottomSplits = walk(state.bottomSplits)
return changed ? { ...state, splits, bottomSplits } : state
const floats = state.floats.map(float => (float.tab.id === tabId ? { ...float, tab: apply(float.tab) } : float))
return changed ? { ...state, splits, bottomSplits, floats } : state
}
/**
@@ -504,11 +556,14 @@ export function openTabInActivePane(state: SidebarState, tab: SidebarTab): Sideb
targetId = firstLeaf(state.splits).id
}
const targetKey = treeOf(state, targetId)
// Id-based safety net: if a tab with the same id exists, focus it.
// Id-based safety net: if a tab with the same id exists, focus it — in a
// pane (activate) or in a free window (raise, no panel switch).
for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) {
const existing = leaf.tabs.find(candidate => candidate.id === tab.id)
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
}
const floated = floatWithTab(state, tab.id)
if (floated !== undefined) return raiseFloat(state, floated.id)
return {
...state,
activePane: targetId,
@@ -674,6 +729,143 @@ export function resizeSplitIn(state: SidebarState, splitId: string, index: numbe
return { ...state, [key]: resizeSplit(state[key], splitId, index, delta) }
}
// ── Free windows ────────────────────────────────────────────────────────────
/** The viewport size, or Infinity where there is no (usable) window — unit
* tests stub partial window objects, and a NaN bound would poison geometry. */
function viewportW(): number {
return typeof window !== 'undefined' && Number.isFinite(window.innerWidth) ? window.innerWidth : Infinity
}
function viewportH(): number {
return typeof window !== 'undefined' && Number.isFinite(window.innerHeight) ? window.innerHeight : Infinity
}
/** Clamp free-window geometry: sizes respect the floor and the viewport, and
* the position keeps the whole window inside the viewport. Without a window
* (unit tests) only the floor applies — the caller's values pass through. */
export function clampFloatGeometry(x: number, y: number, w: number, h: number): Pick<FloatWindow, 'x' | 'y' | 'w' | 'h'> {
const vw = viewportW()
const vh = viewportH()
const width = Math.round(Math.min(Math.max(w, FLOAT_MIN_W), Math.max(FLOAT_MIN_W, vw)))
const height = Math.round(Math.min(Math.max(h, FLOAT_MIN_H), Math.max(FLOAT_MIN_H, vh)))
return {
x: Math.round(Math.min(Math.max(x, 0), Math.max(0, vw - width))),
y: Math.round(Math.min(Math.max(y, 0), Math.max(0, vh - height))),
w: width,
h: height,
}
}
/**
* Float a docked tab: remove it from its pane (either tree; an emptied pane
* collapses like any move) and append a free window centered on the drop
* point, with the default size clamped to the viewport. The stacking order
* is the array order, so a fresh window is born topmost. An unknown tab id
* (or one already floating) is a strict no-op.
*/
export function floatTab(state: SidebarState, tabId: string, x: number, y: number): SidebarState {
let source: SidebarLeaf | undefined
let key: 'splits' | 'bottomSplits' | undefined
for (const treeKey of ['splits', 'bottomSplits'] as const) {
source = leafWithTab(state[treeKey], tabId)
if (source !== undefined) { key = treeKey; break }
}
if (key === undefined || source === undefined) return state
const tab = source.tabs.find(candidate => candidate.id === tabId)!
let emptied = false
let node = mapLeaf(state[key], source.id, (leaf) => {
leaf.tabs = leaf.tabs.filter(candidate => candidate.id !== tabId)
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
if (leaf.tabs.length === 0) emptied = true
})
if (emptied) node = removeLeafAt(node, source.id)
// Phone-ratio default, capped to the viewport before centering so the
// clamped position never leaves the window's bottom past the fold.
const vw = viewportW()
const vh = viewportH()
const width = Math.min(FLOAT_DEFAULT_W, Math.max(FLOAT_MIN_W, vw - 24))
const height = Math.min(FLOAT_DEFAULT_H, Math.max(FLOAT_MIN_H, vh - 24))
const window = clampFloatGeometry(
x - width / 2,
y - height / 2,
width,
height,
)
const next: SidebarState = {
...state,
[key]: node,
floats: [...state.floats, { id: uid('float'), tab, ...window }],
}
// The pane the user was working in may have just collapsed with the tab.
if (emptied && state.activePane === source.id) next.activePane = firstLeaf(next.splits).id
return next
}
/** Move a free window (clamped to the viewport); unknown ids are a no-op. */
export function moveFloat(state: SidebarState, floatId: string, x: number, y: number): SidebarState {
const float = floatById(state, floatId)
if (float === undefined) return state
const geo = clampFloatGeometry(x, y, float.w, float.h)
if (geo.x === float.x && geo.y === float.y) return state
return { ...state, floats: state.floats.map(f => (f.id === floatId ? { ...f, ...geo } : f)) }
}
/** Resize a free window from its SE corner: the top-left corner stays
* anchored, sizes clamp to the floor and to the viewport's remaining room. */
export function resizeFloat(state: SidebarState, floatId: string, w: number, h: number): SidebarState {
const float = floatById(state, floatId)
if (float === undefined) return state
const vw = viewportW()
const vh = viewportH()
const width = Math.round(Math.min(Math.max(w, FLOAT_MIN_W), Math.max(FLOAT_MIN_W, vw - float.x)))
const height = Math.round(Math.min(Math.max(h, FLOAT_MIN_H), Math.max(FLOAT_MIN_H, vh - float.y)))
if (width === float.w && height === float.h) return state
return { ...state, floats: state.floats.map(f => (f.id === floatId ? { ...f, w: width, h: height } : f)) }
}
/** Bring a free window to the top (the array's end). Already topmost (or the
* only window) returns the same reference — no persist churn on every click. */
export function raiseFloat(state: SidebarState, floatId: string): SidebarState {
if (state.floats.length < 2) return state
const index = state.floats.findIndex(f => f.id === floatId)
if (index < 0 || index === state.floats.length - 1) return state
const floats = [...state.floats]
const [raised] = floats.splice(index, 1)
floats.push(raised!)
return { ...state, floats }
}
/** Dock a free window back into a pane (center merge): the tab joins the
* target pane and activates. `toPane` defaults to the active pane with the
* right tree's first leaf as the stale-id fallback (mirrors
* {@link openTabInActivePane}). Unknown window ids are a no-op. */
export function dockFloat(state: SidebarState, floatId: string, toPane?: string): SidebarState {
const float = floatById(state, floatId)
if (float === undefined) return state
let targetId = toPane ?? state.activePane ?? firstLeaf(state.splits).id
if (!allLeaves(state[treeOf(state, targetId)]).some(leaf => leaf.id === targetId)) {
targetId = firstLeaf(state.splits).id
}
const targetKey = treeOf(state, targetId)
return {
...state,
floats: state.floats.filter(f => f.id !== floatId),
activePane: targetId,
[targetKey]: mapLeaf(state[targetKey], targetId, (leaf) => {
leaf.tabs = [...leaf.tabs, float.tab]
leaf.active = float.tab.id
}),
}
}
/** Close the free window holding a tab (the tab closes WITH the window —
* the caller fires the descriptor's onClose lifecycle). */
export function closeFloatByTab(state: SidebarState, tabId: string): SidebarState {
if (!state.floats.some(f => f.tab.id === tabId)) return state
return { ...state, floats: state.floats.filter(f => f.tab.id !== tabId) }
}
/** Prefix marking a tab id as an agent-owned terminal (suffix is the uuid). */
export const AGENT_TAB_PREFIX = 'agent:'
@@ -709,6 +901,7 @@ export function reconcileAgentTerminals(
agentTerminals: ReadonlyArray<{ uuid: string; title: string }>,
): SidebarState {
const existingTabs = allLeaves(state.splits).concat(allLeaves(state.bottomSplits)).flatMap(leaf => leaf.tabs)
.concat(state.floats.map(float => float.tab))
const existingAgentTabs = existingTabs.filter(tab => isAgentTabId(tab.id))
const existingUuids = new Set(existingAgentTabs.map(tab => agentUuidOf(tab.id)))
const serverUuids = new Set(agentTerminals.map(t => t.uuid))
@@ -716,17 +909,22 @@ export function reconcileAgentTerminals(
const toRemove = existingAgentTabs.filter(tab => !serverUuids.has(agentUuidOf(tab.id)))
if (toAdd.length === 0 && toRemove.length === 0) return state
// Remove tabs whose uuids vanished from the server list (the agent closed
// them, or the pty exited and was reaped). Reuse closeTab's leaf cleanup.
// them, or the pty exited and was reaped). Reuse closeTab's leaf cleanup;
// a FLOATED agent terminal leaves with its window.
let splits = state.splits
let floats = state.floats
for (const tab of toRemove) {
const leaf = leafWithTab(splits, tab.id)
if (leaf !== undefined) {
splits = closeTab({ ...state, splits }, leaf.id, tab.id).splits
}
if (floats.some(float => float.tab.id === tab.id)) {
floats = floats.filter(float => float.tab.id !== tab.id)
}
}
// Add tabs for new uuids (the agent created a terminal). They land in the
// active pane via openTabInActivePane; the next reconcile is a no-op for them.
let next: SidebarState = { ...state, splits }
let next: SidebarState = { ...state, splits, floats }
for (const terminal of toAdd) {
const tab: SidebarTab = {
id: agentTabId(terminal.uuid),
@@ -843,6 +1041,31 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
const bottomHeight = Math.min(bottomCap, Math.max(BOTTOM_MIN, Math.round(rawHeight)))
const bottomSplits = pruneEmptyPanes(sanitizeNode(record.bottomSplits, seen, reid)
?? { kind: 'leaf' as const, id: uid('pane'), tabs: [], active: null })
// Free windows arrived with the float feature: a missing field on an older
// persisted state defaults to none (like the bottom-panel fields above).
// Malformed ENTRIES drop individually — unlike the split trees, where
// corruption resets the whole state, a bad window must not cost the user
// their whole layout. Geometry clamps into the CURRENT viewport so a stale
// off-screen window stays reachable; the window ids join the same
// uniqueness set as the pane/split ids (the uid counter mints for both).
const floats: FloatWindow[] = []
if (Array.isArray(record.floats)) {
for (const entry of record.floats) {
if (entry === null || typeof entry !== 'object') continue
const candidate = entry as Record<string, unknown>
if (typeof candidate.id !== 'string' || seen.has(candidate.id)) continue
const tab = sanitizePersistedTab(candidate.tab)
if (tab === undefined || tab === 'diff') continue
if (
typeof candidate.x !== 'number' || !Number.isFinite(candidate.x)
|| typeof candidate.y !== 'number' || !Number.isFinite(candidate.y)
|| typeof candidate.w !== 'number' || !Number.isFinite(candidate.w)
|| typeof candidate.h !== 'number' || !Number.isFinite(candidate.h)
) continue
seen.add(candidate.id)
floats.push({ id: candidate.id, tab, ...clampFloatGeometry(candidate.x, candidate.y, candidate.w, candidate.h) })
}
}
const requestedActivePane = typeof record.activePane === 'string'
? (reid.get(record.activePane) ?? record.activePane)
: null
@@ -869,6 +1092,7 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
// auto-terminal exactly once after the upgrade.
bottomOpenedOnce: record.bottomOpenedOnce === true,
bottomSplits,
floats,
}
}
@@ -902,6 +1126,42 @@ function uniqueNodeId(id: string, seen: Set<string>, reid: Map<string, string>):
return fresh
}
/**
* Validate one persisted tab record. @returns the clean tab, `'diff'` for an
* ephemeral diff tab (dropped everywhere — diff tabs never survive a reload),
* or undefined when the record is malformed (structural corruption when it
* comes from a split-tree leaf; a malformed FLOAT tab only drops the window).
*/
function sanitizePersistedTab(tab: unknown): SidebarTab | 'diff' | undefined {
if (tab === null || typeof tab !== 'object') return undefined
const candidate = tab as Record<string, unknown>
if (typeof candidate.id !== 'string' || typeof candidate.title !== 'string') return undefined
if (candidate.type === 'diff') return 'diff'
// Tab types are an open set (external plugins register their own); accept
// any string type here — an unregistered type renders an <OrphanedTab/> at
// view time and recovers if its plugin loads later.
if (typeof candidate.type !== 'string') return undefined
// The standalone explorer tab type merged INTO the editor (the single
// files window): a persisted explorer tab reopens as an editor home tab —
// no path, tree panel open (an existing meta object survives).
if (candidate.type === 'explorer') {
const meta = candidate.meta !== null && typeof candidate.meta === 'object' && !Array.isArray(candidate.meta)
? candidate.meta as Record<string, unknown>
: undefined
return { id: candidate.id, type: 'editor', title: 'Files', meta: { treeOpen: true, ...meta } }
}
// `meta` is plugin-owned JSON-serializable state (v0.12.0+): the persisted
// value already went through JSON.parse, so it is inherently serializable —
// carry it through verbatim (absent on older states).
return {
id: candidate.id,
type: candidate.type,
title: candidate.title,
...(typeof candidate.path === 'string' ? { path: candidate.path } : {}),
...(candidate.meta !== undefined ? { meta: candidate.meta } : {}),
}
}
/** Validate one split-tree node (leaf or split) and rebuild it cleanly. */
function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string>): SplitNode | undefined {
if (node === null || typeof node !== 'object') return undefined
@@ -911,46 +1171,13 @@ function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string
const tabs: SidebarTab[] = []
let droppedDiff = false
for (const tab of record.tabs) {
if (tab === null || typeof tab !== 'object') return undefined
const candidate = tab as Record<string, unknown>
if (typeof candidate.id !== 'string' || typeof candidate.title !== 'string') return undefined
// Diff tabs are ephemeral by nature (like VSCode's diff editors):
// they never survive a reload, so a stale ref (e.g. a wrong staged
// side persisted by an older build) cannot resurface as a dead tab
// showing "no text changes" after every refresh.
if (candidate.type === 'diff') {
const clean = sanitizePersistedTab(tab)
if (clean === undefined) return undefined
if (clean === 'diff') {
droppedDiff = true
continue
}
// Tab types are an open set (external plugins register their own);
// accept any string type here — an unregistered type renders an
// <OrphanedTab/> at view time and recovers if its plugin loads later.
if (typeof candidate.type !== 'string') return undefined
// The standalone explorer tab type merged INTO the editor (the single
// files window): a persisted explorer tab reopens as an editor home
// tab — no path, tree panel open (an existing meta object survives).
if (candidate.type === 'explorer') {
const meta = candidate.meta !== null && typeof candidate.meta === 'object' && !Array.isArray(candidate.meta)
? candidate.meta as Record<string, unknown>
: undefined
tabs.push({
id: candidate.id,
type: 'editor',
title: 'Files',
meta: { treeOpen: true, ...meta },
})
continue
}
// `meta` is plugin-owned JSON-serializable state (v0.12.0+): the
// persisted value already went through JSON.parse, so it is inherently
// serializable — carry it through verbatim (absent on older states).
tabs.push({
id: candidate.id,
type: candidate.type,
title: candidate.title,
...(typeof candidate.path === 'string' ? { path: candidate.path } : {}),
...(candidate.meta !== undefined ? { meta: candidate.meta } : {}),
})
tabs.push(clean)
}
const active = typeof record.active === 'string' ? record.active : null
// An active pointer into a dropped diff tab is expected after the drop;
+258
View File
@@ -0,0 +1,258 @@
/**
* 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 }
})
}
/** The float's content cell must hand its child a real width (the
* column-flex contract): a collapsed 0px cell paints nothing — the tab is
* "in" the window but invisible. */
async function assertContentPainted(page: Page, label: string): Promise<void> {
const width = await page.evaluate(() => {
const cell = document.querySelector('[data-dsh-float-window] [class*="floatContent"]')
const child = cell?.firstElementChild
return child === null || child === undefined ? -1 : Math.round(child.getBoundingClientRect().width)
})
expect(width, `${label}: the floated tab's content must have real width`).toBeGreaterThan(200)
}
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.
await assertContentPainted(page, 'context-menu float')
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 - 390)
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 assertContentPainted(page, 'drag-out float')
await assertNoCrash(page)
})
+403
View File
@@ -0,0 +1,403 @@
/**
* 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, togglePanel, 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,
// Pins the visibility contract into the DOM: plugin components honor
// `visible` to pause work, so the float tests can assert what a
// registering plugin would actually receive.
component: ({ visible }) => createElement('div', { 'data-visible': String(visible) }, '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 with the phone-ratio
// default (390x780), created capped to the jsdom viewport (h 744) and
// clamped in (the centered y would be negative).
expect(state.floats[0]).toMatchObject({ x: 400 - 195, y: 0, w: 390, h: 744 })
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('317px')
// Grab at (300, 220) — the drag records the window's own origin, so
// moving the pointer to (200, 250) lands the window at (217, 42) —
// clamped back up to y 24 (the 744-tall window must stay in the
// 768-tall viewport: 768 - 744 = 24).
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('217px')
expect(win.style.top).toBe('24px')
// The store is untouched mid-drag (DOM is the only record).
expect(store.getSnapshot().state!.floats[0]!.x).toBe(317)
act(() => { header.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 200, clientY: 250, button: 0 })) })
expect(store.getSnapshot().state!.floats[0]).toMatchObject({ x: 217, y: 24 })
} 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: 700, clientY: 750, button: 0 }))
handle.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 800, clientY: 800 }))
})
await flushFrame()
// +100/+50 from the created 390x744: height clamps to the viewport
// ceiling (768 - y 12 = 756).
expect(win.style.width).toBe('490px')
expect(win.style.height).toBe('756px')
act(() => { handle.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 800, clientY: 800, button: 0 })) })
expect(store.getSnapshot().state!.floats[0]).toMatchObject({ w: 490, h: 756 })
} 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('317px')
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('317px')
act(() => { dockRow.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
expect(store.getSnapshot().state!.floats).toHaveLength(0)
expect(paneTabs(store)).toContain('notes')
} finally {
unmount()
}
})
it('a floated plugin tab stays visible while the panel collapses (placement contract)', () => {
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]')!
expect(win.querySelector<HTMLElement>('[data-visible]')!.dataset.visible).toBe('true')
// Collapse the sidebar: the pane's seeded home tab goes invisible
// (panel closed), but the float is its own surface — a registering
// plugin's component must keep receiving visible=true (AGENTS §7.5).
act(() => { store.reduce(togglePanel) })
expect(win.querySelector<HTMLElement>('[data-visible]')!.dataset.visible).toBe('true')
// (No pane-side control: the pane's remaining tab is the builtin editor
// home tab, which does not surface `visible` into the DOM.)
} finally {
unmount()
}
})
it('releasing the header over a pane docks the tab back (live pane highlight)', async () => {
fakeConversationColumn()
const { container, store, service, unmount } = mountSidebar()
try {
act(() => { service.openTab({ type: 'notes', title: 'Notes' }) })
act(() => { store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) })
const win = container.querySelector<HTMLElement>('[data-dsh-float-window]')!
const header = win.querySelector<HTMLElement>('[class*="floatHeader"]')!
// The workbench pane covers (600, 400) for the hit-test.
const pane = container.querySelector<HTMLElement>('[data-dsh-pane]')!
pane.getBoundingClientRect = () => ({ left: 550, right: 700, top: 350, bottom: 500, width: 150, height: 150 }) as DOMRect
act(() => {
header.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 300, clientY: 220, button: 0 }))
header.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 600, clientY: 400 }))
})
await flushFrame()
// The dock target highlights while hovered.
expect(pane.hasAttribute('data-dsh-float-dock-over')).toBe(true)
act(() => { header.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 600, clientY: 400, button: 0 })) })
const state = store.getSnapshot().state!
expect(state.floats).toHaveLength(0)
expect(paneTabs(store)).toContain('notes')
expect(pane.hasAttribute('data-dsh-float-dock-over')).toBe(false)
expect(container.querySelector('[data-dsh-float-window]')).toBeNull()
} finally {
unmount()
}
})
it('the X button closes the tab with its window', () => {
fakeConversationColumn()
const { container, store, service, unmount } = mountSidebar()
try {
act(() => { service.openTab({ type: 'notes', title: 'Notes' }) })
act(() => { store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) })
const win = container.querySelector<HTMLElement>('[data-dsh-float-window]')!
const close = win.querySelector<HTMLElement>('[class*="floatClose"]')!
act(() => { close.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
const state = store.getSnapshot().state!
expect(state.floats).toHaveLength(0)
expect(paneTabs(store)).not.toContain('notes')
expect(container.querySelector('[data-dsh-float-window]')).toBeNull()
} finally {
unmount()
}
})
it('a reload round-trip restores the window (localStorage persistence)', () => {
fakeConversationColumn()
const saved = new Map<string, string>()
const getItem = vi.fn((key: string): string | null => saved.get(key) ?? null)
const setItem = vi.fn((key: string, value: string): void => { saved.set(key, value) })
vi.stubGlobal('localStorage', { getItem, setItem, removeItem: () => {} })
const first = mountSidebar()
try {
act(() => { first.service.openTab({ type: 'notes', title: 'Notes' }) })
act(() => { first.store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) })
// The debounced persist fires on a later tick — run it synchronously.
const state = first.store.getSnapshot().state!
saved.set('dsh-sidebar:v1:s1', JSON.stringify(state))
} finally {
first.unmount()
}
const second = mountSidebar()
try {
const state = second.store.getSnapshot().state!
expect(state.floats).toHaveLength(1)
expect(state.floats[0]!.tab.id).toBe('notes')
expect(second.container.querySelector('[data-dsh-float-window]')).not.toBeNull()
} finally {
second.unmount()
}
})
})
+68 -2
View File
@@ -2,7 +2,7 @@
* Tests for the BetterSidebar service registry: register/dispose lifecycle,
* matchFileViewer priority/exts/detect algorithm, and openTab dedupe.
*/
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
// Mock browser globals (SidebarStore.reduce → schedulePersist uses window.setTimeout)
const g = globalThis as Record<string, unknown>
@@ -21,7 +21,7 @@ if (g.localStorage === undefined) {
}
import { createBetterSidebarService, matchUrlTarget, SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from '../src/client/service.ts'
import { createSidebarStore, allLeaves, makeDefaultState, openDiffTab, openTabInActivePane, sanitizeState } from '../src/client/state.ts'
import { createSidebarStore, allLeaves, floatTab, makeDefaultState, openDiffTab, openTabInActivePane, sanitizeState } from '../src/client/state.ts'
describe('BetterSidebar service', () => {
it('registerTab adds to the registry and dispose removes it', () => {
@@ -1055,4 +1055,70 @@ describe('independent CR follow-up fixes', () => {
service.registerFileViewer({ id: 'csv', exts: ['csv'], fetchStrategy: 'custom', component: () => null })
expect(() => service.registerFileViewer({ id: 'csv', exts: ['csv'], fetchStrategy: 'custom', component: () => null })).toThrow(/already registered/)
})
describe('free windows (v0.16.0)', () => {
it('openTab dedupe focuses a FLOATING tab by raising its window (no duplicate, no panel expansion)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'singleton', title: 'S', dedupeKey: () => 'singleton', component: () => null })
store.setSession('s1')
service.openTab({ type: 'singleton', title: 'S' })
// Float the singleton out, then float a second tab above it and
// collapse the panel — the focus must raise the window in place
// without reopening a tab or expanding anything.
store.reduce(s => floatTab(s, 'singleton', 100, 100))
service.openTab({ type: 'singleton', title: 'S' })
store.reduce(s => floatTab(s, (s.splits as { tabs: Array<{ id: string }> }).tabs[0]!.id, 100, 100))
const before = store.getSnapshot().state!
expect(before.floats).toHaveLength(2)
store.reduce(s => ({ ...s, panelOpen: false }))
service.openTab({ type: 'singleton', title: 'S' })
const after = store.getSnapshot().state!
// Raised to the top, not duplicated.
expect(after.floats).toHaveLength(2)
expect(after.floats.at(-1)!.tab.type).toBe('singleton')
// The panel stays collapsed (a floating tab is already in sight).
expect(after.panelOpen).toBe(false)
expect(allLeaves(after.splits).some(l => l.tabs.some(t => t.type === 'singleton'))).toBe(false)
})
it('closeTab on a floating tab closes it WITH the window and fires onClose', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const onClose = vi.fn()
service.registerTab({ id: 'notes', title: 'Notes', single: true, onClose, component: () => null })
store.setSession('s1')
service.openTab({ type: 'notes', title: 'Notes' })
store.reduce(s => floatTab(s, 'notes', 50, 50))
expect(store.getSnapshot().state!.floats).toHaveLength(1)
service.closeTab('notes', { sessionId: 's1' })
const after = store.getSnapshot().state!
expect(after.floats).toHaveLength(0)
expect(onClose).toHaveBeenCalledTimes(1)
// Unknown ids stay a strict no-op.
service.closeTab('notes')
expect(store.getSnapshot().state).toBe(after)
})
it('activateTab on a floating tab raises the window and fires onActivate', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const onActivate = vi.fn()
service.registerTab({ id: 'notes', title: 'Notes', single: true, onActivate, component: () => null })
store.setSession('s1')
service.openTab({ type: 'notes', title: 'Notes' })
service.openTab({ type: 'notes', title: 'Notes' })
store.reduce(s => floatTab(s, 'notes', 50, 50))
store.reduce(s => floatTab(s, (s.splits as { tabs: Array<{ id: string }> }).tabs[0]!.id, 60, 60))
const before = store.getSnapshot().state!
expect(before.floats).toHaveLength(2)
expect(before.floats.at(-1)!.tab.type).not.toBe('notes')
service.activateTab('notes')
const after = store.getSnapshot().state!
expect(after.floats.at(-1)!.tab.type).toBe('notes')
// Two activations total: the second openTab's dedupe focus (before the
// float) plus THIS explicit activateTab — both legitimate focuses.
expect(onActivate).toHaveBeenCalledTimes(2)
})
})
})
+291 -4
View File
@@ -1,10 +1,12 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
activateTab, allLeaves, BOTTOM_DEFAULT, BOTTOM_MIN, closeTab, createSidebarStore,
insertLeafAt, makeDefaultState, migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab,
openTabInActivePane, patchTab, resizeSplit, resizeSplitIn, sanitizeState, setBottomHeight,
activateTab, allLeaves, BOTTOM_DEFAULT, BOTTOM_MIN, closeFloatByTab, closeTab, createSidebarStore,
dockFloat, FLOAT_MIN_H, FLOAT_MIN_W, floatTab, floatWithTab, insertLeafAt, makeDefaultState,
migrateBottomTabs, moveFloat, moveTab, moveTabToEdge, openDiffTab,
openTabInActivePane, patchTab, raiseFloat, reconcileAgentTerminals, resizeFloat, resizeSplit,
resizeSplitIn, sanitizeState, setBottomHeight,
splitPane, tabOpenIn, toggleBottomPanel, toggleExpanded, togglePanel,
type SidebarState, type SidebarTab, type SplitNode,
type SidebarLeaf, type SidebarState, type SidebarTab, type SplitNode,
} from '../src/client/state.ts'
describe('sidebar state', () => {
@@ -896,3 +898,288 @@ 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)
// Phone-ratio default (390x780) centered on the drop point (no viewport
// → size cap never bites; only the negative y clamps to 0).
expect(floated.floats[0]).toMatchObject({ x: 300 - 195, y: 0, w: 390, h: 780 })
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)
// The float was CREATED capped to the viewport: w 390, h 768-24=744.
expect(moved.floats[0]!.x).toBe(1024 - 390)
expect(moved.floats[0]!.y).toBe(768 - 744)
// No-op move returns the same reference (no persist churn).
expect(moveFloat(moved, id, moved.floats[0]!.x, moved.floats[0]!.y)).toBe(moved)
// Resize: floors, viewport ceiling, and the SE-corner anchor (x/y keep).
let resized = resizeFloat(moved, id, 10, 10)
expect(resized.floats[0]).toMatchObject({ w: FLOAT_MIN_W, h: FLOAT_MIN_H })
resized = resizeFloat(resized, id, 5000, 5000)
expect(resized.floats[0]!.w).toBe(1024 - resized.floats[0]!.x)
expect(resized.floats[0]!.h).toBe(768 - resized.floats[0]!.y)
// No-op resize and unknown ids return the same reference.
expect(resizeFloat(resized, id, resized.floats[0]!.w, resized.floats[0]!.h)).toBe(resized)
expect(resizeFloat(resized, 'nope', 400, 400)).toBe(resized)
} finally {
if (previous === undefined) delete g.window
else g.window = previous
}
})
it('raiseFloat moves a window to the top and is idempotent at the top', () => {
let s = state()
s = floatTab(openTabInActivePane(s, { id: 'a', type: 'db', title: 'a' }), 'a', 100, 100)
s = floatTab(openTabInActivePane(s, { id: 'b', type: 'db', title: 'b' }), 'b', 100, 100)
s = floatTab(openTabInActivePane(s, { id: 'c', type: 'db', title: 'c' }), 'c', 100, 100)
expect(s.floats.map(f => f.tab.id)).toEqual(['a', 'b', 'c'])
const raised = raiseFloat(s, s.floats[0]!.id)
expect(raised.floats.map(f => f.tab.id)).toEqual(['b', 'c', 'a'])
// Already topmost / unknown id → same reference (no notify churn).
expect(raiseFloat(raised, raised.floats[2]!.id)).toBe(raised)
expect(raiseFloat(raised, 'float:none')).toBe(raised)
// A single window has nothing to raise: same reference.
let single = state()
single = floatTab(single, (single.splits as SidebarLeaf).tabs[0]!.id, 0, 0)
expect(single.floats).toHaveLength(1)
expect(raiseFloat(single, single.floats[0]!.id)).toBe(single)
})
it('dockFloat lands the tab in the target pane (or the active pane) and activates it', () => {
let s = state()
const git = { id: 'git', type: 'git' as const, title: 'Git' }
s = floatTab(openTabInActivePane(s, git), 'git', 100, 100)
const floatId = s.floats[0]!.id
// Explicit target pane.
s = splitPane(s, 'row')
const target = allLeaves(s.splits).find(leaf => leaf.tabs.length === 0)!
const docked = dockFloat(s, floatId, target.id)
expect(docked.floats).toHaveLength(0)
expect(allLeaves(docked.splits).find(leaf => leaf.id === target.id)!.tabs.map(tab => tab.id)).toEqual(['git'])
expect(docked.activePane).toBe(target.id)
// Default target: the active pane.
let s2 = state()
const home = (s2.splits as SidebarLeaf).tabs[0]!
s2 = splitPane(s2, 'row')
s2 = floatTab(s2, home.id, 100, 100)
const active = s2.activePane!
const docked2 = dockFloat(s2, s2.floats[0]!.id)
expect(docked2.floats).toHaveLength(0)
expect(allLeaves(docked2.splits).find(leaf => leaf.id === active)!.tabs.some(tab => tab.id === home.id)).toBe(true)
expect(docked2.activePane).toBe(active)
// Stale target falls back to the right tree's first leaf.
let s3 = state()
const home3 = (s3.splits as SidebarLeaf).tabs[0]!
s3 = floatTab(s3, home3.id, 0, 0)
const docked3 = dockFloat(s3, s3.floats[0]!.id, 'pane:gone')
expect(docked3.floats).toHaveLength(0)
expect(allLeaves(docked3.splits).some(leaf => leaf.tabs.some(tab => tab.id === home3.id))).toBe(true)
// Unknown float id is a strict no-op.
expect(dockFloat(docked3, 'float:gone')).toBe(docked3)
})
it('tabOpenIn / patchTab / floatWithTab see floating tabs', () => {
let s = state()
const git = { id: 'git', type: 'git' as const, title: 'Git' }
s = floatTab(openTabInActivePane(s, git), 'git', 100, 100)
expect(tabOpenIn(s, 'git')).toBe(true)
expect(floatWithTab(s, 'git')!.id).toBe(s.floats[0]!.id)
const patched = patchTab(s, 'git', { title: 'Git 2', path: '/x' })
expect(patched.floats[0]!.tab).toMatchObject({ title: 'Git 2', path: '/x' })
expect(allLeaves(patched.splits).some(leaf => leaf.tabs.some(tab => tab.id === 'git'))).toBe(false)
})
it('closeFloatByTab removes exactly the window holding the tab', () => {
let s = state()
const git = { id: 'git', type: 'git' as const, title: 'Git' }
const term = { id: 'term:1', type: 'terminal' as const, title: 'T' }
s = openTabInActivePane(s, git)
s = openTabInActivePane(s, term)
s = floatTab(s, 'git', 50, 50)
s = floatTab(s, 'term:1', 60, 60)
expect(s.floats).toHaveLength(2)
const after = closeFloatByTab(s, 'git')
expect(after.floats.map(f => f.tab.id)).toEqual(['term:1'])
expect(closeFloatByTab(after, 'git')).toBe(after)
})
it('openTabInActivePane focuses a floating tab by raising (id safety net)', () => {
let s = state()
const git = { id: 'git', type: 'git' as const, title: 'Git' }
const term = { id: 'term:1', type: 'terminal' as const, title: 'T' }
s = floatTab(openTabInActivePane(s, git), 'git', 50, 50)
s = floatTab(openTabInActivePane(s, term), 'term:1', 60, 60)
// Reopening the floated id must NOT duplicate: it raises the window.
const after = openTabInActivePane(s, { id: 'git', type: 'git', title: 'Git' })
expect(after.floats.map(f => f.tab.id)).toEqual(['term:1', 'git'])
expect(allLeaves(after.splits).some(leaf => leaf.tabs.some(tab => tab.id === 'git'))).toBe(false)
})
it('reconcileAgentTerminals removes a vanished FLOATED agent terminal with its window', () => {
let s = state()
s = openTabInActivePane(s, { id: 'agent:u1', type: 'terminal', title: 'A1' })
s = floatTab(s, 'agent:u1', 50, 50)
s = openTabInActivePane(s, { id: 'agent:u2', type: 'terminal', title: 'A2' })
expect(s.floats).toHaveLength(1)
const after = reconcileAgentTerminals(s, [{ uuid: 'u2', title: 'A2' }])
expect(after.floats).toHaveLength(0)
expect(allLeaves(after.splits).flatMap(leaf => leaf.tabs).some(tab => tab.id === 'agent:u2')).toBe(true)
})
describe('sanitizeState (floats)', () => {
const g = globalThis as Record<string, unknown>
beforeEach(() => {
g.window = { clearTimeout: () => {}, setTimeout: () => 0, innerWidth: 1024, innerHeight: 768 }
g.localStorage = { getItem: () => null, setItem: () => {} }
})
afterEach(() => {
delete g.window
delete g.localStorage
})
const base = (): Record<string, unknown> => ({
panelOpen: true,
width: 400,
nextTerminal: 1,
activePane: 'pane:1',
expanded: [],
splits: { kind: 'leaf', id: 'pane:1', active: null, tabs: [] },
})
it('a missing floats field defaults to none (older persisted states load)', () => {
const restored = sanitizeState(base())!
expect(restored.floats).toEqual([])
})
it('drops malformed entries individually and keeps the layout (unlike tree corruption)', () => {
const parsed = base()
parsed.floats = [
'garbage',
{ id: 'float:1' }, // no tab
{ id: 'float:2', tab: { id: 't', type: 'git', title: 'G' }, x: 10, y: 10 }, // no w/h
{ id: 'float:3', tab: { id: 't3', type: 'git', title: 'G3' }, x: 10, y: 10, w: 500, h: 400 },
{ id: 'float:3', tab: { id: 't3b', type: 'git', title: 'G3b' }, x: 0, y: 0, w: 400, h: 300 }, // duplicate id
]
const restored = sanitizeState(parsed)!
expect(restored.floats).toHaveLength(1)
expect(restored.floats[0]).toMatchObject({ id: 'float:3', tab: { id: 't3' }, x: 10, y: 10 })
expect(restored.splits).toBeDefined()
})
it('drops ephemeral diff tabs and migrates explorer tabs inside floats', () => {
const parsed = base()
parsed.floats = [
{ id: 'float:1', tab: { id: 'd', type: 'diff', title: 'D' }, x: 0, y: 0, w: 400, h: 300 },
{ id: 'float:2', tab: { id: 'ex', type: 'explorer', title: 'Explorer' }, x: 0, y: 0, w: 400, h: 300 },
]
const restored = sanitizeState(parsed)!
expect(restored.floats.map(f => f.tab.type)).toEqual(['editor'])
expect(restored.floats[0]!.tab).toMatchObject({ title: 'Files', meta: { treeOpen: true } })
})
it('clamps stale off-screen geometry into the current viewport', () => {
const parsed = base()
parsed.floats = [
{ id: 'float:1', tab: { id: 't', type: 'git', title: 'G' }, x: -200, y: 900, w: 5000, h: 5000 },
]
const restored = sanitizeState(parsed)!
// Sizes cap at the viewport, and the capped window lands at 0,0 (the
// position clamp sees no room beyond it).
expect(restored.floats[0]).toMatchObject({ x: 0, y: 0, w: 1024, h: 768 })
})
it('the uid counter seeds past persisted float ids (no collision on re-float)', () => {
// Persisted: the pane max is 1, but floats reach float:2 — the counter
// must seed to 2 so a fresh float mints float:3+, never a duplicate
// float:2. (Intermediate mints during sanitize may raise it further;
// the guarantee under test is uniqueness, not an exact value.)
const parsed = base()
parsed.floats = [
{ id: 'float:2', tab: { id: 't2', type: 'git', title: 'G2' }, x: 0, y: 0, w: 400, h: 300 },
]
const saved = new Map<string, string>()
saved.set('dsh-sidebar:v1:seedtest', JSON.stringify(parsed))
g.localStorage = {
getItem: (key: string) => saved.get(key) ?? null,
setItem: (key: string, value: string) => { saved.set(key, value) },
}
const store = createSidebarStore()
store.setSession('seedtest')
const s = store.getSnapshot().state!
expect(s.floats[0]!.id).toBe('float:2')
store.reduce(cur => openTabInActivePane(cur, { id: 'tab:9', type: 'git', title: 'G9' }))
store.reduce(cur => floatTab(cur, 'tab:9', 30, 30))
const next = store.getSnapshot().state!
expect(next.floats).toHaveLength(2)
expect(new Set(next.floats.map(f => f.id)).size).toBe(2)
const freshId = next.floats.map(f => f.id).find(id => id !== 'float:2')!
expect(freshId).toMatch(/^float:\d+$/)
expect(Number(freshId.slice('float:'.length))).toBeGreaterThan(2)
})
it('floats survive a full persist round-trip (geometry verbatim when in-viewport)', () => {
const parsed = base()
parsed.floats = [
{ id: 'float:1', tab: { id: 't', type: 'git', title: 'G', meta: { k: 1 } }, x: 12, y: 34, w: 480, h: 360 },
]
const restored = sanitizeState(parsed)!
const again = sanitizeState(JSON.parse(JSON.stringify(restored)))!
expect(again.floats).toEqual(restored.floats)
})
})
})
+36 -17
View File
@@ -1,13 +1,13 @@
/**
* Tab-strip right-click context-menu tests. Right-clicking a tab takes over
* the browser menu (preventDefault) and shows the tab context menu with
* exactly four items: close / close others / close to the left / close to
* the right. All bulk operations are scoped to the CURRENT pane (the render
* time tab snapshot) and reuse the per-tab onClose path, so the target tab
* is never closed and the pane never empties mid-loop. The menu items gray
* out when there is nothing to close (single tab → close others; leftmost →
* close left; rightmost → close right). Opening the menu must not activate
* the right-clicked tab.
* exactly five items: move to free window / close / close others / close to
* the left / close to the right. All bulk operations are scoped to the
* CURRENT pane (the render time tab snapshot) and reuse the per-tab onClose
* path, so the target tab is never closed and the pane never empties
* mid-loop. The menu items gray out when there is nothing to close (single
* tab → close others; leftmost → close left; rightmost → close right).
* Opening the menu must not activate the right-clicked tab.
*/
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -29,18 +29,20 @@ function stubZh(): void {
})
}
const MENU_LABELS = ['关闭', '关闭其他页签', '关闭左侧页签', '关闭右侧页签']
const MENU_LABELS = ['移动到自由窗口', '关闭', '关闭其他页签', '关闭左侧页签', '关闭右侧页签']
function mountBar(tabs: SidebarTab[]): {
tabEls: HTMLElement[]
onClose: ReturnType<typeof vi.fn>
onActivate: ReturnType<typeof vi.fn>
onFloatTab: ReturnType<typeof vi.fn>
unmount: () => void
} {
const container = document.createElement('div')
document.body.append(container)
const onClose = vi.fn()
const onActivate = vi.fn()
const onFloatTab = vi.fn()
const root: Root = createRoot(container)
act(() => {
root.render(createElement(TabBar, {
@@ -51,6 +53,7 @@ function mountBar(tabs: SidebarTab[]): {
onClose,
onNewTab: () => {},
newTabOptions: [],
onFloatTab,
onDropTab: () => {},
}))
})
@@ -59,6 +62,7 @@ function mountBar(tabs: SidebarTab[]): {
tabEls,
onClose,
onActivate,
onFloatTab,
unmount: () => {
act(() => { root.unmount() })
container.remove()
@@ -93,7 +97,7 @@ afterEach(() => {
})
describe('TabBar right-click context menu', () => {
it('opens the four-item menu at the cursor, prevents the browser menu, and does not activate', () => {
it('opens the five-item menu at the cursor, prevents the browser menu, and does not activate', () => {
stubZh()
const { tabEls, onActivate, unmount } = mountBar(fourTabs())
try {
@@ -107,12 +111,27 @@ describe('TabBar right-click context menu', () => {
}
})
it('move to free window floats the target tab without closing it', () => {
stubZh()
const { tabEls, onClose, onFloatTab, unmount } = mountBar(fourTabs())
try {
act(() => { rightClick(tabEls[1]!) })
act(() => { menuItems()[0]!.click() })
expect(onFloatTab).toHaveBeenCalledTimes(1)
expect(onFloatTab).toHaveBeenCalledWith('t2')
expect(onClose).not.toHaveBeenCalled()
expect(menuItems()).toHaveLength(0)
} finally {
unmount()
}
})
it('close closes only the target tab and closes the menu', () => {
stubZh()
const { tabEls, onClose, unmount } = mountBar(fourTabs())
try {
act(() => { rightClick(tabEls[1]!) })
act(() => { menuItems()[0]!.click() })
act(() => { menuItems()[1]!.click() })
expect(onClose).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalledWith('t2')
expect(menuItems()).toHaveLength(0)
@@ -126,7 +145,7 @@ describe('TabBar right-click context menu', () => {
const { tabEls, onClose, unmount } = mountBar(fourTabs())
try {
act(() => { rightClick(tabEls[1]!) })
act(() => { menuItems()[1]!.click() })
act(() => { menuItems()[2]!.click() })
expect(onClose.mock.calls.map(call => call[0])).toEqual(['t1', 't3', 't4'])
expect(onClose).not.toHaveBeenCalledWith('t2')
expect(menuItems()).toHaveLength(0)
@@ -140,7 +159,7 @@ describe('TabBar right-click context menu', () => {
const { tabEls, onClose, unmount } = mountBar(fourTabs())
try {
act(() => { rightClick(tabEls[2]!) })
act(() => { menuItems()[2]!.click() })
act(() => { menuItems()[3]!.click() })
expect(onClose.mock.calls.map(call => call[0])).toEqual(['t1', 't2'])
expect(menuItems()).toHaveLength(0)
} finally {
@@ -153,7 +172,7 @@ describe('TabBar right-click context menu', () => {
const { tabEls, onClose, unmount } = mountBar(fourTabs())
try {
act(() => { rightClick(tabEls[1]!) })
act(() => { menuItems()[3]!.click() })
act(() => { menuItems()[4]!.click() })
expect(onClose.mock.calls.map(call => call[0])).toEqual(['t3', 't4'])
expect(menuItems()).toHaveLength(0)
} finally {
@@ -170,9 +189,9 @@ describe('TabBar right-click context menu', () => {
act(() => { rightClick(single.tabEls[0]!) })
const items = menuItems()
// The Menu renders each row as a disabled <button role="menuitem">.
expect(items.map(item => (item as HTMLButtonElement).disabled)).toEqual([false, true, true, true])
expect(items.map(item => (item as HTMLButtonElement).disabled)).toEqual([false, false, true, true, true])
// Clicking the disabled row must not close anything.
act(() => { items[1]!.click() })
act(() => { items[2]!.click() })
expect(single.onClose).not.toHaveBeenCalled()
} finally {
single.unmount()
@@ -181,9 +200,9 @@ describe('TabBar right-click context menu', () => {
const four = mountBar(fourTabs())
try {
act(() => { rightClick(four.tabEls[0]!) })
expect(menuItems().map(item => (item as HTMLButtonElement).disabled)).toEqual([false, false, true, false])
expect(menuItems().map(item => (item as HTMLButtonElement).disabled)).toEqual([false, false, false, true, false])
act(() => { rightClick(four.tabEls[3]!) })
expect(menuItems().map(item => (item as HTMLButtonElement).disabled)).toEqual([false, false, false, true])
expect(menuItems().map(item => (item as HTMLButtonElement).disabled)).toEqual([false, false, false, false, true])
} finally {
four.unmount()
}
+1
View File
@@ -47,6 +47,7 @@ function mountBar(): {
onClose,
onNewTab: () => {},
newTabOptions: [],
onFloatTab: () => {},
onDropTab: () => {},
}))
})
+1
View File
@@ -36,6 +36,7 @@ function mountBar(overflow: boolean): { list: HTMLElement; unmount: () => void }
onClose: () => {},
onNewTab: () => {},
newTabOptions: [],
onFloatTab: () => {},
onDropTab: () => {},
}))
})