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:
+3
-1
@@ -214,10 +214,12 @@ export class AgentPtyRegistry {
|
||||
cwd: string,
|
||||
cols = 80,
|
||||
rows = 24,
|
||||
shell?: string,
|
||||
shellArgs?: string[],
|
||||
): string {
|
||||
const uuid = randomUUID()
|
||||
const dims = clampDims(cols, rows)
|
||||
const pty = this.nodePty.spawn(this.shell, shellSpawnArgs(this.shellArgs), {
|
||||
const pty = this.nodePty.spawn(shell ?? this.shell, shellSpawnArgs(shellArgs ?? this.shellArgs), {
|
||||
name: 'xterm-256color',
|
||||
cols: dims.cols,
|
||||
rows: dims.rows,
|
||||
|
||||
@@ -713,3 +713,83 @@
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The managing plugin's identity badge: name + version pill, token-driven
|
||||
(labels via --dsw-alias-label-*, surface via --dsw-alias-bg-layer-2). */
|
||||
.versionBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-self: flex-start;
|
||||
padding: 4px 12px 4px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.versionBadgeName {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.versionBadgeTag {
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-alias-accent-soft, var(--dsw-alias-border-l2));
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Catalog search input: a compact text field above the list (token-driven).
|
||||
The entries list below it scrolls INDEPENDENTLY — with many plugins the
|
||||
modal stays navigable instead of growing unbounded. */
|
||||
.pluginSearch {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
appearance: none;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
}
|
||||
|
||||
.pluginSearch::placeholder {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.pluginSearch:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-l4);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* The entries area scrolls within the modal once the catalog grows past
|
||||
the viewport (max-height + own scrollbar keeps the modal shell fixed). */
|
||||
.pluginEntries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: 46vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
/* One catalog group: optional category heading above its stacked entries. */
|
||||
.pluginGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pluginGroupHeading {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
padding: 2px 2px 0;
|
||||
}
|
||||
|
||||
@@ -736,6 +736,15 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
<div className={css.section}>
|
||||
<p className={css.intro}>{t('settingsIntro')}</p>
|
||||
|
||||
{/* The managing plugin's own identity: name + version badge, so the
|
||||
section is attributable at a glance (the version is the service
|
||||
instance's, kept in lockstep with package.json by
|
||||
tests/service.spec.ts). */}
|
||||
<div className={css.versionBadge}>
|
||||
<span className={css.versionBadgeName}>DSH-better-sidebar</span>
|
||||
<span className={css.versionBadgeTag}>v{service.version}</span>
|
||||
</div>
|
||||
|
||||
{/* 常规: the DSH settings-row recipe — title/desc left, control right. */}
|
||||
<div className={css.group}>
|
||||
<div className={css.groupHeading}>{t('settingsGeneralTitle')}</div>
|
||||
|
||||
+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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
* Modal must never be renderToString'd (same rule as the settingsFor popup
|
||||
* in SideCardSection); the modal itself mounts only while open.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { Modal, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { BetterSidebarService } from './service.ts'
|
||||
import { PLUGIN_TOPIC_URL, type PluginEntry } from './plugins-shared.ts'
|
||||
@@ -46,6 +46,31 @@ export function PluginListBody(props: { service: BetterSidebarService; kind: Plu
|
||||
const { service, kind } = props
|
||||
// Which entry's copy button currently shows the "已复制" feedback.
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||
// Live catalog filter (name / id / description). A free-text search keeps
|
||||
// the list usable once the catalogs grow beyond a handful of entries:
|
||||
// every keystroke narrows the rendered rows, so a long catalog never
|
||||
// becomes a wall of scrolling.
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const catalog = catalogOf(kind)
|
||||
const needle = query.trim().toLowerCase()
|
||||
const matches = (entry: PluginEntry): boolean => {
|
||||
if (needle === '') return true
|
||||
const description = typeof entry.description === 'function' ? entry.description() : entry.description
|
||||
return entry.name.toLowerCase().includes(needle)
|
||||
|| entry.id.toLowerCase().includes(needle)
|
||||
|| description.toLowerCase().includes(needle)
|
||||
}
|
||||
const filtered = catalog.filter(matches)
|
||||
// Group by the optional category (stable order: category order of first
|
||||
// appearance, uncategorized entries last under the plain list).
|
||||
const groups = new Map<string | undefined, PluginEntry[]>()
|
||||
for (const entry of filtered) {
|
||||
const key = entry.category === undefined ? undefined : (typeof entry.category === 'function' ? entry.category() : entry.category)
|
||||
const list = groups.get(key)
|
||||
if (list === undefined) groups.set(key, [entry])
|
||||
else list.push(entry)
|
||||
}
|
||||
|
||||
/** Copy the entry's install script to the clipboard and flash the button's
|
||||
* "已复制" label for a moment. The feedback ONLY appears after a
|
||||
@@ -68,6 +93,48 @@ export function PluginListBody(props: { service: BetterSidebarService; kind: Plu
|
||||
window.open(entry.url, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
/** One catalog row (extracted so the group render stays flat). */
|
||||
const renderEntry = (entry: PluginEntry): ReactNode => (
|
||||
<div key={entry.id} className={css.pluginEntry}>
|
||||
<div className={css.pluginEntryHead}>
|
||||
{/* The name is a BUTTON on the same window.open path as the
|
||||
jump button: as an anchor it would be caught by the
|
||||
document-capture link takeover (which ignores
|
||||
target=_blank) and land in the sidebar browser. */}
|
||||
<button
|
||||
type="button"
|
||||
className={css.pluginName}
|
||||
aria-label={`${t('openPlugin')}: ${entry.name}`}
|
||||
onClick={() => { jump(entry) }}
|
||||
>
|
||||
{entry.name}
|
||||
</button>
|
||||
<span className={css.pluginEntryActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.pluginJumpBtn}
|
||||
aria-label={`${t('openPlugin')}: ${entry.name}`}
|
||||
onClick={() => { jump(entry) }}
|
||||
>
|
||||
{t('openPlugin')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.pluginCopyBtn}
|
||||
aria-label={`${t('copyInstall')}: ${entry.name}`}
|
||||
onClick={() => { copy(entry) }}
|
||||
>
|
||||
{copiedId === entry.id ? t('copied') : t('copy')}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className={css.pluginDesc}>
|
||||
{typeof entry.description === 'function' ? entry.description() : entry.description}
|
||||
</div>
|
||||
<code className={css.pluginInstall}>{entry.install}</code>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={css.pluginList}>
|
||||
<button
|
||||
@@ -77,52 +144,30 @@ export function PluginListBody(props: { service: BetterSidebarService; kind: Plu
|
||||
>
|
||||
{t('addPluginsBrowseMore')}
|
||||
</button>
|
||||
<input
|
||||
type="search"
|
||||
className={css.pluginSearch}
|
||||
placeholder={t('addPluginsSearch')}
|
||||
aria-label={t('addPluginsSearch')}
|
||||
value={query}
|
||||
onChange={event => { setQuery(event.currentTarget.value) }}
|
||||
/>
|
||||
<div className={css.groupHeading}>
|
||||
<span>{t('addPluginsRecommended')}</span>
|
||||
<span className={css.count}>{catalogOf(kind).length}</span>
|
||||
<span className={css.count}>{filtered.length}</span>
|
||||
</div>
|
||||
{catalogOf(kind).length === 0 ? (
|
||||
{catalog.length === 0 ? (
|
||||
<div className={css.pluginEmpty}>{t('addPluginsEmpty')}</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className={css.pluginEmpty}>{t('addPluginsNoMatch')}</div>
|
||||
) : (
|
||||
<div className={css.pluginEntries}>
|
||||
{catalogOf(kind).map(entry => (
|
||||
<div key={entry.id} className={css.pluginEntry}>
|
||||
<div className={css.pluginEntryHead}>
|
||||
{/* The name is a BUTTON on the same window.open path as the
|
||||
jump button: as an anchor it would be caught by the
|
||||
document-capture link takeover (which ignores
|
||||
target=_blank) and land in the sidebar browser. */}
|
||||
<button
|
||||
type="button"
|
||||
className={css.pluginName}
|
||||
aria-label={`${t('openPlugin')}: ${entry.name}`}
|
||||
onClick={() => { jump(entry) }}
|
||||
>
|
||||
{entry.name}
|
||||
</button>
|
||||
<span className={css.pluginEntryActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.pluginJumpBtn}
|
||||
aria-label={`${t('openPlugin')}: ${entry.name}`}
|
||||
onClick={() => { jump(entry) }}
|
||||
>
|
||||
{t('openPlugin')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.pluginCopyBtn}
|
||||
aria-label={`${t('copyInstall')}: ${entry.name}`}
|
||||
onClick={() => { copy(entry) }}
|
||||
>
|
||||
{copiedId === entry.id ? t('copied') : t('copy')}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className={css.pluginDesc}>
|
||||
{typeof entry.description === 'function' ? entry.description() : entry.description}
|
||||
</div>
|
||||
<code className={css.pluginInstall}>{entry.install}</code>
|
||||
{[...groups.entries()].map(([category, entries]) => (
|
||||
<div key={category ?? '\u0000'} className={css.pluginGroup}>
|
||||
{category !== undefined && (
|
||||
<div className={css.pluginGroupHeading}>{category}</div>
|
||||
)}
|
||||
{entries.map(renderEntry)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -185,6 +185,18 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
|
||||
key: 'bottomPanelAutoTerminal',
|
||||
title: () => t('settingsBottomTerminalTitle'),
|
||||
desc: () => t('settingsBottomTerminalDesc'),
|
||||
}, {
|
||||
key: 'terminalShell',
|
||||
type: 'text',
|
||||
title: () => t('settingsShellTitle'),
|
||||
desc: () => t('settingsShellDesc'),
|
||||
placeholder: t('settingsShellPlaceholder'),
|
||||
}, {
|
||||
key: 'terminalShellArgs',
|
||||
type: 'text',
|
||||
title: () => t('settingsShellArgsTitle'),
|
||||
desc: () => t('settingsShellArgsDesc'),
|
||||
placeholder: t('settingsShellArgsPlaceholder'),
|
||||
}, {
|
||||
key: 'terminalFontFamily',
|
||||
type: 'text',
|
||||
|
||||
+171
-18
@@ -29,7 +29,10 @@
|
||||
* Caching contract (three layers, each with a failure path):
|
||||
* - In-memory: one in-flight promise per chunk, memoized until
|
||||
* {@link resetChunks}; a failed load removes its entry so the next call
|
||||
* retries from scratch.
|
||||
* retries from scratch. HMR re-activation keeps the resolved exports of
|
||||
* unchanged chunks (ETag revalidation via
|
||||
* {@link revalidateChunksOnReactivate}) — the next lazy open skips the
|
||||
* re-inject / re-execute.
|
||||
* - Script execution: each re-execution overwrites the global registry slot
|
||||
* (assignment, never registration) — no "duplicate factory registration"
|
||||
* class of errors; a failed materialization clears the cache so the retry
|
||||
@@ -38,11 +41,13 @@
|
||||
* no-cache` + ETag, 304 when unchanged), so page refreshes and HMR
|
||||
* re-activations never re-download a multi-MB chunk that did not change.
|
||||
*
|
||||
* HMR: each plugin activation calls {@link resetChunks}, which drops the
|
||||
* in-memory cache (and any test registry), so a hot-reloaded core bundle
|
||||
* re-fetches and re-executes the current chunk scripts on the next lazy
|
||||
* open. Chunk-only source edits still need a manual page refresh (the HMR
|
||||
* poll watches only client.js).
|
||||
* HMR: each plugin activation calls {@link revalidateChunksOnReactivate},
|
||||
* which HEADs every loaded chunk against the bundle route and keeps the
|
||||
* in-memory cache for the ones whose ETag is unchanged (a hot-reloaded core
|
||||
* bundle therefore does not re-execute multi-MB chunk scripts). Chunk-only
|
||||
* source edits still need a manual page refresh (the HMR poll watches only
|
||||
* client.js); an edit that does land while a core HMR happens is caught by
|
||||
* the ETag comparison on the next activation.
|
||||
*/
|
||||
export type ChunkName = 'terminal' | 'editor' | 'mermaid'
|
||||
|
||||
@@ -56,7 +61,12 @@ type ChunkFactory = (require: (spec: string) => unknown) => ChunkExports
|
||||
* The platform externals a chunk bundle may require (mirror of
|
||||
* CLIENT_EXTERNALS in tsdown.config.ts — the chunk builds keep these
|
||||
* external and the loader resolves them here). A superset is safe: the
|
||||
* require only answers what the chunk actually asks for.
|
||||
* require only answers what the chunk actually asks for. The shell's static
|
||||
* module table seeds React, Cordis, and the UI libraries (primitives/slots);
|
||||
* `dsh-client-runtime/client` normalizes onto the runtime package row
|
||||
* (stripClientSuffix). dsh-client-web-react / dsh-client-schema-form were
|
||||
* dropped in DSH 0.1.0-rc.8 (no rc.8 publish, nothing requires them) — the
|
||||
* chunks never asked for them, so they no longer belong here.
|
||||
*/
|
||||
export const CHUNK_EXTERNALS: readonly string[] = [
|
||||
'react',
|
||||
@@ -65,23 +75,61 @@ export const CHUNK_EXTERNALS: readonly string[] = [
|
||||
'react-dom/client',
|
||||
'cordis',
|
||||
'@deepseek-ai/dsh-client-ui-slots',
|
||||
'@deepseek-ai/dsh-client-web-react',
|
||||
'@deepseek-ai/dsh-client-ui-primitives',
|
||||
'@deepseek-ai/dsh-client-schema-form',
|
||||
'@deepseek-ai/dsh-client-runtime/client',
|
||||
]
|
||||
|
||||
/** Chunk script endpoint served by the plugin host half (src/bundle-route.ts). */
|
||||
const CHUNK_URL = (name: ChunkName): string => `/sidebar/bundle/${name}.js`
|
||||
|
||||
/** The client module system surface this loader needs (window.__DSH_MODULES__). */
|
||||
interface ChunkModuleSystem {
|
||||
/** Bound on the revalidation HEAD round-trip. A timeout fails open (drop +
|
||||
* re-fetch on the next open) so a stuck bundle route can never wedge lazy
|
||||
* chunk loads behind the revalidation barrier. */
|
||||
const CHUNK_REVALIDATE_TIMEOUT_MS = 5_000
|
||||
|
||||
/**
|
||||
* The client module system surface this loader needs to resolve externals.
|
||||
* DSH 0.1.0-rc.8 provides it as the `ctx.modules` service (no page global
|
||||
* anymore); the plugin injects it at activation via
|
||||
* {@link setChunkModuleSystem}. The rc.7-era `window.__DSH_MODULES__` global
|
||||
* remains as a fallback so older hosts and the test harness keep working.
|
||||
*/
|
||||
export interface ChunkModuleSystem {
|
||||
import(specifier: string): Promise<unknown>
|
||||
}
|
||||
|
||||
/** Resolve the shell-installed module system (set before any plugin activates). */
|
||||
/** The module system injected by the client half at activation (rc.8+). */
|
||||
let injectedModuleSystem: ChunkModuleSystem | undefined
|
||||
|
||||
/**
|
||||
* Plugin-owned page global carrying the injected module system across
|
||||
* bundle copies: the lazy chunk bundles (client-editor.js etc.) inline their
|
||||
* own chunk-loader instance, and rc.8 no longer exposes the shell module
|
||||
* system as a page global — so the core bundle's injection must be visible
|
||||
* to the chunk copies through a namespace of our own.
|
||||
*/
|
||||
const MODULE_SYSTEM_GLOBAL = '__dshSidebarModuleSystem__'
|
||||
|
||||
/**
|
||||
* Inject the client module system the chunk externals resolve through.
|
||||
* Called by the client half's apply() with `ctx.modules` (rc.8+); pass
|
||||
* undefined to clear (tests). Survives {@link resetChunks} — the module
|
||||
* system is shell state, not chunk state, and stays live across HMR.
|
||||
*/
|
||||
export function setChunkModuleSystem(system: ChunkModuleSystem | undefined): void {
|
||||
injectedModuleSystem = system
|
||||
const g = globalThis as Record<string, unknown>
|
||||
if (system === undefined) delete g[MODULE_SYSTEM_GLOBAL]
|
||||
else g[MODULE_SYSTEM_GLOBAL] = system
|
||||
}
|
||||
|
||||
/** Resolve the shell-installed module system (injected, then the plugin
|
||||
* global shared with chunk-bundle copies, then the rc.7 page global). */
|
||||
function moduleSystem(): ChunkModuleSystem | undefined {
|
||||
return (globalThis as { __DSH_MODULES__?: ChunkModuleSystem }).__DSH_MODULES__
|
||||
const g = globalThis as Record<string, unknown>
|
||||
return injectedModuleSystem
|
||||
?? g[MODULE_SYSTEM_GLOBAL] as ChunkModuleSystem | undefined
|
||||
?? (g as { __DSH_MODULES__?: ChunkModuleSystem }).__DSH_MODULES__
|
||||
}
|
||||
|
||||
/** The plugin-owned chunk factory registry the chunk scripts populate. */
|
||||
@@ -155,6 +203,35 @@ async function buildExternalsRequire(modules: ChunkModuleSystem): Promise<(spec:
|
||||
/** In-flight/memoized chunk loads; a failure removes its entry so a retry re-fetches. */
|
||||
const cache = new Map<ChunkName, Promise<ChunkExports>>()
|
||||
|
||||
/** Chunk names whose exports are currently cached (loaded successfully). */
|
||||
const loadedChunks = new Set<ChunkName>()
|
||||
|
||||
/** ETags observed for loaded chunks (HEAD revalidation, see
|
||||
* {@link revalidateChunksOnReactivate}). */
|
||||
const chunkEtags = new Map<ChunkName, string>()
|
||||
|
||||
/** Pending revalidation barrier: while set, {@link loadChunk} awaits it
|
||||
* before serving cache (see revalidateChunksOnReactivate). */
|
||||
let revalidation: Promise<void> | null = null
|
||||
|
||||
/** Best-effort ETag capture for revalidation. The script tag itself exposes
|
||||
* no response headers, so after a successful load we HEAD the bundle route
|
||||
* once. Failures (including a stuck route — bounded by the timeout) are
|
||||
* ignored — revalidation then fails open (re-fetch). */
|
||||
async function recordEtag(name: ChunkName): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(CHUNK_URL(name), {
|
||||
method: 'HEAD',
|
||||
cache: 'no-cache',
|
||||
signal: AbortSignal.timeout(CHUNK_REVALIDATE_TIMEOUT_MS),
|
||||
})
|
||||
const etag = res.headers.get('etag')
|
||||
if (etag !== null && etag !== '') chunkEtags.set(name, etag)
|
||||
} catch {
|
||||
chunkEtags.delete(name)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load (once) and materialize a lazy chunk, returning its module exports.
|
||||
* Concurrent callers share one in-flight load; a failure clears the cache
|
||||
@@ -162,10 +239,14 @@ const cache = new Map<ChunkName, Promise<ChunkExports>>()
|
||||
* global registry slot — assignments are idempotent).
|
||||
* @param name - the chunk to load.
|
||||
*/
|
||||
export function loadChunk(name: ChunkName): Promise<ChunkExports> {
|
||||
export async function loadChunk(name: ChunkName): Promise<ChunkExports> {
|
||||
// Barrier: never serve a cache entry that a pending revalidation is about
|
||||
// to inspect — a stale chunk could otherwise render mid-HMR (CR #232 P1).
|
||||
if (revalidation !== null) await revalidation
|
||||
const cached = cache.get(name)
|
||||
if (cached !== undefined) return cached
|
||||
const task = (async (): Promise<ChunkExports> => {
|
||||
let task: Promise<ChunkExports>
|
||||
task = (async (): Promise<ChunkExports> => {
|
||||
const test = testLoaders.get(name)
|
||||
if (test !== undefined) return test()
|
||||
const modules = moduleSystem()
|
||||
@@ -178,10 +259,23 @@ export function loadChunk(name: ChunkName): Promise<ChunkExports> {
|
||||
throw new Error(`[dsh-better-sidebar] chunk "${name}" script did not register its factory`)
|
||||
}
|
||||
const require = await buildExternalsRequire(modules)
|
||||
return factory(require)
|
||||
const exports = factory(require)
|
||||
// Only track production loads whose cache entry survived (a revalidation
|
||||
// sweep may have dropped it mid-flight; the caller still gets these
|
||||
// exports, they just are not memoized for the next open). Test-registry
|
||||
// loads return above and never reach this tracking.
|
||||
if (cache.get(name) !== undefined) {
|
||||
loadedChunks.add(name)
|
||||
void recordEtag(name)
|
||||
}
|
||||
return exports
|
||||
})()
|
||||
cache.set(name, task)
|
||||
void task.catch(() => { cache.delete(name) })
|
||||
void task.catch(() => {
|
||||
cache.delete(name)
|
||||
loadedChunks.delete(name)
|
||||
chunkEtags.delete(name)
|
||||
})
|
||||
return task
|
||||
}
|
||||
|
||||
@@ -189,10 +283,69 @@ export function loadChunk(name: ChunkName): Promise<ChunkExports> {
|
||||
* Drop all chunk state for a fresh plugin activation (HMR-safe): clear the
|
||||
* in-memory cache and any test-registry entries, so the next lazy open
|
||||
* re-fetches and re-executes the current chunk scripts (the registry slots
|
||||
* are overwritten by the re-execution — no cleanup needed).
|
||||
* are overwritten by the re-execution — no cleanup needed). A pending
|
||||
* revalidation barrier is cleared too: it was only guarding the cache reads
|
||||
* of the state being dropped, so the next load must not wait on it (the
|
||||
* orphaned task still settles and its identity-guarded `finally` no-ops).
|
||||
*/
|
||||
export function resetChunks(): void {
|
||||
cache.clear()
|
||||
loadedChunks.clear()
|
||||
chunkEtags.clear()
|
||||
testLoaders.clear()
|
||||
externalsRequire = undefined
|
||||
revalidation = null
|
||||
}
|
||||
|
||||
/**
|
||||
* HMR-safe re-activation hook (index.tsx calls this instead of a full
|
||||
* reset): keep the resolved exports of every loaded chunk and drop only the
|
||||
* ones whose script changed on disk — the bundle route revalidates every
|
||||
* request (cache-control: no-cache + ETag), so an unchanged chunk keeps its
|
||||
* memory cache and the next lazy open skips the re-inject / re-execute.
|
||||
* Fail-open: an unreachable, ETag-less, or timed-out chunk is dropped
|
||||
* (re-fetch on next open). Test-registry entries are always cleared
|
||||
* (per-test fixtures).
|
||||
* A page refresh remains the authoritative reset (the HMR poll watches only
|
||||
* client.js; chunk-only edits surface here on the next core re-activation).
|
||||
*
|
||||
* The returned promise is also a BARRIER for {@link loadChunk}: while a
|
||||
* revalidation is pending, every chunk load awaits it before serving cache,
|
||||
* so a lazy tab opening mid-revalidation can never render stale exports
|
||||
* that the sweep is about to invalidate (CR #232 P1).
|
||||
*/
|
||||
export function revalidateChunksOnReactivate(): Promise<void> {
|
||||
testLoaders.clear()
|
||||
const task = (async (): Promise<void> => {
|
||||
// Entries not tracked as production-loaded (test fixtures, orphans) never
|
||||
// survive a re-activation — their resolved exports came from per-test
|
||||
// stubs, not from the bundle route.
|
||||
for (const name of [...cache.keys()]) {
|
||||
if (!loadedChunks.has(name)) cache.delete(name)
|
||||
}
|
||||
if (loadedChunks.size === 0) return
|
||||
const stale: ChunkName[] = []
|
||||
await Promise.all([...loadedChunks].map(async (name) => {
|
||||
try {
|
||||
const res = await fetch(CHUNK_URL(name), {
|
||||
method: 'HEAD',
|
||||
cache: 'no-cache',
|
||||
signal: AbortSignal.timeout(CHUNK_REVALIDATE_TIMEOUT_MS),
|
||||
})
|
||||
const etag = res.headers.get('etag')
|
||||
if (etag !== null && etag !== '' && chunkEtags.get(name) === etag) return
|
||||
} catch {
|
||||
// Fail open below (network errors and timeouts alike).
|
||||
}
|
||||
stale.push(name)
|
||||
}))
|
||||
for (const name of stale) {
|
||||
cache.delete(name)
|
||||
loadedChunks.delete(name)
|
||||
chunkEtags.delete(name)
|
||||
}
|
||||
})()
|
||||
revalidation = task
|
||||
void task.finally(() => { if (revalidation === task) revalidation = null })
|
||||
return task
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Desktop-shell detection for the sidebar. The official DSH Desktop shell
|
||||
* (Electron) stamps every render URL with `dsh-desktop-mode` and
|
||||
* `dsh-desktop-platform` and exposes `window.__DSH_DESKTOP_FILE_PATH__`
|
||||
* through its preload; community Tauri shells keep the native system frame
|
||||
* (no stamps — plain-browser semantics). Parsed once per page and memoized
|
||||
* (the URL never changes mid-session); `resetDesktopEnvForTests` clears the
|
||||
* memo for unit tests.
|
||||
*
|
||||
* Geometry facts the sidebar adapts to (from the Electron runtime):
|
||||
* - win32 `advanced` mode draws the window controls in a 32px overlay strip
|
||||
* at the top-right of the web content (`titleBarOverlay: { height: 32 }`)
|
||||
* — exactly where the toggle cluster sits, so it must drop below the
|
||||
* strip and the right panel's content must clear it.
|
||||
* - darwin `advanced` mode keeps the traffic lights at (16,16) in the
|
||||
* top-left; nothing on the plugin's side touches that corner today, but
|
||||
* the platform is still reported so left-edge controls can avoid it.
|
||||
* - `compatibility` mode keeps the native frame — no adaptation needed.
|
||||
*/
|
||||
export interface DesktopEnv {
|
||||
/** Running inside a desktop shell (any URL stamp or preload marker). */
|
||||
readonly desktop: boolean
|
||||
/** `advanced` = frameless/custom-titlebar shell; `compatibility` = native frame. */
|
||||
readonly mode: 'compatibility' | 'advanced' | null
|
||||
/** Shell platform stamp ('darwin' | 'win32' | …), lowercased, or null. */
|
||||
readonly platform: string | null
|
||||
/** Pixels the win32 advanced shell reserves at the top-right for window
|
||||
* controls (0 elsewhere). */
|
||||
readonly win32OverlayTop: number
|
||||
}
|
||||
|
||||
let cached: DesktopEnv | undefined
|
||||
|
||||
/** Read the shell's desktop stamps (memoized per page). */
|
||||
export function parseDesktopEnv(): DesktopEnv {
|
||||
if (cached !== undefined) return cached
|
||||
// location.search includes the leading '?', which URLSearchParams does NOT
|
||||
// strip (it would become part of the first key) — drop it explicitly.
|
||||
const params = new URLSearchParams(window.location.search.replace(/^\?/, ''))
|
||||
const modeParam = params.get('dsh-desktop-mode')
|
||||
const mode = modeParam === 'compatibility' || modeParam === 'advanced' ? modeParam : null
|
||||
const platformParam = params.get('dsh-desktop-platform')
|
||||
const platform = platformParam !== null && platformParam !== '' ? platformParam.toLowerCase() : null
|
||||
const desktop = mode !== null
|
||||
|| typeof (window as { __DSH_DESKTOP_FILE_PATH__?: unknown }).__DSH_DESKTOP_FILE_PATH__ !== 'undefined'
|
||||
cached = {
|
||||
desktop,
|
||||
mode,
|
||||
platform,
|
||||
win32OverlayTop: desktop && mode === 'advanced' && platform === 'win32' ? 32 : 0,
|
||||
}
|
||||
return cached
|
||||
}
|
||||
|
||||
/** Test hook: drop the memo so the next parse re-reads the URL/globals. */
|
||||
export function resetDesktopEnvForTests(): void {
|
||||
cached = undefined
|
||||
}
|
||||
+84
-7
@@ -13,7 +13,7 @@ import { createRoot, type Root } from 'react-dom/client'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { allLeaves, createSidebarStore, isAgentTabId } from './state.ts'
|
||||
import { createBetterSidebarService, matchUrlTarget } from './service.ts'
|
||||
import { resetChunks } from './chunk-loader.ts'
|
||||
import { revalidateChunksOnReactivate, setChunkModuleSystem } from './chunk-loader.ts'
|
||||
import { registerBuiltins } from './builtins/index.ts'
|
||||
import { Sidebar } from './Sidebar.tsx'
|
||||
import { RenderBoundary } from './RenderBoundary.tsx'
|
||||
@@ -29,8 +29,10 @@ import css from './sidebar.module.css'
|
||||
import './layout.css'
|
||||
|
||||
/** Services required before mounting (provided by the client runtime; the
|
||||
* locale service backs the sidebar's copy — see locales.ts). */
|
||||
export const inject = ['slots', 'sessions', 'connection', 'workspaces', 'locale']
|
||||
* locale service backs the sidebar's copy — see locales.ts). `modules`
|
||||
* (rc.8+) is the client module system the chunk loader resolves its
|
||||
* externals through — Cordis guards service access without inject. */
|
||||
export const inject = ['slots', 'sessions', 'connection', 'workspaces', 'locale', 'modules']
|
||||
|
||||
/**
|
||||
* Error boundary over the sidebar tree (root scope): a render error in the
|
||||
@@ -110,23 +112,96 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
}
|
||||
try {
|
||||
// Fresh chunk state for this activation: invalidate any chunk factories
|
||||
// registered by a previous fiber (HMR) and drop the in-memory load cache
|
||||
// so the next lazy open re-fetches the current chunk scripts.
|
||||
resetChunks()
|
||||
// rc.8+ exposes the client module system as the `ctx.modules` service
|
||||
// (no window.__DSH_MODULES__ page global anymore); the chunk loader needs
|
||||
// it to resolve its externals, so inject it before anything can load a
|
||||
// lazy chunk. The loader falls back to the rc.7 global when absent.
|
||||
setChunkModuleSystem(ctx.modules)
|
||||
// Fresh chunk state for this activation: drop per-test fixtures and
|
||||
// revalidate loaded chunk scripts against the bundle route's ETags —
|
||||
// unchanged chunks keep their resolved exports (no re-inject /
|
||||
// re-execute on HMR), changed ones are dropped for a clean re-fetch.
|
||||
void revalidateChunksOnReactivate()
|
||||
ctx.effect(() => {
|
||||
let disposed = false
|
||||
let root: Root | undefined
|
||||
let host: HTMLDivElement | undefined
|
||||
let mounted = false
|
||||
let bodyObserver: MutationObserver | undefined
|
||||
let hostCheckFrame: number | null = null
|
||||
const unmount = (): void => {
|
||||
if (!mounted) return
|
||||
mounted = false
|
||||
bodyObserver?.disconnect()
|
||||
bodyObserver = undefined
|
||||
if (hostCheckFrame !== null) {
|
||||
cancelAnimationFrame(hostCheckFrame)
|
||||
hostCheckFrame = null
|
||||
}
|
||||
root?.unmount()
|
||||
root = undefined
|
||||
host?.remove()
|
||||
host = undefined
|
||||
}
|
||||
/** Re-attach the host if the page (a desktop shell wrapper, SPA
|
||||
* navigation, …) ever removes it from <body>. Cheap: childList only,
|
||||
* no subtree, no attribute filtering. */
|
||||
const guardAnchor = (): void => {
|
||||
if (bodyObserver !== undefined) return
|
||||
bodyObserver = new MutationObserver(() => {
|
||||
if (host !== undefined && !document.body.contains(host)) {
|
||||
document.body.appendChild(host)
|
||||
}
|
||||
})
|
||||
bodyObserver.observe(document.body, { childList: true })
|
||||
}
|
||||
/** One-shot geometry self-check: if the host page transforms
|
||||
* <html>/<body> itself (exotic shells), a fixed panel host would
|
||||
* track the transformed box instead of the viewport. Flip the
|
||||
* degraded mode and pin the host to the viewport every frame until
|
||||
* the ancestor transform is actually gone. The normal path (no
|
||||
* page-level transform) never runs the sync loop. */
|
||||
const scheduleHostCheck = (): void => {
|
||||
hostCheckFrame ??= requestAnimationFrame(() => {
|
||||
hostCheckFrame = null
|
||||
const layer = host?.querySelector<HTMLElement>('[data-dsh-panel-host]')
|
||||
if (layer === null || layer === undefined) return
|
||||
const rect = layer.getBoundingClientRect()
|
||||
const mismatched = Math.abs(rect.left) > 8 || Math.abs(rect.top) > 8
|
||||
|| Math.abs(rect.width - window.innerWidth) > 8 || Math.abs(rect.height - window.innerHeight) > 8
|
||||
if (!mismatched) {
|
||||
layer.removeAttribute('data-dsh-panel-host-degraded')
|
||||
layer.style.transform = ''
|
||||
return
|
||||
}
|
||||
layer.setAttribute('data-dsh-panel-host-degraded', '')
|
||||
console.warn('[dsh-better-sidebar] panel host geometry mismatch — a page-level transform was detected; using degraded viewport sync')
|
||||
// Track our own compensating translation so the loop judges the
|
||||
// UNCORRECTED geometry: clearing degraded mode must wait for the
|
||||
// ancestor transform to actually disappear — the frame right after
|
||||
// our correction applies would otherwise look "fixed" and the
|
||||
// offset would return immediately (CR #232 P1).
|
||||
let applied = { x: 0, y: 0 }
|
||||
const sync = (): void => {
|
||||
const r = layer.getBoundingClientRect()
|
||||
const rawLeft = r.left - applied.x
|
||||
const rawTop = r.top - applied.y
|
||||
if (Math.abs(rawLeft) <= 1 && Math.abs(rawTop) <= 1
|
||||
&& Math.abs(r.width - window.innerWidth) <= 1 && Math.abs(r.height - window.innerHeight) <= 1) {
|
||||
layer.removeAttribute('data-dsh-panel-host-degraded')
|
||||
layer.style.transform = ''
|
||||
return
|
||||
}
|
||||
const next = { x: -rawLeft, y: -rawTop }
|
||||
if (next.x !== applied.x || next.y !== applied.y) {
|
||||
applied = next
|
||||
layer.style.transform = `translate(${applied.x}px, ${applied.y}px)`
|
||||
}
|
||||
hostCheckFrame = requestAnimationFrame(sync)
|
||||
}
|
||||
hostCheckFrame = requestAnimationFrame(sync)
|
||||
})
|
||||
}
|
||||
const mount = (): void => {
|
||||
if (mounted || disposed) return
|
||||
try {
|
||||
@@ -136,6 +211,8 @@ export function apply(ctx: Context): void {
|
||||
root = createRoot(host)
|
||||
root.render(createElement(RenderBoundary, { className: css.boundaryError }, createElement(Sidebar, { ctx, store: sidebarStore })))
|
||||
mounted = true
|
||||
guardAnchor()
|
||||
scheduleHostCheck()
|
||||
} catch (error) {
|
||||
fail('mount', error)
|
||||
}
|
||||
|
||||
+23
-15
@@ -5,14 +5,22 @@
|
||||
* panel's width squeeze (margin-right on #root) lands exactly on the
|
||||
* conversation output and the input bar, like a VSCode sidebar.
|
||||
*
|
||||
* The width rides `calc(100% - var(...))` instead of a bare margin on a
|
||||
* full-width box: some desktop shells (DSH Desktop, #208) set #root to
|
||||
* width:100%, where a margin would overflow the viewport additively —
|
||||
* the calc keeps the box at exactly 100% minus the push in every shell.
|
||||
* Width and margin transition in lockstep (same variable, same duration
|
||||
* and easing), so expand/collapse animates the content width exactly as
|
||||
* the bare-margin version did.
|
||||
*
|
||||
* The bottom panel squeezes ONLY the center column — it must not cover the
|
||||
* app's own left sidebar or the right panel. DSH 0.1.x wraps slot hosts in
|
||||
* [data-slot] containers, so the AppFrame grid lives one level deeper:
|
||||
* #root > div[data-slot="root"] > div (the frame). Its grid items are
|
||||
* (frame > div:nth-child(1..3)): sidebarCol, centerCol, detailsCol, so the
|
||||
* vertical push lands on the center column alone. The fixed panels cover
|
||||
* the vacated strips, which looks seamless because #root's background is
|
||||
* the theme base.
|
||||
* app's own left sidebar or the right panel. The anchor is the frame's
|
||||
* center grid item via its stable data attribute ([data-pane="conversation"]
|
||||
* on the centerCol inside [data-dsh-frame]), not a positional path: the
|
||||
* frame's child order also contains an overlay layer and drag handles
|
||||
* (verified against a live DSH 0.1.x page), so nth-child is brittle. A
|
||||
* stretched grid item shrinks by its margins, so the conversation content
|
||||
* (output + input bar) lifts without touching the sidebars.
|
||||
*
|
||||
* The sizes ride CSS variables updated by the Sidebar shell (0 while
|
||||
* collapsed); expand/collapse animates both the margins and the panel
|
||||
@@ -21,14 +29,14 @@
|
||||
*/
|
||||
#root {
|
||||
margin-right: var(--dsh-sidebar-width, 0px);
|
||||
transition: margin-right var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
width: calc(100% - var(--dsh-sidebar-width, 0px));
|
||||
transition:
|
||||
margin-right var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
/* The AppFrame's grid items are sidebarCol, centerCol, detailsCol (children
|
||||
1-3 of #root > div[data-slot="root"] > div) — nth-child(2) is the center
|
||||
column. A stretched grid item shrinks by its margins, so the conversation
|
||||
content (output + input bar) lifts without touching the sidebars. */
|
||||
#root > div[data-slot="root"] > div > div:nth-child(2) {
|
||||
/* The AppFrame's center column, anchored by data attributes (see above). */
|
||||
#root [data-dsh-frame] > [data-pane="conversation"] {
|
||||
margin-bottom: var(--dsh-sidebar-height, 0px);
|
||||
transition: margin-bottom var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
@@ -47,7 +55,7 @@ body[data-dsh-sidebar-collapsed] [data-slot="conversation.session.header"] > hea
|
||||
}
|
||||
|
||||
body[data-dsh-sidebar-dragging] #root,
|
||||
body[data-dsh-sidebar-dragging] #root > div[data-slot="root"] > div > div:nth-child(2) {
|
||||
body[data-dsh-sidebar-dragging] #root [data-dsh-frame] > [data-pane="conversation"] {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@@ -72,7 +80,7 @@ body[data-dsh-sidebar-dragging] #root > div[data-slot="root"] > div > div:nth-ch
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#root,
|
||||
#root > div[data-slot="root"] > div > div:nth-child(2) {
|
||||
#root [data-dsh-frame] > [data-pane="conversation"] {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +162,12 @@ export const zh = {
|
||||
settingsFontSizeTitle: '终端字号',
|
||||
settingsFontSizeDesc: '终端字号(9–32,默认 13)',
|
||||
settingsFontSizeSuffix: 'px',
|
||||
settingsShellTitle: 'Shell 路径',
|
||||
settingsShellDesc: 'UI 与模型终端启动的 shell(绝对路径或可执行名)。留空按既有顺序解析:yaml 的 config.shell → $SHELL / 登录 shell / Windows 的 powershell.exe。对之后打开的终端生效',
|
||||
settingsShellPlaceholder: '如 /bin/zsh(留空自动解析)',
|
||||
settingsShellArgsTitle: 'Shell 参数',
|
||||
settingsShellArgsDesc: '显式 shell 启动参数,空格分隔;非空时完全替换默认参数(与 yaml 的 shellArgs 契约一致)',
|
||||
settingsShellArgsPlaceholder: '如 -l(留空用默认参数)',
|
||||
settingsTabsTitle: '侧边栏内容',
|
||||
settingsViewersTitle: '文件预览',
|
||||
settingsGeneralTitle: '常规',
|
||||
@@ -245,6 +251,8 @@ export const zh = {
|
||||
addPluginsTabDesc: '侧边栏页面(Tab)可以由插件扩展。插件通过 ctx.betterSidebar 服务注册;点击「安装」复制安装命令,粘贴到 DSH 所在环境的终端执行。',
|
||||
addPluginsViewerDesc: '文件预览器可以由插件扩展。插件通过 ctx.betterSidebar 服务注册;点击「安装」复制安装命令,粘贴到 DSH 所在环境的终端执行。',
|
||||
addPluginsBrowseMore: '在 GitHub 上浏览更多插件(topic: dsh-better-sidebar)',
|
||||
addPluginsSearch: '搜索插件名称 / 描述…',
|
||||
addPluginsNoMatch: '没有匹配的插件',
|
||||
addPluginsRecommended: '推荐插件',
|
||||
addPluginsEmpty: '暂未收录插件,欢迎在 GitHub topic 下发布你的插件',
|
||||
openPlugin: '跳转',
|
||||
@@ -407,6 +415,12 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
settingsFontFamilyDesc: 'Custom terminal font family (a CSS font-family stack like "JetBrains Mono", monospace; leave empty to follow the theme\'s monospace font)',
|
||||
settingsFontFamilyPlaceholder: '"JetBrains Mono", monospace',
|
||||
settingsFontSizeTitle: 'Terminal font size',
|
||||
settingsShellTitle: 'Shell path',
|
||||
settingsShellDesc: 'Shell spawned for UI and model terminals (absolute path or bare executable). Empty keeps the legacy order: yaml config.shell → $SHELL / login shell / Windows powershell.exe. Applies to terminals opened afterwards',
|
||||
settingsShellPlaceholder: 'e.g. /bin/zsh (empty = auto)',
|
||||
settingsShellArgsTitle: 'Shell arguments',
|
||||
settingsShellArgsDesc: 'Explicit shell arguments, space-separated; when non-empty they fully replace the defaults (same contract as the yaml shellArgs)',
|
||||
settingsShellArgsPlaceholder: 'e.g. -l (empty = defaults)',
|
||||
settingsFontSizeDesc: 'Terminal font size in px (9–32, default 13)',
|
||||
settingsFontSizeSuffix: 'px',
|
||||
settingsTabsTitle: 'Sidebar content',
|
||||
@@ -492,6 +506,8 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
addPluginsTabDesc: 'Sidebar pages (tabs) can be extended by plugins. Plugins register through the ctx.betterSidebar service; clicking Install copies the install command — paste it into a terminal where your DSH profile lives and run it.',
|
||||
addPluginsViewerDesc: 'File previewers can be extended by plugins. Plugins register through the ctx.betterSidebar service; clicking Install copies the install command — paste it into a terminal where your DSH profile lives and run it.',
|
||||
addPluginsBrowseMore: 'Browse more plugins on GitHub (topic: dsh-better-sidebar)',
|
||||
addPluginsSearch: 'Search by plugin name or description…',
|
||||
addPluginsNoMatch: 'No plugins match',
|
||||
addPluginsRecommended: 'Recommended plugins',
|
||||
addPluginsEmpty: 'No plugins curated yet — publish yours under the GitHub topic',
|
||||
openPlugin: 'Open',
|
||||
|
||||
@@ -20,6 +20,9 @@ export interface PluginEntry {
|
||||
url: string
|
||||
/** One-line description (i18n friendly: string or () => string). */
|
||||
description: string | (() => string)
|
||||
/** Optional catalog group heading (i18n friendly); entries without one
|
||||
* render under the plain list (a flat catalog stays flat). */
|
||||
category?: string | (() => string)
|
||||
/** The full shell command pre-filled into the install terminal (not
|
||||
* executed until the user presses Enter). */
|
||||
install: string
|
||||
|
||||
@@ -54,6 +54,12 @@ export function parsePrefs(value: unknown): SidebarPrefs {
|
||||
terminalFontFamily: typeof record.terminalFontFamily === 'string'
|
||||
? record.terminalFontFamily
|
||||
: SIDEBAR_PREFS_DEFAULTS.terminalFontFamily,
|
||||
terminalShell: typeof record.terminalShell === 'string'
|
||||
? record.terminalShell
|
||||
: SIDEBAR_PREFS_DEFAULTS.terminalShell,
|
||||
terminalShellArgs: typeof record.terminalShellArgs === 'string'
|
||||
? record.terminalShellArgs
|
||||
: SIDEBAR_PREFS_DEFAULTS.terminalShellArgs,
|
||||
terminalFontSize: typeof record.terminalFontSize === 'number' && Number.isFinite(record.terminalFontSize)
|
||||
? clampTerminalFontSize(record.terminalFontSize)
|
||||
: SIDEBAR_PREFS_DEFAULTS.terminalFontSize,
|
||||
|
||||
@@ -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.13.1'
|
||||
export const SIDEBAR_SERVICE_VERSION = '0.14.0'
|
||||
|
||||
/**
|
||||
* Monotonic capability list consumers use to gate new API usage (features
|
||||
|
||||
@@ -20,6 +20,28 @@
|
||||
switching skins in the dsh-web-ui skin center re-skins the sidebar panels
|
||||
automatically (see AGENTS.md §8). */
|
||||
|
||||
/* The unified panel host: a fixed, viewport-sized, click-transparent
|
||||
containing block for every panel element. Panels are ABSOLUTE within it
|
||||
(not fixed), so a desktop shell's intermediate wrapper transform can
|
||||
never hijack their containing block — the host itself is the only fixed
|
||||
element, appended directly to document.body. The layer sits at z-index 40
|
||||
(below the app's overlay stack at 100+); each panel re-enables pointer
|
||||
events on itself. Degraded mode ([data-dsh-panel-host-degraded]) covers
|
||||
the rare shell that transforms <html>/<body> itself: the host becomes
|
||||
absolute and the mount self-check pins it to the viewport every frame. */
|
||||
:global([data-dsh-panel-host]) {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:global([data-dsh-panel-host][data-dsh-panel-host-degraded]) {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* The persistent expand/collapse cluster at the top-right corner: two rail
|
||||
controls side by side (bottom panel's glyph LEFT of the right panel's).
|
||||
Always pinned to the viewport corner — inside the right panel's top-right
|
||||
@@ -27,15 +49,18 @@
|
||||
top: 3) whose right end it really squeezes. z-index 45: above the panels
|
||||
(40) but below the app's overlay stack (menus/tooltips/modals sit at
|
||||
100+), so a cordis approval popup is never hidden behind the workbench
|
||||
(issue #52). */
|
||||
(issue #52). Absolute within the panel host (the host's inset: 0 makes
|
||||
viewport coordinates identical). */
|
||||
.toggleCluster {
|
||||
position: fixed;
|
||||
top: 3px;
|
||||
position: absolute;
|
||||
/* env() resolves to 0 on devices without a top inset (desktops). */
|
||||
top: calc(3px + env(safe-area-inset-top));
|
||||
right: 10px;
|
||||
z-index: 45;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 4px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* The tab strip of the OPEN right panel reserves the cluster's width at its
|
||||
@@ -77,7 +102,7 @@
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
/* Full height: the bottom panel squeezes only the center column, so the
|
||||
@@ -90,12 +115,17 @@
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Re-enable interaction inside the click-transparent panel host. */
|
||||
pointer-events: auto;
|
||||
/* The generic card surface (see the root contract above). Never
|
||||
`--dsw-specific-sidebar-fill` — skin plugins restyle that token for the
|
||||
app's own left nav and a panel consuming it loses its fill or clashes
|
||||
with labels (#60/#105/#106). */
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
border-left: 1px solid var(--dsw-alias-border-l2);
|
||||
/* Bottom safe-area (notched phones): the drawer's bottom content clears
|
||||
the home indicator; 0 on desktops. */
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
/* Expand/collapse slides the panel; fullscreen animates the width. */
|
||||
transition:
|
||||
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
@@ -152,13 +182,17 @@
|
||||
(issue #52: below the app's overlay stack so popups win over the
|
||||
workbench). */
|
||||
.bottomPanel {
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
border-top: 1px solid var(--dsw-alias-border-l2);
|
||||
/* Re-enable interaction inside the click-transparent panel host. */
|
||||
pointer-events: auto;
|
||||
/* Bottom safe-area (notched phones); 0 on desktops. */
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
/* Expand/collapse slides the panel; height drags animate the height. */
|
||||
transition:
|
||||
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
@@ -181,6 +215,22 @@
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* Layout isolation: panel-internal reflows (tabs, editors, terminals) never
|
||||
leak into the app shell's layout, and the app shell's layout changes
|
||||
never cascade through the panel subtree — the drag stays confined.
|
||||
will-change: transform is only live while a drag is (the body attribute
|
||||
toggles with the drag state), promoting the panels to their own layer
|
||||
without paying the compositing cost at rest. */
|
||||
.panel,
|
||||
.bottomPanel {
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
body[data-dsh-sidebar-dragging] .panel,
|
||||
body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* The bottom panel's height drag: the top edge strip (mirror of the right
|
||||
panel's width strip), centered on the border like the width strip. */
|
||||
.bottomResize {
|
||||
|
||||
+3
-1
@@ -121,7 +121,9 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
|
||||
terminalFontFamily: z.string().default(''),
|
||||
terminalFontSize: z.number().step(1).min(TERMINAL_FONT_SIZE_MIN).max(TERMINAL_FONT_SIZE_MAX).default(TERMINAL_FONT_SIZE_DEFAULT),
|
||||
interceptOpenPath: z.boolean().default(true),
|
||||
editorExplorer: z.boolean().default(true),
|
||||
editorExplorer: z.boolean().default(false),
|
||||
terminalShell: z.string().default(''),
|
||||
terminalShellArgs: z.string().default(''),
|
||||
titleBarCompat: z.boolean().default(false),
|
||||
titleBarStripPx: z.number().step(1).min(TITLE_BAR_STRIP_MIN).max(TITLE_BAR_STRIP_MAX).default(TITLE_BAR_STRIP_DEFAULT),
|
||||
htmlViewerNoSandbox: z.boolean().default(false),
|
||||
|
||||
@@ -428,6 +428,13 @@ declare module 'cordis' {
|
||||
* dictionaries under the `betterSidebar` namespace. Client side only.
|
||||
*/
|
||||
locale: SidebarLocaleService
|
||||
/**
|
||||
* The client module system (rc.8+): resolves module-table specifiers
|
||||
* (seed words, graph rows) — the chunk loader's externals go through it
|
||||
* (`ctx.modules.import`). rc.7 exposed the same surface as the
|
||||
* `window.__DSH_MODULES__` page global; the loader keeps that fallback.
|
||||
*/
|
||||
modules: { import(specifier: string): Promise<unknown> }
|
||||
/**
|
||||
* The host background-job registry (`ctx.get('jobs')`; optional — the
|
||||
* sidebar routes degrade to a 503 when the deployment lacks it).
|
||||
|
||||
+27
-3
@@ -192,6 +192,26 @@ export interface SidebarSettingsFace {
|
||||
}
|
||||
|
||||
/** Build the API method table bound to the plugin context, pty manager, agent pty registry, resolved config, and effective terminal shell. */
|
||||
/**
|
||||
* Resolve the settings-page terminal shell overrides (the terminal card's
|
||||
* gear rows). Empty fields mean "unset": keep the yaml `config.shell` /
|
||||
* `shellArgs` (or the platform auto-resolution). The settings page is the
|
||||
* runtime complement to the boot-time yaml — same contract, later binding:
|
||||
* the values here win for terminals opened afterwards.
|
||||
*/
|
||||
function shellOverridesOf(getSettings: () => SidebarSettingsFace | undefined): { shell?: string; shellArgs?: string[] } {
|
||||
const settings = getSettings()
|
||||
const value = settings?.get().value
|
||||
if (value === null || typeof value !== 'object') return {}
|
||||
const record = value as Record<string, unknown>
|
||||
const shell = typeof record.terminalShell === 'string' ? record.terminalShell.trim() : ''
|
||||
const args = typeof record.terminalShellArgs === 'string' ? record.terminalShellArgs.trim() : ''
|
||||
return {
|
||||
shell: shell === '' ? undefined : shell,
|
||||
shellArgs: args === '' ? undefined : args.split(/\s+/).filter(Boolean),
|
||||
}
|
||||
}
|
||||
|
||||
function buildApi(
|
||||
ctx: Context,
|
||||
ptyManager: PtyManager | null,
|
||||
@@ -518,7 +538,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
// Degraded mode (node-pty unavailable): never register the terminal
|
||||
// tools — every one of them would fail at spawn time.
|
||||
if (agentPtyRegistry === null) return
|
||||
toolsDisposers = registerTools(ctx, agentPtyRegistry, (sessionId) => sessionCwdOf(ctx, sessionId))
|
||||
toolsDisposers = registerTools(ctx, agentPtyRegistry, (sessionId) => sessionCwdOf(ctx, sessionId), () => shellOverridesOf(() => settingsFace))
|
||||
}
|
||||
} else if (toolsDisposers !== null) {
|
||||
toolsDisposers()
|
||||
@@ -740,7 +760,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
// The structural request/socket/head faces satisfy the shared fence;
|
||||
// the `ws` package wants the real Node types — cast at this boundary.
|
||||
wss.handleUpgrade(req as unknown as IncomingMessage, socket as unknown as Duplex, head as Buffer, (ws) => {
|
||||
void attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolved)
|
||||
void attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolved, () => settingsFace)
|
||||
})
|
||||
},
|
||||
}), 'dsh-better-sidebar: terminal WebSocket')
|
||||
@@ -824,6 +844,7 @@ async function attachTerminal(
|
||||
ws: WebSocket,
|
||||
req: SidebarHttpRequest,
|
||||
resolved: ResolvedSidebarConfig,
|
||||
getSettings: () => SidebarSettingsFace | undefined,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
||||
@@ -857,7 +878,10 @@ async function attachTerminal(
|
||||
return
|
||||
}
|
||||
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
|
||||
const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24)
|
||||
// Settings-page shell overrides win over the yaml/auto shell for
|
||||
// terminals opened from now on (existing pty handles keep their shell).
|
||||
const overrides = shellOverridesOf(getSettings)
|
||||
const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24, overrides.shell, overrides.shellArgs)
|
||||
// Replay the transcript, then follow live output.
|
||||
if (handle.transcript !== '') ws.send(handle.transcript)
|
||||
const onData = (data: string): void => {
|
||||
|
||||
+18
-1
@@ -69,6 +69,21 @@ export interface SidebarPrefs {
|
||||
* Side card settings; off restores the pre-merge editor exactly.
|
||||
*/
|
||||
editorExplorer: boolean
|
||||
/**
|
||||
* The shell the UI and agent terminals spawn (absolute path or bare
|
||||
* executable name). Empty (default) keeps the legacy resolution order:
|
||||
* `cordis.patch.yml` `config.shell`, then `$SHELL` / login shell /
|
||||
* `powershell.exe` on Windows. Set it from the terminal card's gear in
|
||||
* the Side card settings (or the yaml) to pin a specific shell — takes
|
||||
* effect for terminals opened afterwards.
|
||||
*/
|
||||
terminalShell: string
|
||||
/**
|
||||
* Explicit arguments for `terminalShell`, space-separated (empty keeps
|
||||
* the platform defaults; when set, they fully replace them — same
|
||||
* contract as the yaml `shellArgs`).
|
||||
*/
|
||||
terminalShellArgs: string
|
||||
/**
|
||||
* Position compatibility mode: reserves space at the top for the native
|
||||
* Windows title bar (drawn at the window's top-right corner over the web
|
||||
@@ -188,7 +203,9 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT,
|
||||
interceptOpenPath: true,
|
||||
editorExplorer: true,
|
||||
editorExplorer: false,
|
||||
terminalShell: '',
|
||||
terminalShellArgs: '',
|
||||
titleBarCompat: false,
|
||||
titleBarStripPx: TITLE_BAR_STRIP_DEFAULT,
|
||||
htmlViewerNoSandbox: false,
|
||||
|
||||
+10
-2
@@ -104,7 +104,15 @@ export class PtyManager {
|
||||
* @returns the live handle.
|
||||
* @throws {SidebarError} pty-error when the per-session cap is reached.
|
||||
*/
|
||||
open(sessionId: string, tabId: string, cwd: string, cols: number, rows: number): SidebarPty {
|
||||
open(
|
||||
sessionId: string,
|
||||
tabId: string,
|
||||
cwd: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
shell?: string,
|
||||
shellArgs?: string[],
|
||||
): SidebarPty {
|
||||
const key = `${sessionId}:${tabId}`
|
||||
this.cancelClose(key)
|
||||
const existing = this.sessions.get(key)
|
||||
@@ -123,7 +131,7 @@ export class PtyManager {
|
||||
sessionId,
|
||||
tabId,
|
||||
cwd,
|
||||
pty: this.nodePty.spawn(this.shell, shellSpawnArgs(this.shellArgs), {
|
||||
pty: this.nodePty.spawn(shell ?? this.shell, shellSpawnArgs(shellArgs ?? this.shellArgs), {
|
||||
name: 'xterm-256color',
|
||||
cols: Math.max(2, Math.floor(cols)),
|
||||
rows: Math.max(2, Math.floor(rows)),
|
||||
|
||||
+3
-1
@@ -81,6 +81,7 @@ export function registerTools(
|
||||
ctx: Context,
|
||||
registry: AgentPtyRegistry,
|
||||
resolveCwd: (sessionId: string) => string,
|
||||
readShellOverrides: () => { shell?: string; shellArgs?: string[] },
|
||||
): () => void {
|
||||
const disposers: Array<() => void> = []
|
||||
const register = (tool: ReturnType<typeof defineTool>): void => {
|
||||
@@ -127,7 +128,8 @@ export function registerTools(
|
||||
exec.signal.throwIfAborted()
|
||||
const sessionId = sessionIdOf(exec)
|
||||
const cwd = resolveCwd(sessionId)
|
||||
const uuid = registry.create(sessionId, args.title, args.command, cwd, 80, 24)
|
||||
const { shell, shellArgs } = readShellOverrides()
|
||||
const uuid = registry.create(sessionId, args.title, args.command, cwd, 80, 24, shell, shellArgs)
|
||||
return Promise.resolve({ uuid, title: args.title })
|
||||
},
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user