From f17a1b30acae70c8a21879800d27181decf82777 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sun, 23 Aug 2026 23:44:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E8=87=AA=E7=94=B1=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E2=80=94=E2=80=94tab=20=E6=8B=96=E5=87=BA=E4=BE=A7?= =?UTF-8?q?=E8=BE=B9=E6=A0=8F=E6=88=90=E4=B8=BA=E5=8F=AF=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?/=E7=BC=A9=E6=94=BE/=E7=BD=AE=E9=A1=B6=E7=9A=84=E6=B5=AE?= =?UTF-8?q?=E5=8A=A8=E7=AA=97=E5=8F=A3=20(v0.16.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 拖出:标签栏 tab 拖到主会话列(HTML5 DnD 检测 + 虚线提示浮层)或右键菜单 「移动到自由窗口」(右/底面板共用 TabBar),tab 从 pane 移动进 SidebarState.floats - 窗口(FreeWindow,渲染于 [data-dsh-panel-host] z-42):头部 pointer 拖动 (rAF 直写 + 松手提交)、拖到 pane 高亮停靠(center 合并)、SE 角缩放、 点击置顶、头部右键(回到侧边栏/关闭)、X = closeTab 走正常生命周期 - 服务语义:openTab dedupe/id 聚焦浮动 tab = 置顶窗口(不展开面板); closeTab/activateTab 兼顾 floats;agent 终端 reconcile 覆盖浮窗; features 单调新增 'floatWindows',版本 0.16.0 - 持久化:floats 随会话 localStorage,sanitizeState 宽容校验 + 几何钳制, uid 播种覆盖 float 前缀;i18n 三键 19 语言同步 - 关键修复(e2e 发现):portal 覆盖层(头部 Menu)的合成事件沿 React 树 冒泡回拖拽表面,劫持菜单行点击——拖拽起点加 DOM 同源守卫(jsdom 不复现) - e2e-mount.sh:缺省 tarball 改取 mtime 最新(旧字典序选择会把冒烟挂到 过期产物);新增 tests/e2e/float-window.e2e.ts 双用例 --- AGENTS.md | 16 +- docs/plans/2026-08-23-free-window-design.md | 82 +++++ dsh.plugin.json | 2 +- package.json | 2 +- scripts/e2e-mount.sh | 7 +- src/client/FreeWindow.tsx | 333 ++++++++++++++++++ src/client/Sidebar.tsx | 137 +++++++- src/client/TabBar.tsx | 15 +- src/client/locales-ar.ts | 3 + src/client/locales-de.ts | 3 + src/client/locales-fr.ts | 3 + src/client/locales-hi.ts | 3 + src/client/locales-id.ts | 3 + src/client/locales-it.ts | 3 + src/client/locales-ja.ts | 3 + src/client/locales-ko.ts | 3 + src/client/locales-nl.ts | 3 + src/client/locales-pl.ts | 3 + src/client/locales-pt.ts | 3 + src/client/locales-ru.ts | 3 + src/client/locales-sv.ts | 3 + src/client/locales-th.ts | 3 + src/client/locales-tr.ts | 3 + src/client/locales-vi.ts | 3 + src/client/locales-zh-HK.ts | 3 + src/client/locales-zh-MO.ts | 3 + src/client/locales-zh-TW.ts | 3 + src/client/locales.ts | 6 + src/client/service.ts | 39 ++- src/client/sidebar.module.css | 138 +++++++- src/client/split-pane.tsx | 4 + src/client/state.ts | 326 ++++++++++++++--- tests/e2e/float-window.e2e.ts | 244 +++++++++++++ tests/free-window.spec.tsx | 370 ++++++++++++++++++++ tests/service.spec.ts | 70 +++- tests/state.spec.ts | 293 +++++++++++++++- tests/tab-bar-context-menu.spec.tsx | 53 ++- tests/tab-bar-middle-click.spec.tsx | 1 + tests/tab-bar-wheel.spec.tsx | 1 + 39 files changed, 2100 insertions(+), 96 deletions(-) create mode 100644 docs/plans/2026-08-23-free-window-design.md create mode 100644 src/client/FreeWindow.tsx create mode 100644 tests/e2e/float-window.e2e.ts create mode 100644 tests/free-window.spec.tsx diff --git a/AGENTS.md b/AGENTS.md index a9b3836..4ceb9bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -536,7 +536,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。 */ @@ -600,6 +600,20 @@ interface OpenTabSeed { --- +## 7.5 自由窗口(v0.16.0) + +任意 tab(内置或插件注册的)可拖出侧边栏,成为悬浮在主会话区域上的**自由窗口**(free window): + +- **拖出**:把标签栏的 tab 拖到**主会话区域**(conversation 列)上——虚线提示浮层标记落区,松开即浮动(`floatTab` reducer:tab 从 pane **移动**进 `SidebarState.floats`,窗口以默认尺寸 480×360 居中于松点、钳入视口;清空的 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:`),刷新原样恢复;`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` 守护令牌读取)。 diff --git a/docs/plans/2026-08-23-free-window-design.md b/docs/plans/2026-08-23-free-window-design.md new file mode 100644 index 0000000..0704137 --- /dev/null +++ b/docs/plans/2026-08-23-free-window-design.md @@ -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 中移除)为浮窗,默认 480×360 居中于松点、钳入视口 | +| 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 防抖持久化落盘。 +- 其余与本文一致。 diff --git a/dsh.plugin.json b/dsh.plugin.json index a230943..a9e2bf5 100644 --- a/dsh.plugin.json +++ b/dsh.plugin.json @@ -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": { diff --git a/package.json b/package.json index b7c0ec8..c070e26 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/e2e-mount.sh b/scripts/e2e-mount.sh index 983d08b..9f3188e 100755 --- a/scripts/e2e-mount.sh +++ b/scripts/e2e-mount.sh @@ -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")" diff --git a/src/client/FreeWindow.tsx b/src/client/FreeWindow.tsx new file mode 100644 index 0000000..4768741 --- /dev/null +++ b/src/client/FreeWindow.tsx @@ -0,0 +1,333 @@ +/** + * 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('[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(null) + const dragRef = useRef(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(null) + // rAF coalescing: pointermove outruns the display, one write per frame. + const frameRef = useRef(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 ( +
{ onRaise() }} + > +
{ + 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} + {float.tab.title} + + { 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={} + /> +
+
+ {renderTab(float.tab, true, float.id)} +
+
{ + 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() }} + /> +
+ ) +} diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index d7c8c07..5f5b036 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -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' @@ -48,7 +48,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' @@ -86,7 +87,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() @@ -682,6 +683,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 @@ -1013,6 +1093,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]) /** @@ -1385,6 +1476,40 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
)} + {/* + 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 => ( + { 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 && ( +
+ {t('floatDropHint')} +
+ )} ) } diff --git a/src/client/TabBar.tsx b/src/client/TabBar.tsx index 8fd8f39..476ca5b 100644 --- a/src/client/TabBar.tsx +++ b/src/client/TabBar.tsx @@ -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) { diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts index 55901f3..c50d987 100644 --- a/src/client/locales-ar.ts +++ b/src/client/locales-ar.ts @@ -66,6 +66,9 @@ export const ar: Record = { closeOtherTabs: 'إغلاق التبويبات الأخرى', closeLeftTabs: 'إغلاق التبويبات على اليسار', closeRightTabs: 'إغلاق التبويبات على اليمين', + moveToFreeWindow: 'النقل إلى نافذة حرة', + floatDropHint: 'حرّك للفتح في نافذة حرة', + dockToSidebar: 'العودة إلى الشريط الجانبي', collapse: 'طي الشريط الجانبي', expand: 'توسيع الشريط الجانبي', collapseBottomPanel: 'طي اللوحة السفلية', diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts index d42b5fe..797c5f8 100644 --- a/src/client/locales-de.ts +++ b/src/client/locales-de.ts @@ -51,6 +51,9 @@ export const de: Record = { 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', diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts index 3bc2de8..5fe9eaa 100644 --- a/src/client/locales-fr.ts +++ b/src/client/locales-fr.ts @@ -58,6 +58,9 @@ export const fr: Record = { 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', diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts index c65772a..3fc4d06 100644 --- a/src/client/locales-hi.ts +++ b/src/client/locales-hi.ts @@ -65,6 +65,9 @@ export const hi: Record = { closeOtherTabs: 'अन्य टैब बंद करें', closeLeftTabs: 'बाएँ टैब बंद करें', closeRightTabs: 'दाएँ टैब बंद करें', + moveToFreeWindow: 'फ़्री विंडो में ले जाएँ', + floatDropHint: 'फ़्री विंडो में खोलने के लिए छोड़ें', + dockToSidebar: 'साइडबार पर वापस जाएँ', collapse: 'साइडबार संक्षिप्त करें', expand: 'साइडबार विस्तृत करें', collapseBottomPanel: 'निचला पैनल संक्षिप्त करें', diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts index 74db07f..90a736d 100644 --- a/src/client/locales-id.ts +++ b/src/client/locales-id.ts @@ -63,6 +63,9 @@ export const id: Record = { 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', diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts index fd93783..f177333 100644 --- a/src/client/locales-it.ts +++ b/src/client/locales-it.ts @@ -56,6 +56,9 @@ export const it: Record = { 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', diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts index ec29559..0c3045f 100644 --- a/src/client/locales-ja.ts +++ b/src/client/locales-ja.ts @@ -65,6 +65,9 @@ export const ja: Record = { closeOtherTabs: '他のタブを閉じる', closeLeftTabs: '左のタブを閉じる', closeRightTabs: '右のタブを閉じる', + moveToFreeWindow: 'フリーウィンドウへ移動', + floatDropHint: '離すとフリーウィンドウで開きます', + dockToSidebar: 'サイドバーへ戻す', collapse: 'サイドバーを折りたたむ', expand: 'サイドバーを展開', collapseBottomPanel: '下パネルを折りたたむ', diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts index 7e3bbd5..a97dda1 100644 --- a/src/client/locales-ko.ts +++ b/src/client/locales-ko.ts @@ -57,6 +57,9 @@ export const ko: Record = { closeOtherTabs: '다른 탭 닫기', closeLeftTabs: '왼쪽 탭 닫기', closeRightTabs: '오른쪽 탭 닫기', + moveToFreeWindow: '자유 창으로 이동', + floatDropHint: '놓으면 자유 창에서 열립니다', + dockToSidebar: '사이드바로 돌아가기', collapse: '사이드바 접기', expand: '사이드바 펼치기', collapseBottomPanel: '하단 패널 접기', diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts index 252d3dc..e15a0e5 100644 --- a/src/client/locales-nl.ts +++ b/src/client/locales-nl.ts @@ -63,6 +63,9 @@ export const nl: Record = { 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', diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts index e4adacd..445a3ca 100644 --- a/src/client/locales-pl.ts +++ b/src/client/locales-pl.ts @@ -67,6 +67,9 @@ export const pl: Record = { 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', diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts index 9c3b209..fee45b6 100644 --- a/src/client/locales-pt.ts +++ b/src/client/locales-pt.ts @@ -48,6 +48,9 @@ export const pt: Record = { 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', diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts index f7fa9f1..c477402 100644 --- a/src/client/locales-ru.ts +++ b/src/client/locales-ru.ts @@ -61,6 +61,9 @@ export const ru: Record = { closeOtherTabs: 'Закрыть остальные вкладки', closeLeftTabs: 'Закрыть вкладки слева', closeRightTabs: 'Закрыть вкладки справа', + moveToFreeWindow: 'Переместить в свободное окно', + floatDropHint: 'Отпустите, чтобы открыть в свободном окне', + dockToSidebar: 'Вернуть на боковую панель', collapse: 'Свернуть боковую панель', expand: 'Развернуть боковую панель', collapseBottomPanel: 'Свернуть нижнюю панель', diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts index fd28a42..436bd14 100644 --- a/src/client/locales-sv.ts +++ b/src/client/locales-sv.ts @@ -48,6 +48,9 @@ export const sv: Record = { 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', diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts index ce0f6f4..1b9b0ee 100644 --- a/src/client/locales-th.ts +++ b/src/client/locales-th.ts @@ -65,6 +65,9 @@ export const th: Record = { closeOtherTabs: 'ปิด Tab อื่น', closeLeftTabs: 'ปิด Tab ด้านซ้าย', closeRightTabs: 'ปิด Tab ด้านขวา', + moveToFreeWindow: 'ย้ายไปยังหน้าต่างอิสระ', + floatDropHint: 'ปล่อยเพื่อเปิดในหน้าต่างอิสระ', + dockToSidebar: 'กลับไปที่แถบข้าง', collapse: 'ย่อแถบด้านข้าง', expand: 'ขยายแถบด้านข้าง', collapseBottomPanel: 'ย่อพาเนลด้านล่าง', diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts index bc38c72..81e9779 100644 --- a/src/client/locales-tr.ts +++ b/src/client/locales-tr.ts @@ -65,6 +65,9 @@ export const tr: Record = { 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', diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts index 5bdfbee..51427be 100644 --- a/src/client/locales-vi.ts +++ b/src/client/locales-vi.ts @@ -65,6 +65,9 @@ export const vi: Record = { 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', diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts index 2dabfbb..352d637 100644 --- a/src/client/locales-zh-HK.ts +++ b/src/client/locales-zh-HK.ts @@ -80,6 +80,9 @@ export const zhHK: Record = { closeOtherTabs: '關閉其他標籤', closeLeftTabs: '關閉左側標籤', closeRightTabs: '關閉右側標籤', + moveToFreeWindow: '移動到自由視窗', + floatDropHint: '放開以在自由視窗中開啟', + dockToSidebar: '返回側邊欄', collapse: '收起側邊欄', expand: '展開側邊欄', collapseBottomPanel: '收起底部面板', diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts index f108901..a65a7dc 100644 --- a/src/client/locales-zh-MO.ts +++ b/src/client/locales-zh-MO.ts @@ -80,6 +80,9 @@ export const zhMO: Record = { closeOtherTabs: '關閉其他標籤', closeLeftTabs: '關閉左側標籤', closeRightTabs: '關閉右側標籤', + moveToFreeWindow: '移動到自由視窗', + floatDropHint: '放開以在自由視窗中開啟', + dockToSidebar: '返回側邊欄', collapse: '收起側邊欄', expand: '展開側邊欄', collapseBottomPanel: '收起底部面板', diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts index e1f8153..ca16cb5 100644 --- a/src/client/locales-zh-TW.ts +++ b/src/client/locales-zh-TW.ts @@ -80,6 +80,9 @@ export const zhTW: Record = { closeOtherTabs: '關閉其他標籤', closeLeftTabs: '關閉左側標籤', closeRightTabs: '關閉右側標籤', + moveToFreeWindow: '移動到自由視窗', + floatDropHint: '放開以在自由視窗中開啟', + dockToSidebar: '返回側邊欄', collapse: '收起側邊欄', expand: '展開側邊欄', collapseBottomPanel: '收起底部面板', diff --git a/src/client/locales.ts b/src/client/locales.ts index f2a4413..fc80a80 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -72,6 +72,9 @@ export const zh = { closeOtherTabs: '关闭其他页签', closeLeftTabs: '关闭左侧页签', closeRightTabs: '关闭右侧页签', + moveToFreeWindow: '移动到自由窗口', + floatDropHint: '松开以在自由窗口中打开', + dockToSidebar: '回到侧边栏', collapse: '折叠侧边栏', expand: '展开侧边栏', collapseBottomPanel: '折叠底部面板', @@ -402,6 +405,9 @@ export const en: Record = { 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', diff --git a/src/client/service.ts b/src/client/service.ts index af9ac1d..ff69670 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -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) } diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index 1c59197..38ec7cf 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -282,6 +282,141 @@ 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); +} + +.floatContent { + flex: 1; + min-height: 0; + min-width: 0; + display: flex; +} + +/* 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 @@ -293,7 +428,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; } diff --git a/src/client/split-pane.tsx b/src/client/split-pane.tsx index 06af947..56a3d1f 100644 --- a/src/client/split-pane.tsx +++ b/src/client/split-pane.tsx @@ -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 (
{ 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 ? ( /* diff --git a/src/client/state.ts b/src/client/state.ts index d576607..2dc3792 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -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,16 @@ 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 (clamped to the viewport). */ +export const FLOAT_DEFAULT_W = 480 +export const FLOAT_DEFAULT_H = 360 let nextIdCounter = 0 /** Unique pane/tab id within one state instance. */ @@ -143,6 +169,14 @@ function maxCounterId(parsed: unknown): number { } walk((parsed as Record | null)?.splits) walk((parsed as Record | 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 | null)?.floats + if (Array.isArray(floats)) { + for (const float of floats) { + if (float !== null && typeof float === 'object') consider((float as Record).id) + } + } return max } @@ -181,6 +215,7 @@ export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seed: bottomHeight: BOTTOM_DEFAULT, bottomOpenedOnce: false, bottomSplits: bottomLeaf, + floats: [], } } @@ -280,10 +315,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 +500,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 +527,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 +553,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 +726,137 @@ 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 { + 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) + const window = clampFloatGeometry( + x - FLOAT_DEFAULT_W / 2, + y - FLOAT_DEFAULT_H / 2, + FLOAT_DEFAULT_W, + FLOAT_DEFAULT_H, + ) + 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 +892,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 +900,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 +1032,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 + 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 +1083,7 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined { // auto-terminal exactly once after the upgrade. bottomOpenedOnce: record.bottomOpenedOnce === true, bottomSplits, + floats, } } @@ -902,6 +1117,42 @@ function uniqueNodeId(id: string, seen: Set, reid: Map): 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 + 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 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 + : 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, reid: Map): SplitNode | undefined { if (node === null || typeof node !== 'object') return undefined @@ -911,46 +1162,13 @@ function sanitizeNode(node: unknown, seen: Set, reid: Map - 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 - // 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 - : 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; diff --git a/tests/e2e/float-window.e2e.ts b/tests/e2e/float-window.e2e.ts new file mode 100644 index 0000000..303385f --- /dev/null +++ b/tests/e2e/float-window.e2e.ts @@ -0,0 +1,244 @@ +/** + * Free-window lane (v0.16.0): against a real `dsh web` instance with the + * npm-packed plugin mounted (scripts/e2e-mount.sh, DSH_E2E_URL). + * + * 1. The tab context menu's "Move to Free Window" floats the seeded Files + * tab: `[data-dsh-float-window]` mounts and the tab leaves the strip. + * 2. The header drag moves the window (geometry committed to the session + * state). + * 3. A reload restores the window from the persisted session state. + * 4. The header context menu's "Dock Back to Sidebar" returns the tab to + * the workbench and removes the window. + * 5. The drag-out gesture itself (HTML5 DnD onto the conversation column) + * floats another tab with the hint overlay showing mid-drag. + * + * Crash discipline mirrors the mount lane: pageerror / plugin console errors + * / dsh-better-sidebar strips fail the test at every step. + */ +import { mkdirSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test, expect, request, type APIRequestContext, type Page } from '@playwright/test' + +const BASE_URL = process.env.DSH_E2E_URL +if (!BASE_URL) { + throw new Error('DSH_E2E_URL is not set — boot a DSH web instance with the plugin mounted and point this lane at it (see scripts/e2e-mount.sh)') +} + +const WORKSPACE_PATH = process.env.DSH_E2E_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-float-workspace') + +const CRASH_STRIP_PATTERNS = [/^dsh-better-sidebar:/, /^\[dsh-better-sidebar\]/] + +let api: APIRequestContext + +async function seedSession(): Promise { + 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 { + 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> => { + 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 { + const sidebar = page.locator('[data-dsh-better-sidebar]') + const stripTexts = await sidebar.locator('div').evaluateAll( + (nodes, patterns) => nodes.filter((node) => { + const text = (node.textContent ?? '').trim() + return patterns.some((pattern) => pattern.test(text)) + }).map((node) => (node.textContent ?? '').trim()), + CRASH_STRIP_PATTERNS, + ) + expect(stripTexts, 'a dsh-better-sidebar error strip is present').toEqual([]) +} + +/** The conversation column's viewport rect — the production drag-out target + * (Sidebar.tsx): the [data-slot="conversation"] WRAPPER may be zero-size (a + * slot pass-through), so the sized element is its parent column. */ +async function conversationBox(page: Page): Promise<{ x: number; y: number; width: number; height: number } | null> { + return await page.evaluate(() => { + const col = document.querySelector('#root [data-slot="conversation"]')?.parentElement + if (col === null || col === undefined) return null + const rect = col.getBoundingClientRect() + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height } + }) +} + +test('float a tab, move the window, reload restores it, dock it back', async ({ page }) => { + const sidebar = await bootExpanded(page) + + // 1. The tab context menu floats the seeded Files home tab. + const filesTab = sidebar.locator('[title="Files"][draggable="true"]').first() + await expect(filesTab).toHaveCount(1) + await filesTab.click({ button: 'right' }) + const floatItem = page.getByRole('menuitem', { name: 'Move to Free Window' }).first() + await expect(floatItem, 'the tab context menu must offer "Move to Free Window"').toHaveCount(1) + await floatItem.click() + const floatWindow = sidebar.locator('[data-dsh-float-window]') + await expect(floatWindow).toHaveCount(1, { timeout: 10_000 }) + // The tab left the strip with the window born over the conversation area + // (center), well clear of the panel's right edge. + const leftBefore = await floatWindow.evaluate((node) => Number.parseFloat((node as HTMLElement).style.left)) + expect(Number.isFinite(leftBefore), `style.left must be numeric, got ${leftBefore}`).toBe(true) + expect(leftBefore).toBeGreaterThan(0) + expect(leftBefore).toBeLessThan(1440 - 480) + await assertNoCrash(page) + + // 2. The header drag moves the window (pointer capture + commit). + const header = floatWindow.locator('[class*="floatHeader"]') + const box = await header.boundingBox() + const conv = await conversationBox(page) + expect(box, 'the float header must have geometry').not.toBeNull() + expect(conv, 'the conversation column must have geometry').not.toBeNull() + const dragToX = conv!.x + conv!.width * 0.35 + const dragToY = conv!.y + conv!.height * 0.35 + await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2) + await page.mouse.down() + await page.mouse.move(dragToX, dragToY, { steps: 12 }) + await page.mouse.up() + await expect + .poll(async () => ( + await floatWindow.evaluate((node) => Number.parseFloat((node as HTMLElement).style.left)) + ), { timeout: 10_000 }) + .not.toBe(leftBefore) + await assertNoCrash(page) + + // 3. A reload restores the window from the persisted session state. Give + // the store's debounced persist a beat first: reloading the instant the + // drag poll resolves can beat the 200ms flush timer. + await page.waitForTimeout(500) + await page.reload({ waitUntil: 'domcontentloaded' }) + await dismissTakeovers(page) + const sidebar2 = page.locator('[data-dsh-better-sidebar]') + await expect(sidebar2.locator('[data-dsh-float-window]')).toHaveCount(1, { timeout: 90_000 }) + await assertNoCrash(page) + + // 4. The header context menu docks the tab back into the sidebar. + const header2 = sidebar2.locator('[data-dsh-float-window] [class*="floatHeader"]') + await header2.click({ button: 'right' }) + const dockItem = page.getByRole('menuitem', { name: 'Dock Back to Sidebar' }).first() + await expect(dockItem, 'the float header context menu must offer "Dock Back to Sidebar"').toHaveCount(1) + await dockItem.click() + await expect(sidebar2.locator('[data-dsh-float-window]')).toHaveCount(0, { timeout: 10_000 }) + await expect(sidebar2.locator('[title="Files"][draggable="true"]').first()).toHaveCount(1) + await assertNoCrash(page) +}) + +test('dragging a tab onto the conversation area floats it (drag-out gesture)', async ({ page }) => { + const sidebar = await bootExpanded(page) + + // Open a terminal through the + menu (a second tab to drag out; the Files + // home tab keeps the pane non-empty afterwards). The terminal tab's title + // is the host shell name once resolved (fallback "Terminal"), so address + // it by POSITION: the strip's second tab. + await sidebar.getByRole('button', { name: 'New tab' }).first().click() + const terminalItem = page.getByRole('menuitem', { name: 'Terminal' }).first() + await expect(terminalItem).toHaveCount(1) + await terminalItem.click() + const stripTabs = sidebar.locator('[class*="tabList"] > [draggable="true"]') + await expect(stripTabs, 'the strip must hold the seeded Files tab plus the terminal').toHaveCount(2, { timeout: 30_000 }) + const terminalTab = stripTabs.nth(1) + const terminalTitle = (await terminalTab.getAttribute('title')) ?? 'Terminal' + + // Drag it onto the conversation column: the hint overlay appears mid-drag + // and the drop floats the tab. ([class*="floatDropHint"] matches BOTH the + // overlay and its label span — expect the pair.) + const tabBox = await terminalTab.boundingBox() + const conv = await conversationBox(page) + expect(tabBox, 'the terminal tab must have geometry').not.toBeNull() + expect(conv, 'the conversation column must have geometry').not.toBeNull() + const hint = sidebar.locator('[class*="floatDropHint"]') + await page.mouse.move(tabBox!.x + tabBox!.width / 2, tabBox!.y + tabBox!.height / 2) + await page.mouse.down() + await page.mouse.move(conv!.x + conv!.width / 2, conv!.y + conv!.height / 2, { steps: 12 }) + await expect(hint, 'the drag-out hint must mark the conversation column mid-drag').toHaveCount(2, { timeout: 10_000 }) + await page.mouse.up() + const floatWindow = sidebar.locator('[data-dsh-float-window]') + await expect(floatWindow, 'the drop must float the tab').toHaveCount(1, { timeout: 10_000 }) + await expect(hint).toHaveCount(0) + // The terminal tab left the strip (only Files remains); the window shows + // its title. + await expect(stripTabs).toHaveCount(1) + await expect(floatWindow.locator('[class*="floatTitle"]')).toHaveText(terminalTitle) + await assertNoCrash(page) +}) diff --git a/tests/free-window.spec.tsx b/tests/free-window.spec.tsx new file mode 100644 index 0000000..694c807 --- /dev/null +++ b/tests/free-window.spec.tsx @@ -0,0 +1,370 @@ +/** + * Free-window interaction tests, against the REAL Sidebar shell + real + * store/service (the sidebar-crash spec's mount pattern). + * + * 1. Drag-out detection: while a tab drag (the body flag the tab strips + * maintain) hovers the conversation column, a hint overlay marks the drop + * zone; the drop floats the tab at the release point and the pane loses + * it. Targets inside the panel host stay the panes' own business. + * 2. The tab context menu's "move to free window" floats the tab at the + * conversation column's center (no drop point exists). + * 3. The window itself: header drag moves (rAF direct writes, store commit + * on release), the SE corner resizes, releasing the header over a pane + * docks the tab back (the pane highlights live), and the X closes the tab + * with its window. + */ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { act } from 'react-dom/test-utils' + +// The act() environment flag (React 18.2 reads it before flushing effects). +;(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true + +import { Sidebar } from '../src/client/Sidebar.tsx' +import { createSidebarStore, type SidebarStore, floatTab as floatTabReducer } from '../src/client/state.ts' +import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts' +import { serializeDrag } from '../src/client/TabBar.tsx' + +/** jsdom has no WebSocket; the agent-terminals push effect constructs one on mount. */ +class FakeWebSocket { + onmessage: ((event: { data: unknown }) => void) | null = null + onclose: (() => void) | null = null + onerror: (() => void) | null = null + close = (): void => {} + constructor(_url: string) {} +} + +/** The app shell's center column: #root > col > [data-slot="conversation"], + * with a real-looking rect so the drag-out zone can be hit-tested. */ +const CONV_RECT = { left: 100, right: 700, top: 50, bottom: 600, width: 600, height: 550 } + +function fakeConversationColumn(): HTMLElement { + const rootEl = document.createElement('div') + rootEl.id = 'root' + const col = document.createElement('div') + const conv = document.createElement('div') + conv.setAttribute('data-slot', 'conversation') + col.append(conv) + rootEl.append(col) + document.body.append(rootEl) + col.getBoundingClientRect = () => CONV_RECT as DOMRect + return col +} + +interface Mounted { + container: HTMLDivElement + store: SidebarStore + service: BetterSidebarService + unmount: () => void +} + +function mountSidebar(): Mounted { + vi.stubGlobal('WebSocket', FakeWebSocket) + if (!('ResizeObserver' in globalThis)) { + vi.stubGlobal('ResizeObserver', class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + }) + } + const container = document.createElement('div') + document.body.append(container) + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerTab({ id: 'notes', title: 'Notes', single: true, component: () => createElement('div', null, 'notes body') }) + store.setPrefs({ ...store.getPrefs(), openByDefault: true }) + store.setSession('s1') + const localeSnapshot = { active: 'en' } + const sessionsSnapshot = { current: 's1', byId: { s1: { cwd: '/tmp' } } } + const ctx = { + locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot }, + sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } }, + betterSidebar: service, + } + const root: Root = createRoot(container) + act(() => { root.render(createElement(Sidebar, { ctx: ctx as never, store })) }) + return { + container, + store, + service, + unmount: () => { + act(() => { root.unmount() }) + container.remove() + }, + } +} + +/** A document-level drag event with a payload-carrying dataTransfer (jsdom + * has no DragEvent/DataTransfer). Dispatched AT document, where the shell's + * capture listeners live. */ +function dispatchDrag(type: 'dragover' | 'drop', x: number, y: number, payload?: string): void { + const event = new Event(type, { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'clientX', { value: x }) + Object.defineProperty(event, 'clientY', { value: y }) + Object.defineProperty(event, 'dataTransfer', { value: { getData: () => payload ?? '' } }) + document.dispatchEvent(event) +} + +const flushFrame = async (): Promise => { + await act(async () => { await new Promise(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('[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('[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('[role="menuitem"]')] + expect(items.length).toBeGreaterThan(0) + act(() => { items[0]!.click() }) + const state = store.getSnapshot().state! + expect(state.floats).toHaveLength(1) + expect(state.floats[0]!.tab.id).toBe('notes') + // Born at the conversation column's center, minus half the default + // size (480x360), clamped into the 1024x768 viewport. + expect(state.floats[0]).toMatchObject({ x: 400 - 240, y: 325 - 180, w: 480, h: 360 }) + expect(container.querySelector('[data-dsh-float-window]')).not.toBeNull() + } finally { + unmount() + } + }) + + it('header drag moves the window (rAF direct writes, store commit on release)', async () => { + fakeConversationColumn() + const { container, store, service, unmount } = mountSidebar() + try { + act(() => { service.openTab({ type: 'notes', title: 'Notes' }) }) + act(() => { store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) }) + const win = container.querySelector('[data-dsh-float-window]')! + const header = win.querySelector('[class*="floatHeader"]')! + expect(win.style.left).toBe('272px') + // Grab at (300, 220) — the drag records the window's own origin, so + // moving the pointer to (200, 250) lands the window at (172, 234). + act(() => { + header.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 300, clientY: 220, button: 0 })) + header.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 200, clientY: 250 })) + }) + await flushFrame() + expect(win.style.left).toBe('172px') + expect(win.style.top).toBe('234px') + // The store is untouched mid-drag (DOM is the only record). + expect(store.getSnapshot().state!.floats[0]!.x).toBe(272) + act(() => { header.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 200, clientY: 250, button: 0 })) }) + expect(store.getSnapshot().state!.floats[0]).toMatchObject({ x: 172, y: 234 }) + } finally { + unmount() + } + }) + + it('the SE corner resizes the window and persists on release', async () => { + fakeConversationColumn() + const { container, store, service, unmount } = mountSidebar() + try { + act(() => { service.openTab({ type: 'notes', title: 'Notes' }) }) + act(() => { store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) }) + const win = container.querySelector('[data-dsh-float-window]')! + const handle = win.querySelector('[class*="floatResize"]')! + act(() => { + handle.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 752, clientY: 564, button: 0 })) + handle.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 852, clientY: 614 })) + }) + await flushFrame() + expect(win.style.width).toBe('580px') + expect(win.style.height).toBe('410px') + act(() => { handle.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 852, clientY: 614, button: 0 })) }) + expect(store.getSnapshot().state!.floats[0]).toMatchObject({ w: 580, h: 410 }) + } finally { + unmount() + } + }) + + it('a pointerdown on the portaled header menu must not start a header drag', async () => { + fakeConversationColumn() + const { container, store, service, unmount } = mountSidebar() + try { + act(() => { service.openTab({ type: 'notes', title: 'Notes' }) }) + act(() => { store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) }) + const win = container.querySelector('[data-dsh-float-window]')! + const header = win.querySelector('[class*="floatHeader"]')! + expect(win.style.left).toBe('272px') + act(() => { header.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 300, clientY: 220 })) }) + // The menu list is PORTALED to document.body, yet its events still + // bubble through the React tree into the header's onPointerDown — + // without the containment guard (real-browser regression, e2e-caught: + // the hijacked drag preventDefaults the row's pointerdown and captures + // the pointer, so the row's click never fires) this starts a move. + const dockRow = [...document.querySelectorAll('[role="menuitem"]')] + .find(row => /回到侧边栏|Dock Back to Sidebar/.test(row.textContent ?? ''))! + act(() => { + dockRow.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 300, clientY: 220, button: 0 })) + header.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 200, clientY: 250 })) + }) + await flushFrame() + expect(win.style.left).toBe('272px') + act(() => { dockRow.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) + expect(store.getSnapshot().state!.floats).toHaveLength(0) + expect(paneTabs(store)).toContain('notes') + } finally { + unmount() + } + }) + + it('releasing the header over a pane docks the tab back (live pane highlight)', async () => { + fakeConversationColumn() + const { container, store, service, unmount } = mountSidebar() + try { + act(() => { service.openTab({ type: 'notes', title: 'Notes' }) }) + act(() => { store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) }) + const win = container.querySelector('[data-dsh-float-window]')! + const header = win.querySelector('[class*="floatHeader"]')! + // The workbench pane covers (600, 400) for the hit-test. + const pane = container.querySelector('[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('[data-dsh-float-window]')! + const close = win.querySelector('[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() + 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() + } + }) +}) diff --git a/tests/service.spec.ts b/tests/service.spec.ts index aab61ab..7282803 100644 --- a/tests/service.spec.ts +++ b/tests/service.spec.ts @@ -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 @@ -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) + }) + }) }) diff --git a/tests/state.spec.ts b/tests/state.spec.ts index 2d1f952..482558c 100644 --- a/tests/state.spec.ts +++ b/tests/state.spec.ts @@ -1,10 +1,12 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { - activateTab, allLeaves, BOTTOM_DEFAULT, BOTTOM_MIN, closeTab, createSidebarStore, - insertLeafAt, makeDefaultState, migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab, - openTabInActivePane, patchTab, resizeSplit, resizeSplitIn, sanitizeState, setBottomHeight, + activateTab, allLeaves, BOTTOM_DEFAULT, BOTTOM_MIN, closeFloatByTab, closeTab, createSidebarStore, + dockFloat, FLOAT_MIN_H, FLOAT_MIN_W, floatTab, floatWithTab, insertLeafAt, makeDefaultState, + migrateBottomTabs, moveFloat, moveTab, moveTabToEdge, openDiffTab, + openTabInActivePane, patchTab, raiseFloat, reconcileAgentTerminals, resizeFloat, resizeSplit, + resizeSplitIn, sanitizeState, setBottomHeight, splitPane, tabOpenIn, toggleBottomPanel, toggleExpanded, togglePanel, - type SidebarState, type SidebarTab, type SplitNode, + type SidebarLeaf, type SidebarState, type SidebarTab, type SplitNode, } from '../src/client/state.ts' describe('sidebar state', () => { @@ -896,3 +898,286 @@ describe('v0.12.0 store additions', () => { }) }) }) + +describe('free windows (v0.16.0)', () => { + // Node environment: no window → the viewport clamps are Infinity and only + // the size floors apply; the geometry tests stub a window where clamping + // is the point under test. + const state = (): SidebarState => makeDefaultState() + + it('floatTab moves a docked tab into floats (pane keeps its other tabs)', () => { + let s = state() + const git = { id: 'git', type: 'git' as const, title: 'Git' } + const term = { id: 'term:1', type: 'terminal' as const, title: 'Term' } + s = openTabInActivePane(s, git) + s = openTabInActivePane(s, term) + const floated = floatTab(s, git.id, 300, 200) + const leaf = floated.splits as SidebarLeaf + expect(leaf.tabs).toHaveLength(2) + expect(leaf.tabs.some(tab => tab.id === 'git')).toBe(false) + // The pane's active pointer fell to the last remaining tab. + expect(leaf.active).toBe('term:1') + expect(floated.floats).toHaveLength(1) + expect(floated.floats[0]!.tab).toEqual(git) + // Default size centered on the drop point (no viewport → floors only). + expect(floated.floats[0]).toMatchObject({ x: 300 - 240, y: 200 - 180, w: 480, h: 360 }) + expect(floated.activePane).toBe(leaf.id) + }) + + it('floatTab collapses a pane it empties and repoints the active pane', () => { + let s = state() + const paneId = (s.splits as SidebarLeaf).id + s = splitPane(s, 'row') + const fresh = allLeaves(s.splits).find(leaf => leaf.id !== paneId)! + s = { ...s, activePane: fresh.id } + s = openTabInActivePane(s, { id: 'term:1', type: 'terminal', title: 'T' }) + expect(allLeaves(s.splits).find(leaf => leaf.id === fresh.id)!.tabs).toHaveLength(1) + const floated = floatTab(s, 'term:1', 100, 100) + expect(floated.floats).toHaveLength(1) + expect(allLeaves(floated.splits).some(leaf => leaf.id === fresh.id)).toBe(false) + expect(floated.activePane).toBe(paneId) + }) + + it('floatTab with an unknown or already-floating tab id is a strict no-op', () => { + let s = state() + const git = { id: 'git', type: 'git' as const, title: 'Git' } + s = openTabInActivePane(s, git) + expect(floatTab(s, 'nope', 0, 0)).toBe(s) + const floated = floatTab(s, 'git', 50, 50) + expect(floatTab(floated, 'git', 60, 60)).toBe(floated) + }) + + it('moveFloat / resizeFloat clamp to the viewport (floors always)', () => { + const g = globalThis as Record + const previous = g.window + g.window = { innerWidth: 1024, innerHeight: 768 } + try { + let s = state() + const git = { id: 'git', type: 'git' as const, title: 'Git' } + s = floatTab(openTabInActivePane(s, git), 'git', 512, 384) + const id = s.floats[0]!.id + // Negative position clamps to 0; the window stays fully inside. + let moved = moveFloat(s, id, -50, -50) + expect(moved.floats[0]).toMatchObject({ x: 0, y: 0 }) + // Beyond the right/bottom edge clamps the top-left so the window fits. + moved = moveFloat(moved, id, 5000, 5000) + expect(moved.floats[0]!.x).toBe(1024 - 480) + expect(moved.floats[0]!.y).toBe(768 - 360) + // No-op move returns the same reference (no persist churn). + expect(moveFloat(moved, id, moved.floats[0]!.x, moved.floats[0]!.y)).toBe(moved) + // Resize: floors, viewport ceiling, and the SE-corner anchor (x/y keep). + let resized = resizeFloat(moved, id, 10, 10) + expect(resized.floats[0]).toMatchObject({ w: FLOAT_MIN_W, h: FLOAT_MIN_H }) + resized = resizeFloat(resized, id, 5000, 5000) + expect(resized.floats[0]!.w).toBe(1024 - resized.floats[0]!.x) + expect(resized.floats[0]!.h).toBe(768 - resized.floats[0]!.y) + // No-op resize and unknown ids return the same reference. + expect(resizeFloat(resized, id, resized.floats[0]!.w, resized.floats[0]!.h)).toBe(resized) + expect(resizeFloat(resized, 'nope', 400, 400)).toBe(resized) + } finally { + if (previous === undefined) delete g.window + else g.window = previous + } + }) + + it('raiseFloat moves a window to the top and is idempotent at the top', () => { + let s = state() + s = floatTab(openTabInActivePane(s, { id: 'a', type: 'db', title: 'a' }), 'a', 100, 100) + s = floatTab(openTabInActivePane(s, { id: 'b', type: 'db', title: 'b' }), 'b', 100, 100) + s = floatTab(openTabInActivePane(s, { id: 'c', type: 'db', title: 'c' }), 'c', 100, 100) + expect(s.floats.map(f => f.tab.id)).toEqual(['a', 'b', 'c']) + const raised = raiseFloat(s, s.floats[0]!.id) + expect(raised.floats.map(f => f.tab.id)).toEqual(['b', 'c', 'a']) + // Already topmost / unknown id → same reference (no notify churn). + expect(raiseFloat(raised, raised.floats[2]!.id)).toBe(raised) + expect(raiseFloat(raised, 'float:none')).toBe(raised) + // A single window has nothing to raise: same reference. + let single = state() + single = floatTab(single, (single.splits as SidebarLeaf).tabs[0]!.id, 0, 0) + expect(single.floats).toHaveLength(1) + expect(raiseFloat(single, single.floats[0]!.id)).toBe(single) + }) + + it('dockFloat lands the tab in the target pane (or the active pane) and activates it', () => { + let s = state() + const git = { id: 'git', type: 'git' as const, title: 'Git' } + s = floatTab(openTabInActivePane(s, git), 'git', 100, 100) + const floatId = s.floats[0]!.id + // Explicit target pane. + s = splitPane(s, 'row') + const target = allLeaves(s.splits).find(leaf => leaf.tabs.length === 0)! + const docked = dockFloat(s, floatId, target.id) + expect(docked.floats).toHaveLength(0) + expect(allLeaves(docked.splits).find(leaf => leaf.id === target.id)!.tabs.map(tab => tab.id)).toEqual(['git']) + expect(docked.activePane).toBe(target.id) + // Default target: the active pane. + let s2 = state() + const home = (s2.splits as SidebarLeaf).tabs[0]! + s2 = splitPane(s2, 'row') + s2 = floatTab(s2, home.id, 100, 100) + const active = s2.activePane! + const docked2 = dockFloat(s2, s2.floats[0]!.id) + expect(docked2.floats).toHaveLength(0) + expect(allLeaves(docked2.splits).find(leaf => leaf.id === active)!.tabs.some(tab => tab.id === home.id)).toBe(true) + expect(docked2.activePane).toBe(active) + // Stale target falls back to the right tree's first leaf. + let s3 = state() + const home3 = (s3.splits as SidebarLeaf).tabs[0]! + s3 = floatTab(s3, home3.id, 0, 0) + const docked3 = dockFloat(s3, s3.floats[0]!.id, 'pane:gone') + expect(docked3.floats).toHaveLength(0) + expect(allLeaves(docked3.splits).some(leaf => leaf.tabs.some(tab => tab.id === home3.id))).toBe(true) + // Unknown float id is a strict no-op. + expect(dockFloat(docked3, 'float:gone')).toBe(docked3) + }) + + it('tabOpenIn / patchTab / floatWithTab see floating tabs', () => { + let s = state() + const git = { id: 'git', type: 'git' as const, title: 'Git' } + s = floatTab(openTabInActivePane(s, git), 'git', 100, 100) + expect(tabOpenIn(s, 'git')).toBe(true) + expect(floatWithTab(s, 'git')!.id).toBe(s.floats[0]!.id) + const patched = patchTab(s, 'git', { title: 'Git 2', path: '/x' }) + expect(patched.floats[0]!.tab).toMatchObject({ title: 'Git 2', path: '/x' }) + expect(allLeaves(patched.splits).some(leaf => leaf.tabs.some(tab => tab.id === 'git'))).toBe(false) + }) + + it('closeFloatByTab removes exactly the window holding the tab', () => { + let s = state() + const git = { id: 'git', type: 'git' as const, title: 'Git' } + const term = { id: 'term:1', type: 'terminal' as const, title: 'T' } + s = openTabInActivePane(s, git) + s = openTabInActivePane(s, term) + s = floatTab(s, 'git', 50, 50) + s = floatTab(s, 'term:1', 60, 60) + expect(s.floats).toHaveLength(2) + const after = closeFloatByTab(s, 'git') + expect(after.floats.map(f => f.tab.id)).toEqual(['term:1']) + expect(closeFloatByTab(after, 'git')).toBe(after) + }) + + it('openTabInActivePane focuses a floating tab by raising (id safety net)', () => { + let s = state() + const git = { id: 'git', type: 'git' as const, title: 'Git' } + const term = { id: 'term:1', type: 'terminal' as const, title: 'T' } + s = floatTab(openTabInActivePane(s, git), 'git', 50, 50) + s = floatTab(openTabInActivePane(s, term), 'term:1', 60, 60) + // Reopening the floated id must NOT duplicate: it raises the window. + const after = openTabInActivePane(s, { id: 'git', type: 'git', title: 'Git' }) + expect(after.floats.map(f => f.tab.id)).toEqual(['term:1', 'git']) + expect(allLeaves(after.splits).some(leaf => leaf.tabs.some(tab => tab.id === 'git'))).toBe(false) + }) + + it('reconcileAgentTerminals removes a vanished FLOATED agent terminal with its window', () => { + let s = state() + s = openTabInActivePane(s, { id: 'agent:u1', type: 'terminal', title: 'A1' }) + s = floatTab(s, 'agent:u1', 50, 50) + s = openTabInActivePane(s, { id: 'agent:u2', type: 'terminal', title: 'A2' }) + expect(s.floats).toHaveLength(1) + const after = reconcileAgentTerminals(s, [{ uuid: 'u2', title: 'A2' }]) + expect(after.floats).toHaveLength(0) + expect(allLeaves(after.splits).flatMap(leaf => leaf.tabs).some(tab => tab.id === 'agent:u2')).toBe(true) + }) + + describe('sanitizeState (floats)', () => { + const g = globalThis as Record + 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 => ({ + 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() + 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) + }) + }) +}) diff --git a/tests/tab-bar-context-menu.spec.tsx b/tests/tab-bar-context-menu.spec.tsx index af4e817..7392e40 100644 --- a/tests/tab-bar-context-menu.spec.tsx +++ b/tests/tab-bar-context-menu.spec.tsx @@ -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 onActivate: ReturnType + onFloatTab: ReturnType 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