feat(injection): 统一面板宿主注入重构——含块层+几何写路径+chunk缓存+锚点弱化+桌面/移动适配 (#232)
* feat(injection): 统一面板宿主——固定含块层 + 挂载自检降级 + body 锚点守卫 - Sidebar 渲染根包 [data-dsh-panel-host](fixed inset-0 pointer-events:none) - 面板/开关簇由 fixed 改 absolute(层内视口坐标等价),免疫套壳中间层 transform 劫持 - 挂载后一次性几何自检:页面级 transform 时降级 absolute + rAF 视口同步 - MutationObserver(body childList)守卫锚点被宿主清空时重挂 * perf(injection): 几何写路径统一为 writeGeometry + 面板 layout 隔离 - 拖拽帧与打开态共用单一推挤写入器(--dsh-sidebar-width/height) - .panel/.bottomPanel contain: layout style——内部重排不外泄 - 拖拽期(body[data-dsh-sidebar-dragging])才挂 will-change: transform * perf(chunks): HMR 激活保留未变 chunk 的已解析 exports + ETag HEAD 重验证 - 新增 revalidateChunksOnReactivate():激活时 HEAD 每个已加载 chunk 对比 ETag, 未变保留内存缓存(跳过重注入/重执行),变更或不可达则 fail-open 重取 - 测试夹具与孤儿缓存条目不跨激活存活 - index.tsx 激活路径由 resetChunks() 改为 revalidateChunksOnReactivate() * fix(layout): 推挤锚点改 data 属性 + #root calc 宽度防桌面壳溢出 - nth-child(2) → #root [data-dsh-frame] > [data-pane="conversation"](实证同元素) - #root 宽度 calc(100% - var) 与 margin 同步过渡:web 视觉不变(实测 1140+300=1440 无溢出), Desktop 壳(#208)width:100% 的加性溢出根因消除 * feat(desktop): 桌面信号自动探测 + win32 顶栏 overlay 自动避让 + 键盘/safe-area 适配 - 新增 desktop-env.ts:解析 dsh-desktop-mode/platform URL 信号与 preload 标记 - win32 advanced 壳自动启用 titleBarCompat(32px overlay),手动 pref 仍可覆盖 - visualViewport 键盘 inset:底部面板/窄屏抽屉键盘弹起时上移 - 面板与开关簇 safe-area-inset 适配(env() 桌面端为 0) - 单测 7 例(含 URLSearchParams 前导 ? 剥除修复) * docs+e2e: 注入方案实施记录(路线B关闭、偏差记录、基线数据)+ 契约同步 + e2e 新断言 - 设计文档:路线 B 标记关闭、§8.1 改单 PR 步骤(含 commit 对照)、拖拽基线数据、实施偏差记录 - AGENTS.md §8:data-dsh-panel-host 含块层、推挤锚点契约(禁 nth-child)、桌面信号 - mount.e2e:data-dsh-panel-host 断言 + win32 advanced 桌面信号场景 * fix(cr): 响应 #232 CR 三条 findings - P1 降级同步:按未修正几何判定(跟踪自身补偿量),祖先 transform 消失才退出循环—— 修正后矩形不再被误判为无需修正(自清除缺陷) - P1 chunk 重验证成为 loadChunk 屏障:重验证挂起期间不提供缓存,杜绝 HMR 竞态陈旧 exports; 测试夹具加载不再被误记为生产加载 - P2 键盘 inset:innerHeight - (vv.height + vv.offsetTop),并监听 scroll(offsetTop 变化) - 新增屏障单测 1 例;设计文档偏差记录更新 * fix(chunks): 屏障 HEAD 加超时兜底 + resetChunks 清 revalidation - revalidateChunksOnReactivate 的 HEAD 与 recordEtag 均带 AbortSignal.timeout(5s):路由挂起时 fail-open(drop + 重取), 屏障不再可能无限期阻塞所有懒加载 chunk(CR #232 P1 修复的健壮性缺口) - resetChunks 同步清 revalidation:重验证挂起期间重置后, loadChunk 不再等待已丢弃状态的孤儿任务(finally 身份判断 no-op) - 单测 +2:HEAD 挂起超时 fail-open(fake AbortSignal.timeout 触发中止)、 resetChunks 清屏障后 loadChunk 立即放行 * fix(deps): align @deepseek-ai/* peers with DSH 0.1.0-rc.8 - peer/devDependencies: 13 个 @deepseek-ai/* 从 ^0.1.0-rc.7 升至 ^0.1.0-rc.8;删除随 rc.8 消失的 dsh-client-web-react / dsh-client-schema-form (无 rc.8 发布、rc.8 依赖树与壳 bundle 零引用、插件 src 零引用); cordis peer/devDep 同步升 ^4.0.0-rc.8 - 版本 0.13.1 -> 0.14.0(package.json / dsh.plugin.json / SIDEBAR_SERVICE_VERSION 三处同步) - CHUNK_EXTERNALS / CLIENT_EXTERNALS / CLIENT_REQUIRE_ALLOWED 三处镜像 同步删除两个死包(primitives/slots 仍是 rc.8 壳静态模块表种子,保留) - CI 挂载冒烟钉版 @deepseek-ai/dsh@0.1.0-rc.7 -> rc.8 - pnpm clean --lockfile 全量重解析:lockfile 内 @deepseek-ai/* rc.7 残留 清零(传递链同升 rc.8,杜绝 #206 式混用树);pnpm-workspace.yaml minimumReleaseAgeExclude 按 #207 模式整体换为 rc.8 列表 - 文档同步:README 安装说明、AGENTS.md CI 钉版、外部插件指南与设计文档 cordis 示例、issue 模板版本示例 * feat(prefs): editorExplorer 默认独立(关)——文件各自新开 tab - SIDEBAR_PREFS_DEFAULTS 与 host PrefsSchema 的 editorExplorer 默认 true -> false:新会话树点击/打开文件按 path 新开 tab,无路径窗口即 纯资源管理器;合并模式保留为可选手动开启(持久化过的用户设置不受影响) - 测试同步:prefs.spec 默认断言翻转、plugin-shape.spec 与 smoke.spec 默认对象翻转;editor-host 合并模式用例显式开启 editorExplorer(split 用例本已显式);side-card-section-rows 的 select 行用例固定选中 Merged - e2e 重写:mount.e2e 文件打开流程从 in-place 断言改为独立模式 (新开 hello.txt tab、Files home tab 保留),文件头注释同步 - 契约文档同步:AGENTS.md §3.4 editor 行默认描述翻转 + CI 冒烟描述、 README/README_EN v0.13.0 特性描述 + v0.14.0 changelog * fix(client): rc.8 模块系统经 ctx.modules 注入 + chunk 副本全局共享 rc.8 不再暴露 window.__DSH_MODULES__ 页面全局(改由 ctx.modules 服务 提供),chunk 外部依赖解析全面失效(terminal/editor/mermaid 全部 "client module system unavailable")。修复: - src/client/index.tsx: inject 增加 'modules'(Cordis 服务访问守卫, 此前直接读 ctx.modules 抛 "cannot get property modules without inject");apply() 激活时 setChunkModuleSystem(ctx.modules) - package.json dsh.client.inject 增加 @deepseek-ai/dsh-client-modules - chunk-loader: 注入写插件自有页面全局 __dshSidebarModuleSystem__—— 懒加载 chunk bundle 各自内联一份 loader(rc.7 靠官方全局回退工作, rc.8 全局消失后副本注入变量恒空);moduleSystem() 按 注入变量 → 插件全局 → rc.7 全局回退解析 - context-types: Context 镜像补 modules 服务声明(client half) - e2e: assertNoCrash 失败时输出错误条文本;独立模式断言修正 (Files 数量按存在性、mermaid 流程先切回 Files、md 预览 locator 去掉 重复的 [data-dsh-better-sidebar] 嵌套) 验证:typecheck / 639 测试全绿;本地 rc.8 CLI + Chromium 挂载冒烟 3/3 通过(drag-layout、mount 深扫含 editor/mermaid chunk、桌面信号) * feat(settings): 终端 shell/shellArgs 可在设置页配置(bash 管理) - SidebarPrefs 新增 terminalShell / terminalShellArgs(默认空 = 保持 yaml config.shell → $SHELL / 登录 shell / powershell.exe 的既有解析) - 终端卡齿轮二级页面新增两行 text 配置(与字体行同列) - host 侧生效链路:UI 终端 WS 打开与 agent terminal_create 均读取 设置文档覆盖(ptyManager.open / AgentPtyRegistry.create 增加可选 shell 覆盖参数;shellOverridesOf 解析设置文档);设置页写入即时 影响之后打开的终端(既有 pty 保持原 shell) - locales zh/en 文案;parsePrefs 校验新字段;测试同步 * feat(settings): 侧边卡片设置页加 DSH-better-sidebar 版本徽标 - 设置页 intro 下新增身份徽标:名称 + v{service.version}(服务实例 版本,与 package.json 由 tests/service.spec.ts 保持同步) - token 驱动样式(--dsw-alias-*),名称/版本 pill 布局 * fix(drag): 快速拖动提交最后位置 + HMR 后中心列重定位兜底 快速释放(浏览器合并/丢失 pointermove 突发,超快速 flick 甚至可能 完全无 move)时的回退问题: - pointerup 优先采用最后一次已知拖动值(rAF pending),而非 up 位置 (事件合并时可能等于按下位置) - pointercancel / lostpointercapture 中断时同样提交最后已知位置; 无 move 时用中断事件自身的坐标计算(clamp 后提交)——只有真正 未产生位移的拖动才回滚 - commitDrag 统一 flush + 同值提交 + 立即重测中心列(消除提交后 React 用旧 centerRect 覆盖底栏 right 的中间帧) HMR 重激活后底栏空白(输入框被推挤上移、强制刷新恢复): - 根因:新组件实例重跑 locate 时壳的 centerCol 尚未就绪,而壳重建 复用 #root 子节点(childList 观察器不触发)→ centerRect 卡 {0,0} → 底栏 visibility:hidden 永久空白 - 修复:额外观察 <html> 的 style 属性(layout push 写回 --dsh-sidebar-* 时重定位重测);底栏打开时 effect 重跑 locate/measure 兜底 - 底栏未测量时由「不渲染」改为 visibility:hidden(渲染链不被几何 门控,jsdom 测试不回归) e2e 新增:极快拖动(单 move 立即 up)提交拖动位置、稳定后不回退 * feat(plugins): 添加插件目录支持搜索/分组/独立滚动(多插件场景) - PluginListBody 顶部加实时搜索框(按名称/id/描述过滤,空结果提示) - PluginEntry 增加可选 category 分组字段(i18n 友好),目录条目按组 渲染(未分类条目保持平铺);数量徽标跟随过滤结果 - 条目列表 max-height + overflow-y 独立滚动,插件多时弹窗不再无限 增长;渲染数量由搜索天然收敛 - locales zh/en(搜索占位、无匹配);测试文件固定英文环境 (vitest 4.1.11+ jsdom 跟随系统 locale,本机 zh-CN 导致英文文案 断言漂移) * chore(deps): 适配 pnpm 11.8 supply-chain 校验(exclude 通配) pnpm 11.8.0 起对 lockfile 既有条目强制 minimumReleaseAge 校验, 且精确 exclude(@scope/pkg@version)对带 peer 依赖后缀的解析条目 匹配失效(44 条误报违规,CI 全红)。@deepseek-ai/* scope 为官方 可信 scope,minimumReleaseAgeExclude 改为 '@deepseek-ai/*' 通配 (保留 rc.1 历史与 stable 条目);通配经实测通过全部 504 条校验。 * docs: v0.14.0 changelog 补全(自 v0.13.1 全部更改 + 特别声明 rc.8 适配) 新增条目:终端 shell/shellArgs 设置页配置、设置页版本徽标、 添加插件目录搜索/分组/滚动、rc.8 模块系统迁移、拖拽健壮性修复; 版本说明块醒目声明本版适配 DSH 0.1.0-rc.8(rc.7 及更早无法解析依赖)
This commit is contained in:
+231
-59
@@ -1,16 +1,19 @@
|
||||
/**
|
||||
* The sidebar shell: fixed-position panels portalled onto document.body
|
||||
* (the core AppFrame owns the left sidebar / center / details columns and
|
||||
* has no right-side hole for plugins). The right panel hosts the original
|
||||
* workbench; the bottom panel hosts a second, independent workbench. The
|
||||
* bottom panel squeezes ONLY the center column (the agent output area): it
|
||||
* spans from the app shell's own left sidebar to the right panel's left
|
||||
* edge, so neither sidebar gives up any position (the right panel keeps its
|
||||
* full height). A persistent two-button cluster at the top-right corner
|
||||
* toggles each panel; the right panel's width drags from its left edge, the
|
||||
* bottom panel's height from its top edge, and the shared corner drags both
|
||||
* at once. The whole layout lives in the per-session store, so switching
|
||||
* conversations swaps the sidebar.
|
||||
* The sidebar shell: panels mounted inside the unified panel host — a
|
||||
* fixed, viewport-sized containing block ([data-dsh-panel-host]) appended
|
||||
* to document.body — instead of individual fixed-position elements, so a
|
||||
* desktop shell's intermediate wrapper transforms can never hijack the
|
||||
* panels' fixed containing block (the core AppFrame owns the left sidebar /
|
||||
* center / details columns and has no right-side hole for plugins). The
|
||||
* right panel hosts the original workbench; the bottom panel hosts a
|
||||
* second, independent workbench. The bottom panel squeezes ONLY the center
|
||||
* column (the agent output area): it spans from the app shell's own left
|
||||
* sidebar to the right panel's left edge, so neither sidebar gives up any
|
||||
* position (the right panel keeps its full height). A persistent two-button
|
||||
* cluster at the top-right corner toggles each panel; the right panel's
|
||||
* width drags from its left edge, the bottom panel's height from its top
|
||||
* edge, and the shared corner drags both at once. The whole layout lives in
|
||||
* the per-session store, so switching conversations swaps the sidebar.
|
||||
*
|
||||
* The shell binds the workbench actions to the store and dispatches tab
|
||||
* content to the views. New tabs come from the + menu (explorer / git /
|
||||
@@ -40,6 +43,7 @@ import {
|
||||
import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
|
||||
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
|
||||
import { useNarrowViewport } from './breakpoints.ts'
|
||||
import { parseDesktopEnv } from './desktop-env.ts'
|
||||
import type { NewTabOption } from './TabBar.tsx'
|
||||
import type { TabDragPayload } from './TabBar.tsx'
|
||||
import { relativeTo } from './paths.ts'
|
||||
@@ -137,6 +141,37 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// keep living in the right tree.
|
||||
const narrow = useNarrowViewport()
|
||||
|
||||
// On-screen keyboard / visual-viewport inset (mobile, split-screen, …):
|
||||
// when the visual viewport shrinks below the layout viewport, bottom-
|
||||
// anchored panels would hide under the keyboard. Track the inset and
|
||||
// offset the bottom-anchored surfaces by it. The obscured bottom strip is
|
||||
// innerHeight − (vv.height + vv.offsetTop): offsetTop is nonzero while
|
||||
// the visual viewport is scrolled/zoomed under browser chrome, so
|
||||
// omitting it would over-lift the panels (CR #232 P2). offsetTop changes
|
||||
// through the viewport's scroll event too, so both events are listened.
|
||||
// Guarded: browsers without visualViewport (older WebViews, jsdom) stay
|
||||
// at 0. rAF-throttled, same pattern as useNarrowViewport.
|
||||
const [keyboardInset, setKeyboardInset] = useState(0)
|
||||
useEffect(() => {
|
||||
const vv = window.visualViewport
|
||||
if (vv === null || vv === undefined) return
|
||||
let frame: number | null = null
|
||||
const measure = (): void => {
|
||||
frame = null
|
||||
const inset = Math.max(0, window.innerHeight - (vv.height + vv.offsetTop))
|
||||
setKeyboardInset(inset > 1 ? Math.round(inset) : 0)
|
||||
}
|
||||
const onResize = (): void => { if (frame === null) frame = requestAnimationFrame(measure) }
|
||||
vv.addEventListener('resize', onResize)
|
||||
vv.addEventListener('scroll', onResize)
|
||||
measure()
|
||||
return () => {
|
||||
vv.removeEventListener('resize', onResize)
|
||||
vv.removeEventListener('scroll', onResize)
|
||||
if (frame !== null) cancelAnimationFrame(frame)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Current conversation (the sessions list feed).
|
||||
const sessionList = useSyncExternalStore(
|
||||
useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]),
|
||||
@@ -169,16 +204,23 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
|
||||
// Position compatibility mode (titleBarCompat pref): Windows frameless
|
||||
// windows draw the native title bar (minimize/maximize/close) at the
|
||||
// window's top-right corner, OVER the web content. When the user enables
|
||||
// the pref, the body attribute lets sidebar.module.css drop the toggle
|
||||
// cluster below the strip and push the right panel's content below it.
|
||||
// The strip height is user-tunable (titleBarStripPx) and rides a CSS
|
||||
// variable so the rules stay declarative. The attribute rides the
|
||||
// window's top-right corner, OVER the web content. When enabled, the body
|
||||
// attribute lets sidebar.module.css drop the toggle cluster below the
|
||||
// strip and push the right panel's content below it. Auto-enabled in the
|
||||
// win32 advanced desktop shell (the shell stamps the URL — see
|
||||
// desktop-env.ts — and reserves a 32px overlay for the window controls);
|
||||
// the manual pref and its strip height still win when the user sets them,
|
||||
// and the manual switch only ever ADDS compat. The strip height rides a
|
||||
// CSS variable so the rules stay declarative; the attribute rides the
|
||||
// snapshot's prefs, so flipping the setting re-renders and re-applies
|
||||
// immediately; the cleanup removes both on unmount/boundary swap so a
|
||||
// crashed sidebar never leaves them behind.
|
||||
const titleBarCompat = snapshot.prefs.titleBarCompat
|
||||
const titleBarStrip = snapshot.prefs.titleBarStripPx
|
||||
const desktopEnv = parseDesktopEnv()
|
||||
const autoTitleBarCompat = desktopEnv.win32OverlayTop > 0
|
||||
const titleBarCompat = snapshot.prefs.titleBarCompat || autoTitleBarCompat
|
||||
const titleBarStrip = snapshot.prefs.titleBarCompat
|
||||
? snapshot.prefs.titleBarStripPx
|
||||
: desktopEnv.win32OverlayTop
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
if (titleBarCompat) {
|
||||
@@ -415,13 +457,28 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
const watcher = new MutationObserver(locate)
|
||||
const root = document.getElementById('root')
|
||||
if (root !== null) watcher.observe(root, { childList: true })
|
||||
// The layout push writes --dsh-sidebar-* on <html>. A HMR re-activation
|
||||
// clears those variables on teardown and re-writes them on setup — and
|
||||
// that is also the moment the shell may have re-created the center
|
||||
// column under a REUSED #root child (React swaps nodes in place, so
|
||||
// #root's childList never changes and the watcher above never fires).
|
||||
// Watching <html>'s style attribute catches that re-sync: the push
|
||||
// rewrite re-locates and re-measures, so the bottom panel recovers
|
||||
// instead of staying hidden on a stale {0,0} center rect.
|
||||
const htmlStyleWatcher = new MutationObserver(locate)
|
||||
htmlStyleWatcher.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] })
|
||||
return () => {
|
||||
disposed = true
|
||||
observer?.disconnect()
|
||||
watcher.disconnect()
|
||||
htmlStyleWatcher.disconnect()
|
||||
centerColRef.current = null
|
||||
}
|
||||
}, [measureCenter])
|
||||
// Opening the bottom panel re-runs the whole locate/measure chain: a
|
||||
// panel opened before the center column was ever found must not stay
|
||||
// invisible forever (the HMR recovery path depends on the observers
|
||||
// above, this is the belt-and-braces retry for the open moment itself).
|
||||
}, [measureCenter, state?.bottomOpen])
|
||||
|
||||
/**
|
||||
* Bottom-panel first-expansion auto terminal: the FIRST time the user
|
||||
@@ -486,10 +543,24 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
const clampHeight = (height: number): number =>
|
||||
Math.min(Math.max(BOTTOM_MIN, Math.round(height)), Math.max(BOTTOM_MIN, window.innerHeight - PANEL_MIN))
|
||||
|
||||
/** Single writer for the layout-push variables: the app shell gives up
|
||||
* the panel's width/height while open (0 while collapsed) through
|
||||
* layout.css's margins. Every size change — drag frames and committed
|
||||
* state — flows through here so the push never forks between paths. */
|
||||
const writeGeometry = (width: number, height: number): void => {
|
||||
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
|
||||
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
|
||||
// The corner handle positions itself relative to the panel (CSS
|
||||
// `bottom: calc(var(--dsh-sidebar-height) + 6px)`), so these two layout
|
||||
// variables are all it needs — no viewport coordinates written here
|
||||
// (issue #106: skins that inset the panels must not fight JS coords).
|
||||
}
|
||||
|
||||
/** Apply a drag size to the DOM without touching React state or the store.
|
||||
* The bottom panel's right edge tracks the right panel's left edge HERE
|
||||
* too — React state only updates on release, so the inline right must be
|
||||
* written directly or the bottom panel would lag the sidebar mid-drag. */
|
||||
* written directly or the bottom panel would lag the sidebar mid-drag.
|
||||
* The layout push rides the shared writer (writeGeometry). */
|
||||
const applyDrag = (width: number, height: number): void => {
|
||||
panelRef.current?.style.setProperty('width', `${width}px`)
|
||||
bottomRef.current?.style.setProperty('height', `${height}px`)
|
||||
@@ -498,12 +569,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// `width + detailsWidth` — derived from the measured column, keeping the
|
||||
// drag write-only (no React re-render mid-drag).
|
||||
bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRect.right) + (width - (state?.width ?? 0))}px`)
|
||||
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
|
||||
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
|
||||
// The corner handle positions itself relative to the panel (CSS
|
||||
// `bottom: calc(var(--dsh-sidebar-height) + 6px)`), so these two layout
|
||||
// variables are all it needs — no viewport coordinates written here
|
||||
// (issue #106: skins that inset the panels must not fight JS coords).
|
||||
writeGeometry(width, height)
|
||||
}
|
||||
|
||||
// Drags write at most once per frame: pointer events fire several times
|
||||
@@ -535,23 +601,88 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
pendingDrag.current = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize a drag on pointer up: flush the LAST drag frame to the DOM
|
||||
* synchronously, then commit the SAME clamped values to the store. A fast
|
||||
* release cancels the rAF before it ran — without the flush the DOM would
|
||||
* sit at the pre-drag size until React re-renders with the committed
|
||||
* value, and a value that never made it into a move handler would never
|
||||
* be applied at all. The measurement pause ends here too: the center
|
||||
* column is re-measured BEFORE the committed re-render lands, so the
|
||||
* bottom panel's React-rendered right edge already reflects the new
|
||||
* width (otherwise the re-render would re-apply the stale rect — the
|
||||
* bottom panel visibly jumps for one frame).
|
||||
*/
|
||||
const commitDrag = (
|
||||
width: number,
|
||||
height: number,
|
||||
reduce: (state: SidebarState) => SidebarState,
|
||||
): void => {
|
||||
stopDragScheduling()
|
||||
applyDrag(width, height)
|
||||
draggingRef.current = false
|
||||
measureCenter()
|
||||
store.reduce(reduce)
|
||||
}
|
||||
|
||||
/** Set once a drag's pointerup handler commits — premature capture loss
|
||||
* (pointercancel / lostpointercapture without pointerup) must then be told
|
||||
* apart from a normal release. */
|
||||
const dragCommitted = useRef(false)
|
||||
/** Abort a drag whose pointer stream was interrupted (pointercancel, or
|
||||
* capture lost before pointerup): no pointerup will arrive, so without
|
||||
* this the dragging state would stick true and center-column measurement
|
||||
* would stay paused forever — the bottom panel freezes at stale edges and
|
||||
* stops tracking sidebar/app-rail layout changes. Reverts the DOM and the
|
||||
* layout variables to the store's committed sizes. */
|
||||
const abortDrag = (reset: () => void): void => {
|
||||
/**
|
||||
* Abort a drag whose pointer stream was interrupted (pointercancel, or
|
||||
* capture lost before pointerup): no pointerup will arrive, so without
|
||||
* this the dragging state would stick true and center-column measurement
|
||||
* would stay paused forever — the bottom panel freezes at stale edges and
|
||||
* stops tracking sidebar/app-rail layout changes.
|
||||
*
|
||||
* A FAST release is the common trigger: browsers merge pointermove bursts,
|
||||
* and an ultra-fast flick can cancel the stream before ANY move lands.
|
||||
* The commit order is therefore: the LAST KNOWN dragged size (the rAF
|
||||
* pending value) first, then the interrupting event's own pointer
|
||||
* position (pointercancel / lostpointercapture still carry coordinates),
|
||||
* and only a drag that produced neither (pure down+up at the same spot)
|
||||
* reverts to the store's committed sizes.
|
||||
*/
|
||||
const abortDrag = (reset: () => void, event?: { clientX: number; clientY: number }): void => {
|
||||
if (dragCommitted.current) return
|
||||
stopDragScheduling()
|
||||
applyDrag(
|
||||
!narrow && state?.panelOpen === true ? Math.min(state?.width ?? 0, window.innerWidth) : 0,
|
||||
!narrow && state?.bottomOpen === true ? Math.min(state.bottomHeight, window.innerHeight) : 0,
|
||||
)
|
||||
const pending = pendingDrag.current
|
||||
let width: number | undefined
|
||||
let height: number | undefined
|
||||
if (pending !== null) {
|
||||
width = pending.width
|
||||
height = pending.height
|
||||
} else if (event !== undefined) {
|
||||
// No move ever landed: the cancel/lost-capture position is all we
|
||||
// have — commit it (clamped) instead of rolling back the flick.
|
||||
if (draggingWidth) {
|
||||
width = clampWidth(widthDrag.current.startWidth + (widthDrag.current.startX - event.clientX))
|
||||
height = state?.bottomOpen === true ? Math.min(state.bottomHeight, window.innerHeight) : 0
|
||||
} else if (draggingBottom) {
|
||||
width = Math.min(state?.width ?? 0, window.innerWidth)
|
||||
height = clampHeight(bottomDrag.current.startHeight + (bottomDrag.current.startY - event.clientY))
|
||||
} else if (draggingCorner) {
|
||||
width = clampWidth(cornerDrag.current.startWidth + (cornerDrag.current.startX - event.clientX))
|
||||
height = clampHeight(cornerDrag.current.startHeight + (cornerDrag.current.startY - event.clientY))
|
||||
}
|
||||
}
|
||||
if (width !== undefined && height !== undefined) {
|
||||
pendingDrag.current = null
|
||||
if (dragFrame.current !== null) {
|
||||
cancelAnimationFrame(dragFrame.current)
|
||||
dragFrame.current = null
|
||||
}
|
||||
applyDrag(width, height)
|
||||
draggingRef.current = false
|
||||
measureCenter()
|
||||
store.reduce(s => setBottomHeight(setWidth(s, width), height))
|
||||
} else {
|
||||
stopDragScheduling()
|
||||
applyDrag(
|
||||
!narrow && state?.panelOpen === true ? Math.min(state?.width ?? 0, window.innerWidth) : 0,
|
||||
!narrow && state?.bottomOpen === true ? Math.min(state.bottomHeight, window.innerHeight) : 0,
|
||||
)
|
||||
}
|
||||
reset()
|
||||
}
|
||||
|
||||
@@ -569,8 +700,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
const height = !narrow && snapshot.state?.bottomOpen === true
|
||||
? Math.min(snapshot.state.bottomHeight, window.innerHeight)
|
||||
: 0
|
||||
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
|
||||
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
|
||||
writeGeometry(width, height)
|
||||
// Unmount must release the push (issue #31): when the boundary swaps the
|
||||
// whole sidebar after a render crash (or the plugin fiber is disposed /
|
||||
// HMR), the CSS variables would otherwise stay on <html> and layout.css
|
||||
@@ -660,19 +790,21 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
|
||||
if (state === undefined || sessionId === undefined) {
|
||||
return (
|
||||
<div className={css.toggleCluster}>
|
||||
{!narrow && (
|
||||
<div data-dsh-panel-host>
|
||||
<div className={css.toggleCluster}>
|
||||
{!narrow && (
|
||||
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
|
||||
<IconPanelBottomOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
|
||||
<IconPanelBottomOutline16 />
|
||||
<IconPanelRightOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
|
||||
<IconPanelRightOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -744,7 +876,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div data-dsh-panel-host>
|
||||
{/*
|
||||
The persistent toggle cluster at the top-right corner: the bottom
|
||||
panel's button (bottom glyph) LEFT of the right panel's (side glyph).
|
||||
@@ -793,7 +925,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={clsx(css.panel, !state.panelOpen && css.panelHidden)}
|
||||
style={{ width: narrow ? '100vw' : Math.min(state.width, window.innerWidth) }}
|
||||
style={{
|
||||
width: narrow ? '100vw' : Math.min(state.width, window.innerWidth),
|
||||
// Narrow drawer: keep the bottom-anchored sheet above the on-screen
|
||||
// keyboard (visualViewport inset); desktop panels are full-height
|
||||
// and unaffected.
|
||||
bottom: narrow && keyboardInset > 0 ? `${keyboardInset}px` : undefined,
|
||||
}}
|
||||
|
||||
data-dragging={anyDragging || undefined}
|
||||
>
|
||||
@@ -820,8 +958,16 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
dragCommitted.current = true
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
const { startX, startWidth } = widthDrag.current
|
||||
stopDragScheduling()
|
||||
store.reduce(s => setWidth(s, startWidth + (startX - event.clientX)))
|
||||
// A very fast release can merge/lose pointermove bursts:
|
||||
// prefer the LAST KNOWN dragged width (already clamped by
|
||||
// the move handler) over the up position, which may equal
|
||||
// the down position when no move landed.
|
||||
const pending = pendingDrag.current
|
||||
const width = pending !== null
|
||||
? pending.width
|
||||
: clampWidth(startWidth + (startX - event.clientX))
|
||||
const height = state.bottomOpen ? Math.min(state.bottomHeight, window.innerHeight) : 0
|
||||
commitDrag(width, height, s => setWidth(s, width))
|
||||
setDraggingWidth(false)
|
||||
}}
|
||||
onPointerCancel={() => { abortDrag(() => setDraggingWidth(false)) }}
|
||||
@@ -878,8 +1024,16 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
dragCommitted.current = true
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
const { startX, startY, startWidth, startHeight } = cornerDrag.current
|
||||
stopDragScheduling()
|
||||
store.reduce(s => setBottomHeight(setWidth(s, startWidth + (startX - event.clientX)), startHeight + (startY - event.clientY)))
|
||||
// Last known dragged size wins over the up position (fast
|
||||
// releases can merge/lose pointermove bursts).
|
||||
const pending = pendingDrag.current
|
||||
const width = pending !== null
|
||||
? pending.width
|
||||
: clampWidth(startWidth + (startX - event.clientX))
|
||||
const height = pending !== null
|
||||
? pending.height
|
||||
: clampHeight(startHeight + (startY - event.clientY))
|
||||
commitDrag(width, height, s => setBottomHeight(setWidth(s, width), height))
|
||||
setDraggingCorner(false)
|
||||
}}
|
||||
onPointerCancel={() => { abortDrag(() => setDraggingCorner(false)) }}
|
||||
@@ -896,6 +1050,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
down like the right panel. On NARROW viewports it does not exist —
|
||||
the bottom workbench lives inside the drawer (MobileWorkbench).
|
||||
*/}
|
||||
{/* The bottom panel only becomes VISIBLE once the center column is
|
||||
measured: before that, `centerRect` is the {0,0} fallback and
|
||||
`right` computes to the full viewport width — the panel (and its
|
||||
overflow content) would flash full-width for a frame until the
|
||||
first measurement lands. Rendering stays unconditional so the
|
||||
mount/render chain (auto-terminal etc.) is never gated on
|
||||
geometry. */}
|
||||
{!narrow && (
|
||||
<div
|
||||
ref={bottomRef}
|
||||
@@ -903,6 +1064,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
style={{
|
||||
height: Math.min(state.bottomHeight, window.innerHeight),
|
||||
left: centerRect.left,
|
||||
// Keep the panel above the on-screen keyboard when the visual
|
||||
// viewport shrinks (see the keyboardInset effect).
|
||||
bottom: keyboardInset > 0 ? `${keyboardInset}px` : undefined,
|
||||
// Direct from the center column's measured right edge: the bottom
|
||||
// panel spans ONLY the center column, ending exactly at the
|
||||
// details column's left edge (the details column sits between the
|
||||
@@ -913,6 +1077,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// (the right panel's border-left alone is covered by this panel's
|
||||
// fill — without it the corner looks cut off).
|
||||
borderRight: state.panelOpen ? '1px solid var(--dsw-alias-border-l2)' : undefined,
|
||||
// Unmeasured center column → keep the panel invisible (zero-size
|
||||
// geometry would flash full-width overflow instead).
|
||||
visibility: centerRect.right > 0 ? undefined : 'hidden',
|
||||
}}
|
||||
|
||||
data-dragging={(draggingBottom || draggingCorner) || undefined}
|
||||
@@ -938,8 +1105,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
dragCommitted.current = true
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
const { startY, startHeight } = bottomDrag.current
|
||||
stopDragScheduling()
|
||||
store.reduce(s => setBottomHeight(s, startHeight + (startY - event.clientY)))
|
||||
// Last known dragged height wins over the up position (fast
|
||||
// releases can merge/lose pointermove bursts).
|
||||
const pending = pendingDrag.current
|
||||
const height = pending !== null
|
||||
? pending.height
|
||||
: clampHeight(startHeight + (startY - event.clientY))
|
||||
commitDrag(Math.min(state.width, window.innerWidth), height, s => setBottomHeight(s, height))
|
||||
setDraggingBottom(false)
|
||||
}}
|
||||
onPointerCancel={() => { abortDrag(() => setDraggingBottom(false)) }}
|
||||
@@ -974,6 +1146,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user