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