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:
+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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user