feat(ui): 自由窗口——tab 拖出侧边栏成为可移动/缩放/置顶的浮动窗口 (v0.16.0)

- 拖出:标签栏 tab 拖到主会话列(HTML5 DnD 检测 + 虚线提示浮层)或右键菜单
  「移动到自由窗口」(右/底面板共用 TabBar),tab 从 pane 移动进 SidebarState.floats
- 窗口(FreeWindow,渲染于 [data-dsh-panel-host] z-42):头部 pointer 拖动
  (rAF 直写 + 松手提交)、拖到 pane 高亮停靠(center 合并)、SE 角缩放、
  点击置顶、头部右键(回到侧边栏/关闭)、X = closeTab 走正常生命周期
- 服务语义:openTab dedupe/id 聚焦浮动 tab = 置顶窗口(不展开面板);
  closeTab/activateTab 兼顾 floats;agent 终端 reconcile 覆盖浮窗;
  features 单调新增 'floatWindows',版本 0.16.0
- 持久化:floats 随会话 localStorage,sanitizeState 宽容校验 + 几何钳制,
  uid 播种覆盖 float 前缀;i18n 三键 19 语言同步
- 关键修复(e2e 发现):portal 覆盖层(头部 Menu)的合成事件沿 React 树
  冒泡回拖拽表面,劫持菜单行点击——拖拽起点加 DOM 同源守卫(jsdom 不复现)
- e2e-mount.sh:缺省 tarball 改取 mtime 最新(旧字典序选择会把冒烟挂到
  过期产物);新增 tests/e2e/float-window.e2e.ts 双用例
This commit is contained in:
Menghuan1918
2026-08-23 23:44:18 +08:00
parent d6d8db0416
commit f17a1b30ac
39 changed files with 2100 additions and 96 deletions
+36 -3
View File
@@ -22,8 +22,8 @@
import type { ReactNode } from 'react'
import type { Context } from '../context-types.ts'
import {
activateTab as activateTabReducer, allLeaves, closeTab as closeTabReducer, leafWithTab,
openTabInActivePane, patchTab, tabOpenIn, togglePanel, treeOf,
activateTab as activateTabReducer, allLeaves, closeTab as closeTabReducer, closeFloatByTab, floatWithTab,
leafWithTab, openTabInActivePane, patchTab, raiseFloat, tabOpenIn, togglePanel, treeOf,
type SidebarSnapshot, type SidebarState, type SidebarStore, type SidebarTab,
} from './state.ts'
import { isNarrowWidth } from './breakpoints.ts'
@@ -470,7 +470,7 @@ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDes
* The plugin version this service instance reports. Keep in lockstep with
* `package.json`'s version — `tests/service.spec.ts` asserts the pair.
*/
export const SIDEBAR_SERVICE_VERSION = '0.15.2'
export const SIDEBAR_SERVICE_VERSION = '0.16.0'
/**
* Monotonic capability list consumers use to gate new API usage (features
@@ -485,6 +485,9 @@ export const SIDEBAR_SERVICE_VERSION = '0.15.2'
* - 'pluginSettings': SidebarSettingsDeclaration.pluginToggles/render
* - 'urlTarget' (v0.13.0): TabDescriptor.urlTarget (external-link claims)
* - 'settingSelect': SidebarSettingToggle type 'select' (options/multi)
* - 'floatWindows' (v0.16.0): tabs float as free windows — openTab's dedupe/
* id focus targets RAISE the floating window (never duplicate the tab or
* expand panels), closeTab on a floating tab closes it with its window.
*/
export const SIDEBAR_FEATURES = [
'badge',
@@ -497,6 +500,7 @@ export const SIDEBAR_FEATURES = [
'pluginSettings',
'urlTarget',
'settingSelect',
'floatWindows',
] as const
/** Run one plugin callback; a throw is logged and never breaks the caller. */
@@ -651,6 +655,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined)
const key = dedupeKey?.(tab)
const inputTabs = allLeaves(state.splits).concat(allLeaves(state.bottomSplits)).flatMap(leaf => leaf.tabs)
.concat(state.floats.map(f => f.tab))
const existedByKey = key !== undefined
&& inputTabs.some(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key)
const existedById = tabOpenIn(state, tab.id)
@@ -675,12 +680,24 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
} else {
// A focus happened: resolve the tab that is actually active now and
// report THAT to onActivate (never the caller's un-inserted seed).
// The pool covers free windows too — a floating instance focuses by
// raising, and the callback must see the real (patched) tab.
const candidates = allLeaves(landed.splits).concat(allLeaves(landed.bottomSplits)).flatMap(leaf => leaf.tabs)
.concat(landed.floats.map(f => f.tab))
activated = key !== undefined
? candidates.find(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key)
: candidates.find(candidate => candidate.id === tab.id)
activated ??= tab
}
// A CONTENT open that focuses an existing FLOATING tab is already in
// sight (free windows render regardless of panel state): expanding a
// panel for it would point the user at a pane the content is not in.
if (
!isCreation
&& floatWithTab(landed, activated?.id ?? tab.id) !== undefined
) {
return landed
}
// A CONTENT open (file / browser) must land in sight: when the panel
// hosting the landing pane is collapsed, expand it. On narrow
// viewports the two workbenches merge into one drawer, so the drawer
@@ -728,6 +745,12 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
// Unknown tab ids are a strict no-op: no state churn, no notify, no
// pointless localStorage rewrite (mirrors updateTab's short-circuit).
if (!tabOpenIn(state, tabId)) return state
// A floating tab closes WITH its window — the float is the tab's pane.
const float = floatWithTab(state, tabId)
if (float !== undefined) {
closed = float.tab
return closeFloatByTab(state, tabId)
}
const paneId = findPaneIdOf(state, tabId)
const leaf = leafWithTab(state[treeOf(state, paneId)], tabId)
closed = leaf?.tabs.find(tab => tab.id === tabId)
@@ -764,6 +787,12 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
store.reduce((state) => {
// Unknown tab ids are a strict no-op (no state churn / notify).
if (!tabOpenIn(state, tabId)) return state
// A floating tab "activates" by raising its window — no pane switch.
const float = floatWithTab(state, tabId)
if (float !== undefined) {
activated = float.tab
return raiseFloat(state, float.id)
}
const paneId = findPaneIdOf(state, tabId)
const leaf = leafWithTab(state[treeOf(state, paneId)], tabId)
activated = leaf?.tabs.find(tab => tab.id === tabId)
@@ -825,6 +854,10 @@ function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescri
const existing = leaf.tabs.find(t => t.type === tab.type && dedupeKey!(t) === key)
if (existing !== undefined) return activateTabReducer(state, leaf.id, existing.id)
}
// A floating instance focuses by raising its window (no duplicate tab,
// no panel expansion — the window is the tab's pane).
const floated = state.floats.find(f => f.tab.type === tab.type && dedupeKey!(f.tab) === key)
if (floated !== undefined) return raiseFloat(state, floated.id)
}
return openTabInActivePane(state, tab)
}