Compare commits

..
31 Commits
Author SHA1 Message Date
Atlas Zeng 9ad0a49b8a feat(settings): mutual exclusion with the dsh-web-ui aionui-panel provider (v0.13.0) (#181)
Reads the aionui-panel settings namespace's rightPanel provider choice: while
it resolves to 'aionui-panel', the whole sidebar does not mount (host
settings.get reports externalDisable, the client mount gate skips mounting
and the link/open-path/turn-tail takeovers step aside). Live via the
settings/document-updated push (best effort; deployments without the
'remote' service fall back to boot-time evaluation). No new user-facing
setting — the aionui panel card remains the single source of truth.
2026-08-18 00:44:22 +08:00
Atlas Zeng 6ee1ba11d0 feat(editor): 文件窗口与资源管理器二合一 + 声明式设置 select 行 (#151)
* feat(editor): merged explorer mode — path input, docked file tree, global name search

New editorExplorer pref (default on, editor card gear): the editor tab gains
a path-input header and a toggleable right-docked tree panel (per-tab state
in tab.meta.treeOpen; file tabs default closed, the seeded path-less home
tab defaults open). Fresh sessions seed that empty editor tab instead of
the explorer tab when merged mode is on; the standalone explorer tab stays
in the + menu unchanged.

- Extract FileTree.tsx from ExplorerView (shared by both hosts)
- Host fs.search route: recursive case-insensitive name search rooted at
  the session cwd, budget-capped (200 matches / 100k visited), skips .git,
  never descends symlink dirs
- EditorHost reads the pref reactively (boolean snapshot) and keeps the
  pre-merge layout verbatim when off

* feat(settings): select row type for declarative settings; editorExplorer as iconed select

- SidebarSettingToggle gains type 'select' with options {value,title,desc,icon}
  and multi (default false; stores the picked values as an array in options
  order). Any option carrying an icon renders big-icon option cards in the
  dropdown and the icon in the closed anchor; icon-less rows are one-line text.
- SelectRow in SideCardSection rides the primitives Menu (portal); single-pick
  commits and closes, multi toggles and stays open. Wired for both host-prefs
  toggles and plugin-owned pluginToggles via the new onSelectValue seam.
- The editorExplorer merged-mode pref is now an iconed select (merged vs
  separate) instead of a switch; the stored value stays boolean.
- Advertise the capability as 'settingSelect' in SIDEBAR_FEATURES.

* feat(editor): unify explorer into the editor surface, resizable tree dock, hoisted toolbar

- One tree implementation, two presentations: the shared TreePanel (global
  name search + refresh + FileTree) docks as the editor tab's right panel
  and — in merged mode — fills the explorer tab's window; the pref-off
  explorer keeps its legacy header shell.
- The docked panel drag-resizes from its left edge (160-480px clamp),
  persisted as tab.meta.treeWidth alongside treeOpen.
- The text editor's preview/edit/save toolbar moves into the path-input
  header row: FileViewerProps gains internal toolbar fields ('host' mode
  skips the viewer's own row, reports EditorToolbarState and registers
  EditorToolbarControls); non-merged mode is byte-identical to before.

* feat(editor): single files window with in-place switching; explorer descriptor removed

- editor tab replaces explorer on the + menu (order 10, Files); persisted
  explorer tabs migrate to the editor home tab via sanitizeState
- editorExplorer now only controls open behavior: merged = in-place
  updateTab, independent = openSidebarFile; both modes share the merged
  chrome (path input + hoisted preview/edit/save toolbar + tree toggle)
- file tree context menu gains 'open in new tab' / 'open to the side'
  (mintTabId avoids the id safety net; insertLeafAt splits right)
- tree dock resize uses pointer capture (touch-action none, 6px handle)
- defaults: openByDefault off, sidebar width 35%
- file tree clips horizontal overflow (no sideways scroll)
- e2e: mount lane arms the editor-chunk wait before goto and clicks the
  tree row's left edge (hover @-reference button sits mid-row); drag lane
  expands the panel first (collapsed push writes 0px), waits for the
  slide-in to settle, and picks the leftmost col-resize strip; 'Files'
  title assertions require draggable to skip the bottom panel's welcome
  cards

* feat(editor): split mode turns the path-less window into the standalone explorer

In independent (split) mode a path-less files window renders ONLY the
tree panel (search + FileTree, full-window form) — no path input, no
hoisted toolbar, no tree toggle. File clicks open per-path editor tabs
(full chrome, tree docked closed by default) through openSidebarFile,
unchanged. Merged mode is untouched (chrome + docked tree, in-place
switching). TreePanel's previously reserved 'full' prop now has its
consumer.
2026-08-17 15:00:03 +08:00
Menghuan1918 c923fc57b1 docs: v0.12.3 最近更新补全 0.12.2→0.12.3 全部变更(皮肤兼容/路径处理/终端shell/语言/插件目录等) 2026-08-17 00:53:14 +08:00
Menghuan1918 f39156607e docs: 安装/更新命令统一为 dsh-better-sidebar@latest(双语) 2026-08-17 00:39:44 +08:00
Menghuan1918 ab785d71fb docs: 最近更新按 0.12.0–0.12.3 版本分栏(双语) 2026-08-17 00:35:05 +08:00
Atlas Zeng 333961be71 Merge pull request #150 from omdsh-dev/feat/release-ci
ci: GitHub Release 自动触发 npm 发版(Trusted Publishing)
2026-08-17 00:29:47 +08:00
Atlas Zeng 618858b5b1 Merge pull request #149 from omdsh-dev/release/v0.12.3
docs: v0.12.3 发布说明与安装命令更新
2026-08-17 00:29:40 +08:00
Menghuan1918 565e66d30e docs: 安装/更新说明只留 dsh plugin add 命令,移除一键脚本引用 2026-08-17 00:26:48 +08:00
Menghuan1918 de5234a3d7 docs: v0.12.3 最近更新改图片行+文本,安装直接提供 dsh plugin add 命令 2026-08-17 00:22:39 +08:00
Atlas Zeng a673f2399f ci: GitHub Release 自动触发 npm 发版(Trusted Publishing) (#148)
- 新增 .github/workflows/release.yml:release published 事件触发,
  校验 tag 与 package.json 版本一致后 pnpm publish --provenance
- 认证走 npm Trusted Publishing(OIDC),无需 NPM_TOKEN secret
- workflow_dispatch 提供 dry_run 调试入口(只打包不发版)
- AGENTS.md 补充发版流程与 npm 后台一次性配置说明
2026-08-17 00:16:03 +08:00
Menghuan1918 6f52aa79d2 ci: GitHub Release 自动触发 npm 发版(Trusted Publishing)
- 新增 .github/workflows/release.yml:release published 事件触发,
  校验 tag 与 package.json 版本一致后 pnpm publish --provenance
- 认证走 npm Trusted Publishing(OIDC),无需 NPM_TOKEN secret
- workflow_dispatch 提供 dry_run 调试入口(只打包不发版)
- AGENTS.md 补充发版流程与 npm 后台一次性配置说明
2026-08-17 00:12:29 +08:00
Atlas Zeng 8d9ab1cd2f Merge pull request #147 from omdsh-dev/fix/node-pty-load-failure-repair
fix: node-pty 加载失败降级为终端修复提示,不再拖垮 server(#140)
2026-08-17 00:06:20 +08:00
yq a0918496f3 Merge pull request #91 from yq04/feat/catalog-dsh-git-remotes
Recommend dsh-git-remotes in the tab plugin catalog
2026-08-17 00:05:24 +08:00
Atlas Zeng aea9fcd3a0 Merge pull request #134 from omdsh-dev/feat/unified-path-handling
feat: 统一路径处理——UNC/软链接分类/html 路由平台守卫(#65 #67 #43 #79 #115)
2026-08-16 23:47:26 +08:00
Menghuan1918 be95787c00 fix: symlink 探测有界并发 + UNC 标记平台中性化(回应 CR/自审)
- fs-tree: symlink 目标 stat 从串行改为 32 并发工作池(readdir 流关闭后统一
  探测),symlink 密集/网络目标目录不再累加延迟;顺序与分类语义不变
- html-route: 解码不再重建反斜杠 UNC,统一重建为平台中性的 //server/share/...
  前斜杠形态(win32/POSIX 均正确 resolve),从而删除 windowsPathStyle 平台信号
  与 client isWindowsStylePath 助手;同时解决无 cwd 时正斜杠 UNC 的往返盲点
- 测试/文档同步;移除未使用的 isPosix
2026-08-16 23:35:57 +08:00
Menghuan1918 9652bbda59 fix(html): 正斜杠 UNC 路径在匹配正斜杠 cwd 时也保留标记(回应 Codex P2 #2)
当 cwd 与产出路径都是 //server/share/... 形式时,此前两个
isWindowsStylePath 都为 false 而漏掉 '//' 标记,win32 解码后落在当前
盘符被 isWithin 拒绝。现按 review 建议:'//' 路径仅在其 '//' cwd 匹配时
视为 UNC;POSIX 会话的 '//' 绝对路径仍保持无标记。
2026-08-16 23:24:53 +08:00
Menghuan1918 0052061a72 fix(html): UNC 标记信号纳入文件路径本身(回应 Codex P2 review)
cwd 未水合或为正斜杠形式时,仅凭 scope.cwd 会漏掉反斜杠 UNC 路径的
'//' 标记,win32 上预览被 isWithin 拒绝(403)。htmlUrl 现在同时以
isWindowsStylePath(path) 判定:明确的反斜杠 UNC 路径始终保留标记;
'//' 正斜杠路径仍只由 cwd 形态决定(POSIX 不受影响)。
2026-08-16 23:24:50 +08:00
Menghuan1918 db189e3852 feat: 统一路径处理——UNC/软链接分类/html 路由平台守卫(#65 #67 #43 #79 #115)
- fs-tree: requireAbsolute 改用 path.isAbsolute(win32 UNC 放行、C:foo 仍拒);
  目录列表仅对 symlink 行 stat 一次,暴露 isSymlink/broken,目录软链接可展开
- html-route: UNC 路径经 '//' 标记编码往返;标记仅在 Windows 风格会话启用,
  POSIX '//' 绝对路径不再被误判(平台守卫)
- client: isAbsolutePath/isWindowsStylePath 镜像 + resolveSidebarPath 识别 UNC,
  htmlUrl 以会话 cwd 形态传递平台信号;FsEntry 增加 isSymlink/broken
- explorer: 软链接行链接徽标、悬空链接标红 + tooltip(issue #65)
- 测试: tests/platform.ts 守卫助手;win32 专属用例改为 skipIf 可见跳过,
  补 POSIX 反向分支与 UNC 矩阵;新增 fs-tree-symlink.spec
2026-08-16 23:24:43 +08:00
taurusandyukun.wu bb3b40ff15 refactor(tests): decentralize monolithic unit.spec.ts and add state boundary tests (#141)
Co-authored-by: yukun.wu <yukun.wu@justcoglobal.com>
2026-08-16 23:06:44 +08:00
zemul 5c138ca851 catalog: add dsh-video-preview (video previewer) to the file-previewer recommended plugins (#126)
* catalog: add dsh-video-preview to the file-previewer recommended plugins

Adds the video preview plugin (dsh-video-preview) to the "add preview plugin" modal catalog, with zh/en descriptions. Data integrity is guarded by tests/plugin-list.spec.ts.

* test: make add-plugin-modal window.open count catalog-driven

The test pinned the viewer catalog to a single entry (2 "Open:" buttons).
Adding a second recommended plugin (dsh-video-preview) renders 4 buttons
and broke the hardcoded assertion. Derive the expected count from
builtinViewerPlugins so adding/removing plugins keeps the test honest.
2026-08-16 22:32:17 +08:00
Atlas Zeng fc4031e65f fix: 迁移弃用的 xterm 依赖至 @xterm/xterm(Closes #122) (#128)
* fix: 迁移弃用的 xterm 依赖至 @xterm/xterm(#122)

- dependencies 移除 xterm@^5.3.0(已弃用)与 @xterm/addon-fit(二者始终
  内联进 lib/client-terminal.js 懒加载 chunk,运行时零依赖)
- 二者移至 devDependencies(构建期仍需:tsdown 内联 + CSS 构建期解析 + 类型)
- 代码与构建注释改用官方新包名 @xterm/xterm@^5.5.0(addon-fit@0.10.0 的
  peer 约束锁 ^5.0.0,不用 6.x),CSS 路径同步为 @xterm/xterm/css/xterm.css
- 消除用户安装时的 deprecated 警告与 missing peer @xterm/xterm 警告

* chore: 顺带升级 @xterm/addon-fit 至 ^0.11.0

- 0.11.0(2025-12 发布)不再声明 peer 依赖,消除 peer 解析噪音
- 其运行时 API(_renderService.clear / options.overviewRuler 兜底)与
  @xterm/xterm@^5.5.0 兼容:overviewRuler 在 5.x 为 undefined 时回退 14,
  与 0.10.0 行为一致;typecheck/build/test(515)/ 挂载门禁均通过
2026-08-16 19:14:57 +08:00
Atlas Zeng 3c196fffc3 feat: 皮肤兼容(令牌驱动)+ CSS 修复(#106 #105 #90 #60,附带 #52 #57 #92) (#110)
令牌驱动方案(主目标:与 dsh-web-ui 皮肤中心兼容):
- 面板表面改用通用卡片令牌 --dsw-alias-bg-layer-1,不再消费宿主左导航专属
  --dsw-specific-sidebar-fill——dsh-web-ui 10 款皮肤全部覆盖 --dsw-alias-*
  层,换肤后侧边栏自动跟随,零每皮肤适配(#60/#105/#106)。
- effectiveTokenValue 增加 alpha 阈值(新增 colorAlpha 解析 computed 颜色):
  transparent 与 alpha<0.9 的半透明玻璃值(dsh-web-ui 皮肤 bg-base 为
  rgba 0.16–0.7)一律回退不透明底色,终端/编辑器文字永不叠在皮肤背景画上
  (#90);≥0.9 的近不透明值放行,皮肤仍可控终端表面。

独立修复(保留):
- z-index 降到 DSH 浮层栈(100+)之下:面板 40 / 按钮簇 45 / 角手柄面板内
  局部 2,Cordis 弹出框不再被底部工作台遮挡(#52)。
- 角手柄移入右面板、CSS 相对定位(bottom: calc(var(--dsh-sidebar-height)
  + 6px)),删除 JS 内联 viewport 坐标。
- 刷新按钮统一 14px(#57 前半)。
- 拖拽逐帧回归 e2e(#92):真实挂载下 rAF 采样(拖条以 cursor: col-resize
  语义定位),断言拖拽期间 data-dsh-sidebar-dragging 存在、#root transition
  为 none、会话列与面板边单调 1:1 跟随(实测通过)。

按 KISS 回退首版契约:类名 kebab-case 改名与 data-bs-* 语义钩子全部移除
(令牌驱动下不需要,改名引入无谓 breaking),skin-hooks 测试删除;
mount e2e 改为断言 --dsh-sidebar-width 布局变量随面板挂载生效。

文档:AGENTS.md §8 重写为「皮肤兼容(令牌驱动)」;设计文档记录决策与
回退。README 与版本号不在本批更新(发布流程统一处理)。

测试:tests/theme.spec.ts(colorAlpha + effectiveTokenValue 阈值);
typecheck/vitest(511)/build/consumer-types/test:mount 全绿。
2026-08-16 16:37:11 +08:00
oy-g fd9544f16b feat: configurable terminal shell with Windows pwsh auto-probe (#95)
* feat: configurable terminal shell with Windows pwsh auto-probe

Windows previously hardcoded powershell.exe (5.1) in defaultShell(), ignoring PowerShell 7 installs. Resolve the shell in priority order: explicit shell config -> DSH_SIDEBAR_SHELL -> PATH/known-dir probe for pwsh.exe -> inbox 5.1 fallback. The same resolved shell feeds both the UI terminal tabs (PtyManager) and the model-facing terminal_* tools (AgentPtyRegistry).

defaultShell() now accepts injectable platform/env/exists options so the Windows chain is unit-testable on the ubuntu CI, which never executes win32 branches. The probe also checks ProgramW6432 for 32-bit Node, and machines without PowerShell 7 keep the 5.1 fallback.

Refs: #46

* fix: trim resolved SHELL and document ProgramW6432 in resolver contract

- POSIX SHELL is validated with trim() but was returned untrimmed; trailing whitespace could leak into the spawned executable path. Return the trimmed value like the explicit/config and Windows/env branches.
- ShellResolutionOptions.env comment now lists ProgramW6432, which the Windows probe prefers over ProgramFiles on 32-bit Node.
- Test pins the SHELL trimming behavior.
2026-08-16 16:34:21 +08:00
時芽ときめ 835d1538e0 Merge pull request #114 from XuYouo/main
fix(ui): 优化侧边卡片设置页图标与布局
2026-08-16 16:24:28 +08:00
Atlas Zeng 9b50f5fb64 Merge pull request #120 from yanzhaohui1999/feat/editor-languages
feat(editor): add C# / Kotlin / Swift syntax highlightingFeat/editor languages
2026-08-16 16:23:11 +08:00
ymac d33d6764fc test(smoke): fix flaky login-shell temp dir cleanup (ENOTEMPTY on CI)
disposeAll() only requests the pty kill; the bash child exits
asynchronously via onExit, so deleting the temp HOME right after
races the still-running shell (it may be writing history files) and
fails with ENOTEMPTY on CI (seen on both #114 and #120).

Wait for the spawned handle to report exited (bounded 2s) before
removing the directory, with a short retry loop as a fallback.
2026-08-16 13:45:18 +08:00
ymac 515ed0abd0 feat(editor): add Swift (.swift) syntax highlighting
Map .swift to the built-in swift StreamLanguage from
@codemirror/legacy-modes/mode/swift (no new dependencies).
2026-08-16 04:12:30 +08:00
ymac d7dc609553 feat(editor): add Kotlin (.kt/.kts) syntax highlighting
Map .kt/.kts to the built-in kotlin StreamLanguage from
@codemirror/legacy-modes/mode/clike (no new dependencies).
2026-08-16 04:11:30 +08:00
ymac c9372de69b feat(editor): add C# (.cs) syntax highlighting
Map the .cs extension to the built-in csharp StreamLanguage from
@codemirror/legacy-modes/mode/clike (already a dependency, no new packages),
so .cs files open with syntax highlighting in the sidebar editor.
2026-08-16 04:10:38 +08:00
Atlas Zeng ecebc97800 Merge pull request #24 from log-li/fix/markdown-editor-selection-insert
fix: enable selection-to-conversation popup in the markdown editor
2026-08-15 22:47:21 +08:00
Logan 1f9af78c3c fix: enable selection-to-conversation popup in the markdown editor
The "add to conversation" selection popup was registered only for the
catch-all code viewer (viewerId === 'code'), so selecting text in the
CodeMirror editor of a markdown file produced no popup; insertion only
worked in markdown preview mode via the separate DOM-selection handler.

Extend the CodeMirror selection popup to the markdown editor
(viewerId === 'markdown') so line-exact selections can be injected into
the composer draft (\`\`\`rel/path:start-end\`\`\` payload), matching
preview-mode behavior and the code viewer.
2026-08-14 12:59:17 +08:00
84 changed files with 6600 additions and 2532 deletions
+97
View File
@@ -0,0 +1,97 @@
name: Release
# Publishes `dsh-better-sidebar` to npm via npm Trusted Publishing (OIDC) —
# no NPM_TOKEN secret required, provenance is attached to the tarball.
#
# Trigger: a GitHub Release is published (tag vX.Y.Z). The tag MUST match
# package.json `version`; the workflow verifies this before publishing.
#
# One-time npm-side setup (manual, on npmjs.com):
# 1. package `dsh-better-sidebar` -> Settings -> Publishing access
# -> Trusted Publishers -> "Add Trusted Publisher".
# 2. Provider: GitHub Actions
# Organization or user: omdsh-dev
# Repository: DSH-better-sidebar
# Workflow filename: release.yml
# Environment: (blank)
# 3. Save. Do NOT add an NPM_TOKEN secret — Trusted Publishing replaces it.
# Canonical reference: https://docs.npmjs.com/trusted-publishers
on:
release:
types: [published]
workflow_dispatch:
inputs:
dry_run:
description: Pack and verify everything, but do not publish
type: boolean
default: false
permissions:
contents: read
id-token: write # mint the OIDC token npm Trusted Publishing exchanges
concurrency:
group: npm-release
cancel-in-progress: false
jobs:
publish:
name: build · verify · publish
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
with:
# Tests run git log against this repository and need full history.
fetch-depth: 0
- uses: pnpm/action-setup@v6
with:
version: 11
- uses: actions/setup-node@v7
with:
node-version: 22
registry-url: https://registry.npmjs.org
cache: pnpm
# The plugin's git revert/cherry-pick spawn git without an identity
# (see tests/smoke.spec.ts); the runner has none by default.
- name: Configure git identity for tests
run: |
git config --global user.name "dsh-better-sidebar-ci"
git config --global user.email "ci@dsh.invalid"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Verify release tag matches package.json version
if: github.event_name == 'release'
env:
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
expected="v$(node -p "require('./package.json').version")"
echo "package.json version: ${expected#v}"
echo "release tag: ${RELEASE_TAG}"
if [ "${RELEASE_TAG}" != "${expected}" ]; then
echo "::error::Release tag ${RELEASE_TAG} does not match package.json version ${expected#v} (expected ${expected})."
exit 1
fi
- name: Build
run: pnpm build
- name: Typecheck
run: pnpm typecheck
- name: Test
run: pnpm test
- name: Dry-run publish (validation only)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true'
run: pnpm publish --dry-run --no-git-checks
- name: Publish to npm
if: github.event_name == 'release' || github.event.inputs.dry_run != 'true'
run: pnpm publish --provenance --access public --no-git-checks
+74 -17
View File
@@ -19,10 +19,19 @@ better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context
1. `pnpm build && pnpm pack` 产出 tarball(与发布产物一致)。 1. `pnpm build && pnpm pack` 产出 tarball(与发布产物一致)。
2. `scripts/e2e-mount.sh` 用官方 CLI 把它装进一个**全新 scratch profile**(`dsh plugin --profile web add file:<tarball>`,触发 `dsh.profile.bundles` 协调),然后启动真实 `dsh web`(keyless,`--port 0`)。 2. `scripts/e2e-mount.sh` 用官方 CLI 把它装进一个**全新 scratch profile**(`dsh plugin --profile web add file:<tarball>`,触发 `dsh.profile.bundles` 协调),然后启动真实 `dsh web`(keyless,`--port 0`)。
3. `tests/e2e/mount.e2e.ts`(Playwright Chromium)加载页面,断言外壳与 `[data-dsh-better-sidebar]` 挂载、无 `dsh-better-sidebar:` 错误条、无 pageerror/插件 console 错误,并通过「+ 菜单」逐个打开内置 tab(含终端懒加载 chunk)深扫,再经 Explorer 打开 seed 文件强制加载 editor 懒加载 chunk(`client-editor.js`)——缺失的内置 tab 或 chunk 都会使门禁变红。 3. `tests/e2e/mount.e2e.ts`(Playwright Chromium)加载页面,断言外壳与 `[data-dsh-better-sidebar]` 挂载、无 `dsh-better-sidebar:` 错误条、无 pageerror/插件 console 错误,显式展开面板(`openByDefault` 默认关)后通过「+ 菜单」逐个打开内置 tab(含终端懒加载 chunk)深扫,再经 Files 文件窗口的内嵌树打开 seed 文件强制加载 editor 懒加载 chunk(`client-editor.js`,原地模式:seed 的 home tab 原地切换到文件)——缺失的内置 tab 或 chunk 都会使门禁变红。
本地跑:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`(需 PATH 上有 `dsh` 或可经 npx 拉取)。DSH CLI 版本在 CI 钉住 `@deepseek-ai/dsh@0.1.0-rc.6`(与插件 peer 范围同步)。`tests/e2e` 的 spec 命名 `*.e2e.ts` + vitest `exclude` 双保险与 vitest 隔离;**改动 vitest `exclude` 时必须保留默认排除项**(`exclude` 会整体替换默认值)。 本地跑:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`(需 PATH 上有 `dsh` 或可经 npx 拉取)。DSH CLI 版本在 CI 钉住 `@deepseek-ai/dsh@0.1.0-rc.6`(与插件 peer 范围同步)。`tests/e2e` 的 spec 命名 `*.e2e.ts` + vitest `exclude` 双保险与 vitest 隔离;**改动 vitest `exclude` 时必须保留默认排除项**(`exclude` 会整体替换默认值)。
### npm 发版(GitHub Release → npm publish)
`.github/workflows/release.yml` 在 GitHub Release 发布(tag `vX.Y.Z`)时自动发版到 npm:
1. **发版前置**:`package.json` 版本号 bump 到 `X.Y.Z`(manifest 一致性守卫会校验其它副本),CI 全绿后打 tag `vX.Y.Z` 并发布 GitHub Release。tag 必须与 package.json 版本一致——workflow 在发布前校验,不匹配直接失败、不发版。
2. **流程**:workflow 依次执行 `pnpm build` / `pnpm typecheck` / `pnpm test`,校验 tag,然后 `pnpm publish --provenance --access public`(`prepublishOnly` 会重建产物;产物带 provenance 签名)。
3. **认证**:走 npm **Trusted Publishing(OIDC)**,不需要也不应配置 `NPM_TOKEN` secret。一次性手动配置(在 npmjs.com 完成):package `dsh-better-sidebar` → Settings → Publishing access → Trusted Publishers → Add Trusted Publisher,字段为 Provider `GitHub Actions`、Organization `omdsh-dev`、Repository `DSH-better-sidebar`、**Workflow filename `release.yml`**、Environment 留空。未配置前发布会失败(OIDC 交换报错),配置后无需改 workflow。
4. **调试**:`workflow_dispatch` 手动触发 + `dry_run=true` 只打包验证、不发版。
--- ---
## 1. 服务定位 ## 1. 服务定位
@@ -122,7 +131,7 @@ interface TabDescriptor {
title: string | (() => string) title: string | (() => string)
/** 图标:ReactNode 或 (size: number) => ReactNode */ /** 图标:ReactNode 或 (size: number) => ReactNode */
icon?: ReactNode | ((size: number) => ReactNode) icon?: ReactNode | ((size: number) => ReactNode)
/** + 菜单排序(升序);默认 100。内置:explorer=10, git=20, subagent=30, terminal=40 */ /** + 菜单排序(升序);默认 100。内置:editor=10, git=20, subagent=30, terminal=40, browser=50 */
order?: number order?: number
/** 从 + 菜单隐藏(editor/diff 用:由其他流程触发打开,不在菜单里) */ /** 从 + 菜单隐藏(editor/diff 用:由其他流程触发打开,不在菜单里) */
hidden?: boolean hidden?: boolean
@@ -136,7 +145,7 @@ interface TabDescriptor {
/** /**
* 去重键:openTab 时若已存在 dedupeKey 相同的 tab,则聚焦而非新开。 * 去重键:openTab 时若已存在 dedupeKey 相同的 tab,则聚焦而非新开。
* 返回 undefined 表示不去重(每次都新开,但同 id 会被 id 安全网聚焦)。 * 返回 undefined 表示不去重(每次都新开,但同 id 会被 id 安全网聚焦)。
* 内置策略:explorer/git/subagent 用 single: true;editor 用 tab => tab.path;diff 用 tab => tab.id。 * 内置策略:git/subagent 用 single: true;editor 用 tab => tab.path;diff 用 tab => tab.id。
*/ */
dedupeKey?: (tab: SidebarTab) => string | undefined // 必须保持纯函数:每次 open 会求值两次,抛错会向外传播 dedupeKey?: (tab: SidebarTab) => string | undefined // 必须保持纯函数:每次 open 会求值两次,抛错会向外传播
/** /**
@@ -159,20 +168,24 @@ interface TabDescriptor {
* 声明式设置(v0.4.1+):每个注册的 tab 都会在 Side card 设置页获得一行 * 声明式设置(v0.4.1+):每个注册的 tab 都会在 Side card 设置页获得一行
* 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套设置行, * 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套设置行,
* 绑定 SidebarPrefs 字段。嵌套设置仅父级启用时显示(v0.11.0 起行控件不限于 * 绑定 SidebarPrefs 字段。嵌套设置仅父级启用时显示(v0.11.0 起行控件不限于
* 布尔开关:`type: 'switch' | 'text' | 'number'`,缺省 'switch';text/number * 布尔开关:`type: 'switch' | 'text' | 'number' | 'select'`,缺省 'switch';
* 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀)。 * text/number 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀;
* select 行是下拉选择:options 声明 `{ value, title, desc?, icon? }`,任一
* 选项带 icon 时下拉渲染大图标选项卡、收起态同样显示图标,否则单行文本;
* `multi: true`(缺省 false)多选,存储值为选中 value 的数组,按 options
* 顺序提交)。
* v0.12.0 起增加两个插件自有扩展(详见 §5 声明式设置): * v0.12.0 起增加两个插件自有扩展(详见 §5 声明式设置):
* `pluginToggles`(插件自有 key,持久化在 pluginSettings[id],无需宿主 schema 字段) * `pluginToggles`(插件自有 key,持久化在 pluginSettings[id],无需宿主 schema 字段)
* 与 `render`(自定义设置面板,替代行列表)。 * 与 `render`(自定义设置面板,替代行列表)。
*/ */
settings?: { settings?: {
toggles?: readonly { toggles?: readonly {
/** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'terminalFontFamily' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks' / 'browserInterceptHttp' / 'browserInterceptHttps') */ /** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'terminalFontFamily' / 'editorExplorer' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks' / 'browserInterceptHttp' / 'browserInterceptHttps') */
key: string key: string
title: string | (() => string) title: string | (() => string)
desc?: string | (() => string) desc?: string | (() => string)
/** 行控件类型;缺省 'switch'(向后兼容)。 */ /** 行控件类型;缺省 'switch'(向后兼容)。 */
type?: 'switch' | 'text' | 'number' type?: 'switch' | 'text' | 'number' | 'select'
/** number 行的提交钳制下限。 */ /** number 行的提交钳制下限。 */
min?: number min?: number
/** number 行的提交钳制上限。 */ /** number 行的提交钳制上限。 */
@@ -181,6 +194,15 @@ interface TabDescriptor {
placeholder?: string placeholder?: string
/** 输入框后的单位后缀(如 'px')。 */ /** 输入框后的单位后缀(如 'px')。 */
unit?: string unit?: string
/** select 行的选项(value 为提交值:string | number | boolean)。 */
options?: readonly {
value: string | number | boolean
title: string | (() => string)
desc?: string | (() => string)
icon?: ReactNode | ((size: number) => ReactNode)
}[]
/** select 行是否多选(缺省 false;存储值为 value 数组)。 */
multi?: boolean
}[] }[]
/** 插件自有设置行(v0.12.0+):形状同 toggles,但 key 是插件局部的, /** 插件自有设置行(v0.12.0+):形状同 toggles,但 key 是插件局部的,
* 持久化在 `pluginSettings[<descriptor id>]`——不需要宿主 PrefsSchema 字段。 */ * 持久化在 `pluginSettings[<descriptor id>]`——不需要宿主 PrefsSchema 字段。 */
@@ -188,11 +210,18 @@ interface TabDescriptor {
key: string key: string
title: string | (() => string) title: string | (() => string)
desc?: string | (() => string) desc?: string | (() => string)
type?: 'switch' | 'text' | 'number' type?: 'switch' | 'text' | 'number' | 'select'
min?: number min?: number
max?: number max?: number
placeholder?: string placeholder?: string
unit?: string unit?: string
options?: readonly {
value: string | number | boolean
title: string | (() => string)
desc?: string | (() => string)
icon?: ReactNode | ((size: number) => ReactNode)
}[]
multi?: boolean
}[] }[]
/** 自定义设置面板(v0.12.0+):给出时齿轮弹窗渲染它而非行列表。 /** 自定义设置面板(v0.12.0+):给出时齿轮弹窗渲染它而非行列表。
* props 含 store/service/prefs、本 descriptor 的 pluginSettings blob、 * props 含 store/service/prefs、本 descriptor 的 pluginSettings blob、
@@ -242,7 +271,7 @@ interface TabComponentProps {
tab: SidebarTab // 当前 tab 实例(含 id/type/title/path?/diff?) tab: SidebarTab // 当前 tab 实例(含 id/type/title/path?/diff?)
visible: boolean // 是否是当前激活 tab 且面板打开(不可见时暂停轮询等) visible: boolean // 是否是当前激活 tab 且面板打开(不可见时暂停轮询等)
// 以下由内置 tab 使用,外部 tab 可忽略: // 以下由内置 tab 使用,外部 tab 可忽略:
expanded?: string[] // explorer 的展开目录集 expanded?: string[] // 文件树的展开目录集
onToggleDir?: (path: string) => void onToggleDir?: (path: string) => void
onReferenceFile?: (path: string) => void onReferenceFile?: (path: string) => void
onOpenFile?: (path: string) => void onOpenFile?: (path: string) => void
@@ -321,8 +350,7 @@ ctx.effect(() => {
| id | order | single | hidden | 用途 | | id | order | single | hidden | 用途 |
|---|---|---|---|---| |---|---|---|---|---|
| `editor` | -1 | 否(按 path 去重) | 是 | 文件编辑/预览(由 openSidebarFile 触发) | | `editor` | 10 | 否(按 path 去重) | 否 | 唯一的「文件窗口」(文件编辑/预览 + 文件资源管理):文件 tab(有 path)在两种 `editorExplorer` 模式下 chrome 恒为合并形态——头部路径输入框 + 文本编辑器预览/编辑/保存控件 + 可开关的内嵌文件树面板(含全局文件名搜索,走 host `fs.search` 路由;左缘拖拽调宽),状态存 `tab.meta.treeOpen` / `tab.meta.treeWidth`;pref 控制**打开行为与无路径窗口形态**:开(默认,合并)= 树点击/输入框 Enter 原地切换当前 tab(`updateTab` 重写 path/title,id 与 meta 不变),无路径窗口 = 带 chrome 的空文件窗口(树默认展开);关(独立)= 走 `openSidebarFile` 按 path 新开,**无路径窗口即独立资源管理器——只渲染文件树面板**(搜索 + FileTree 撑满全窗,无编辑器 chrome)。树右键菜单提供「在新 Tab 中打开」「在侧边打开」(后者在当前 pane 右侧 split 出新 editor tab)。新会话在两种模式下都默认 seed 空文件窗口(`title: 'Files'`,无 path,树面板展开);持久化的旧 `explorer` tab 经 `sanitizeState` 迁移为该 home tab |
| `explorer` | 10 | 是 | 否 | 文件资源管理器 |
| `git` | 20 | 是 | 否 | Git 面板 | | `git` | 20 | 是 | 否 | Git 面板 |
| `subagent` | 30 | 是 | 否 | 子代理拓扑 | | `subagent` | 30 | 是 | 否 | 子代理拓扑 |
| `terminal` | 40 | 否 | 否 | 终端(nextTerminal 自增) | | `terminal` | 40 | 否 | 否 | 终端(nextTerminal 自增) |
@@ -377,6 +405,11 @@ interface FileViewerProps {
truncated?: boolean // fetchStrategy='fsRead' 时 truncated?: boolean // fetchStrategy='fsRead' 时
mediaUrl?: string // fetchStrategy='mediaUrl' 时 mediaUrl?: string // fetchStrategy='mediaUrl' 时
customData?: unknown // fetchStrategy='custom' 时(load() 的返回值) customData?: unknown // fetchStrategy='custom' 时(load() 的返回值)
// 以下三个为内置文本编辑器与 EditorHost 的内部协作字段(合并模式把
// 预览/编辑/保存工具栏上移到路径输入框行);外部 viewer 忽略即可:
toolbar?: 'self' | 'host'
onToolbarState?: (state: EditorToolbarState) => void
onToolbarControls?: (controls: EditorToolbarControls | null) => void
} }
``` ```
@@ -501,7 +534,7 @@ interface BetterSidebarService {
readonly version: string readonly version: string
/** 单调能力清单(只增不删):'badge' | 'tabLifecycle' | 'updateTab' | /** 单调能力清单(只增不删):'badge' | 'tabLifecycle' | 'updateTab' |
* 'openFile' | 'targetedOpen' | 'stateSubscription' | 'tabMeta' | * 'openFile' | 'targetedOpen' | 'stateSubscription' | 'tabMeta' |
* 'pluginSettings' | 'urlTarget'——消费插件用 `features.includes('xxx')` 按能力 gate。 */ * 'pluginSettings' | 'urlTarget' | 'settingSelect'——消费插件用 `features.includes('xxx')` 按能力 gate。 */
readonly features: readonly string[] readonly features: readonly string[]
/** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。 /** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。
* session 未激活时 state/sessionId 为 undefined。 */ * session 未激活时 state/sessionId 为 undefined。 */
@@ -514,7 +547,10 @@ interface BetterSidebarService {
* 未知 id 严格 no-op);scope(v0.12.0+)随回调传递,同 closeTab */ * 未知 id 严格 no-op);scope(v0.12.0+)随回调传递,同 closeTab */
activateTab(tabId: string, scope?: SessionScope): void activateTab(tabId: string, scope?: SessionScope): void
/** 在 scope.sessionId 的侧边栏编辑器打开一个文件(title 缺省为文件名; /** 在 scope.sessionId 的侧边栏编辑器打开一个文件(title 缺省为文件名;
* id 按路径派生,与内置 open-path 拦截一致,不同文件可并排打开) */ * id 按路径派生(`editor:` + path),与内置 open-path 拦截一致,不同文件
* 可并排打开。注意:path 派生 id 只对 openFile/openSidebarFile 的打开成立——
* 文件窗口的原地切换(editorExplorer 开)经 updateTab 重写 path/title,
* tab id 保持稳定、不再与 path 对应) */
openFile(scope: SessionScope, path: string, title?: string): void openFile(scope: SessionScope, path: string, title?: string): void
} }
@@ -532,7 +568,7 @@ interface OpenTabSeed {
} }
``` ```
> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`、终端的 `terminalFontFamily`/`terminalFontSize`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑——`type: 'switch'` 行是复选框,`type: 'text'`/`'number'` 行是输入框(v0.11.0+)——父级卡片关闭时齿轮隐藏;`settings.toggles` 的 **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks` / `browserInterceptHttp` / `browserInterceptHttps`)。**v0.12.0 起设置 seam 已开放**:外部插件用 `settings.pluginToggles`(同款行控件,key 插件局部)或 `settings.render`(自定义面板)声明自己的设置,值持久化在 prefs 文档的 `pluginSettings[<descriptor id>]`(开放 map,宿主 schema 已有字段,无需注册)——齿轮弹窗对 tab 与 viewer 都可用(viewer 卡片 v0.12.0 起也有齿轮)。 > **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`、终端的 `terminalFontFamily`/`terminalFontSize`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑——`type: 'switch'` 行是复选框,`type: 'text'`/`'number'` 行是输入框(v0.11.0+),`type: 'select'` 行是下拉选择(options 带 icon 时为大图标选项卡,`multi: true` 多选存 value 数组)——父级卡片关闭时齿轮隐藏;`settings.toggles` 的 **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `editorExplorer` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks` / `browserInterceptHttp` / `browserInterceptHttps`)。**v0.12.0 起设置 seam 已开放**:外部插件用 `settings.pluginToggles`(同款行控件,key 插件局部)或 `settings.render`(自定义面板)声明自己的设置,值持久化在 prefs 文档的 `pluginSettings[<descriptor id>]`(开放 map,宿主 schema 已有字段,无需注册)——齿轮弹窗对 tab 与 viewer 都可用(viewer 卡片 v0.12.0 起也有齿轮)。
--- ---
@@ -555,12 +591,32 @@ interface OpenTabSeed {
| **host 半无此服务** | `ctx.betterSidebar` 只在 client 侧存在;host 半需要 better-sidebar 数据走 `/sidebar/api/*` HTTP 路由 | | **host 半无此服务** | `ctx.betterSidebar` 只在 client 侧存在;host 半需要 better-sidebar 数据走 `/sidebar/api/*` HTTP 路由 |
| **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换 | | **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换 |
| **id 冲突** | `registerTab` / `registerFileViewer` 对重复 id 抛错;建议用包前缀(`my-plugin:xxx`) | | **id 冲突** | `registerTab` / `registerFileViewer` 对重复 id 抛错;建议用包前缀(`my-plugin:xxx`) |
| **家族右面板互斥(v0.13.0+)** | 读取 `aionui-panel` 设置命名空间的 `rightPanel`:解析为 `'aionui-panel'` 时整个侧边栏不挂载(`settings.get` 路由返回 `externalDisable: true`,客户端挂载门 + 各类接管一并停用;`settings/document-updated` 推送实时生效,无 `remote` 服务的部署回退为启动时判定)。未安装 aionui 或提供方为其他值时不受影响 |
| **i18n 跟随** | 侧边栏界面文案跟随 DSH 的 `ctx.locale`(`@deepseek-ai/dsh-client-locale`):词典注册在 `betterSidebar` 命名空间,语言偏好(Host-backed `locale.preference`)与浏览器语言不一致时以 DSH 为准并实时切换;locale 服务缺失时回退浏览器语言。插件自身的 `t()`(`src/client/locales.ts`)由 `apply()` 挂接服务;消费插件**不要**依赖此内部函数——标题等字段传字符串或 `() => string` 即可(i18n 友好)。⚠️ 渲染 DSH 的 `MarkdownText` 时必须传 `codeLabels={{ copyLabel: t('copy'), copiedLabel: t('copied') }}`——该组件 cordis-free,漏传则代码块复制按钮回退硬编码中文 | | **i18n 跟随** | 侧边栏界面文案跟随 DSH 的 `ctx.locale`(`@deepseek-ai/dsh-client-locale`):词典注册在 `betterSidebar` 命名空间,语言偏好(Host-backed `locale.preference`)与浏览器语言不一致时以 DSH 为准并实时切换;locale 服务缺失时回退浏览器语言。插件自身的 `t()`(`src/client/locales.ts`)由 `apply()` 挂接服务;消费插件**不要**依赖此内部函数——标题等字段传字符串或 `() => string` 即可(i18n 友好)。⚠️ 渲染 DSH 的 `MarkdownText` 时必须传 `codeLabels={{ copyLabel: t('copy'), copiedLabel: t('copied') }}`——该组件 cordis-free,漏传则代码块复制按钮回退硬编码中文 |
| **懒加载 chunk** | 内置重依赖(xterm/CodeMirror)在独立 bundle(`lib/client-<name>.js`)中,经 `/sidebar/bundle` 路由按需下发;每个脚本把 factory 赋到插件自有全局注册表 `globalThis.__dshChunks__[<name>]`,由 `src/client/chunk-loader.ts` 用自定义 require(externals 经 `__DSH_MODULES__` seed 分支解析)物化——**不经过** `__ModuleLoader__` 注册;**核心 bundle 禁止静态 import `src/client/chunks/*`**(会把库拖回启动路径);对消费插件透明——懒加载只作用于内置 descriptor,`component` 契约(`(props) => ReactNode` 纯渲染函数)不变 | | **懒加载 chunk** | 内置重依赖(xterm/CodeMirror)在独立 bundle(`lib/client-<name>.js`)中,经 `/sidebar/bundle` 路由按需下发;每个脚本把 factory 赋到插件自有全局注册表 `globalThis.__dshChunks__[<name>]`,由 `src/client/chunk-loader.ts` 用自定义 require(externals 经 `__DSH_MODULES__` seed 分支解析)物化——**不经过** `__ModuleLoader__` 注册;**核心 bundle 禁止静态 import `src/client/chunks/*`**(会把库拖回启动路径);对消费插件透明——懒加载只作用于内置 descriptor,`component` 契约(`(props) => ReactNode` 纯渲染函数)不变 |
--- ---
## 8. 完整最小示例 ## 8. 皮肤兼容(令牌驱动)
> 侧边栏的皮肤兼容是**令牌驱动**的:所有视觉值都消费 DSH 的 `--dsw-alias-*` / `--dsw-font-*` / `--ds-*` 令牌(无硬编码颜色),皮肤系统覆盖什么令牌我们就跟随什么——**不做任何每皮肤适配**。当前已与 dsh-web-ui 皮肤中心兼容:其 10 款皮肤全部覆盖 `--dsw-alias-*` 层,换肤后面板自动跟随(测试:`tests/e2e/mount.e2e.ts` 断言布局变量随面板挂载生效;`tests/theme.spec.ts` 守护令牌读取)。
### 8.1 规则
- **面板表面**:右/底面板背景 = `var(--dsw-alias-bg-layer-1)`(通用卡片表面)。**绝不消费 `--dsw-specific-sidebar-fill`**——那是宿主左侧导航列专属令牌,皮肤系统按左导航语义覆盖它(dsh-web-ui 皮肤把它做成半透明玻璃或主题色,Aqua 设成 `transparent`),面板消费它会失去填充或与标签令牌冲突。皮肤要整体换面板表面:覆写 `--dsw-alias-bg-layer-1` 即可(dsh-web-ui 10 款皮肤都已覆盖,无需任何额外工作)。
- **终端/编辑器表面**:经 `effectiveTokenValue` 读取 `--dsw-alias-bg-base`——`transparent` 与 alpha < 0.9 的半透明玻璃值(dsh-web-ui 皮肤用 rgba 0.16–0.7)一律回退不透明底色,文字永不叠在皮肤背景画上滚动(issue #90);≥ 0.9 的近不透明值(如皮肤作用域内 0.96 的瓷器玻璃)放行,皮肤仍能控制终端表面。
- **根锚点**:宿主 div 带 `data-dsh-better-sidebar` 属性(append 到 `document.body`),面板是其 fixed 直接子级。皮肤若要做作用域覆盖(deep-whale 的做法),限定在 `[data-dsh-better-sidebar]` 内即可,避免全局改写影响宿主。
- **布局变量**(写在 `<html>` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`(面板几何;拖拽期间逐帧更新)。
- **z-index**:面板 40、折叠按钮簇 45(角手柄在面板内层叠,z-index 2 仅面板内有效)——全部低于 DSH 浮层栈(100/1000+),任何浮层天然盖住侧边栏。
### 8.2 注意事项
- 类名是 CSS Modules 哈希(`[hash]_[local]`),**不是契约**——皮肤不要依赖类名寻址;需要精确命中单表面时,用 `[data-dsh-better-sidebar]` 属性选择器配合子串类名(如 `[class*='panel']`)或 DOM 结构。
- 改动本契约(面板表面令牌、透明度阈值、z-index 层级)必须同步更新本文档、设计文档与 `tests/theme.spec.ts`。
---
## 9. 完整最小示例
> 假设插件 `my-plugin` 要加一个"Database 浏览器" tab + `.csv` 文件预览器。 > 假设插件 `my-plugin` 要加一个"Database 浏览器" tab + `.csv` 文件预览器。
@@ -636,16 +692,17 @@ function parseCsv(text: string): string[][] { /* ... */ }
--- ---
## 9. 参考实现 ## 10. 参考实现
better-sidebar 自己的内置 tab 和 viewer 就是参考实现("吃狗粮"): better-sidebar 自己的内置 tab 和 viewer 就是参考实现("吃狗粮"):
- **`src/client/builtins/`**:7 个内置 tab(explorer/git/subagent/terminal/browser/editor/diff)+ 6 个内置 viewer(image/pdf/markdown/html/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.ts;Office 预览已迁至推荐插件,见 plugins-viewers.ts) - **`src/client/builtins/`**:6 个内置 tab(editor/git/subagent/terminal/browser/diff)+ 6 个内置 viewer(image/pdf/markdown/html/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.ts;Office 预览已迁至推荐插件,见 plugins-viewers.ts)
- **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现 - **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现
- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套设置行:switch/text/number + 持久化) - **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套设置行:switch/text/number + 持久化)
- **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating 测试 - **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating 测试
- **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 6 viewer + 声明式元数据) - **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 6 viewer + 声明式元数据)
- **`src/client/plugins-tabs.ts`** / **`src/client/plugins-viewers.ts`**:推荐插件目录(名字/url/简介/安装脚本,分别对应 Tab 注册与文件预览注册),在设置页两个「添加插件」弹窗展示(共享类型在 `plugins-shared.ts`);插件作者可按扩展点加一条数据(弹窗内「跳转」直达仓库、「复制」把安装命令写入剪贴板,粘贴到 DSH 所在环境的终端执行)——数据完整性由 `tests/plugin-list.spec.ts` 守护 - **`src/client/plugins-tabs.ts`** / **`src/client/plugins-viewers.ts`**:推荐插件目录(名字/url/简介/安装脚本,分别对应 Tab 注册与文件预览注册),在设置页两个「添加插件」弹窗展示(共享类型在 `plugins-shared.ts`);插件作者可按扩展点加一条数据(弹窗内「跳转」直达仓库、「复制」把安装命令写入剪贴板,粘贴到 DSH 所在环境的终端执行)——数据完整性由 `tests/plugin-list.spec.ts` 守护
- **`src/client/FileTree.tsx`** / **`src/client/TreePanel.tsx`** / **`src/fs-search.ts`**:受控文件树组件(纯树体,文件行右键菜单含「在新 Tab 中打开」「在侧边打开」,仅宿主编排提供回调时渲染)/ 树面板(搜索框 + 刷新 + FileTree,文件窗口的内嵌 dock 使用)与 host 侧递归文件名搜索(`fs.search` 路由,预算兜底 + 跳过 `.git`/symlink 目录;测试 `tests/fs-search.spec.ts`、组件测试 `tests/editor-host.spec.tsx`)
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`** / **`docs/plans/2026-08-14-add-plugins-modal-design.md`**:设计文档(含实施偏差记录) - **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`** / **`docs/plans/2026-08-14-add-plugins-modal-design.md`**:设计文档(含实施偏差记录)
调试时直接读这些文件即可看到所有 API 的真实用法。 调试时直接读这些文件即可看到所有 API 的真实用法。
+64 -95
View File
@@ -21,7 +21,7 @@
## ✨ 功能一览 ## ✨ 功能一览
- **🗂️ 文件工作台**:资源管理器(懒加载目录树)+ CodeMirror 编辑器;图片 / Markdown / HTML / PDF / Office 内联预览 - **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown / HTML / PDF / Office 内联预览
- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整) - **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整)
- **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具 - **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原 - **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原
@@ -36,113 +36,81 @@
## 🆕 最近更新 ## 🆕 最近更新
<small>v0.12.2</small> <div align="center">
<a href="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0"><img width="45%" alt="服务化基座截图" src="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0" /></a>
<a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="45%" alt="添加插件截图" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a>
</div>
> 📝 **说明**:本版新增「位置兼容模式」——为 Windows 右上角原生标题栏预留顶部空间,侧边栏整体下移,距离可在齿轮弹窗自定义(0–120px)。随 0.12.0 起的服务化基座一同发布:能力探测、状态订阅、tab 角标、生命周期回调、外链认领(`urlTarget`)与插件自有设置均已就绪,第三方插件可深度接入;设置页新增「添加插件」推荐目录。详细条目见下表。 ### v0.13.0
| 功能 | 说明 | 截图 | **✨ 新功能**
|---|---|---|
| 📐 位置兼容模式 | 设置页新增「位置兼容模式」开关:为 Windows 右上角原生标题栏预留顶部空间,侧边栏按钮与内容整体下移(默认关闭);下移距离可在齿轮弹窗中自定义(0–120px) | | - 🔀 **与 dsh-web-ui 家族右侧面板互斥**:读取 `aionui-panel` 设置命名空间的提供方选择——当选择「使用 aionui-panel」时,整个 better-sidebar(右侧栏 / 底部面板 / 浮动入口 / 各类接管)不再挂载;选择 DSH-better-sidebar(或未安装 aionui)时正常。设置页保存后实时生效(settings-document 推送),无需刷新
| 🔌 服务化基座 | 完整类型导出 + `version`/`features` 能力探测、状态订阅(`getSnapshot`/`subscribeState`)、tab 角标、`onOpen`/`onActivate`/`onClose` 生命周期回调、`updateTab`/`activateTab`/`openFile`、定向打开、`meta` 跨刷新持久化、插件自有设置(`pluginToggles`/`render`)、外链点击目标认领(`urlTarget`) | <a href="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0"><img width="800" alt="服务化基座截图" src="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0" /></a> |
| ➕ 添加插件 | 设置页「推荐插件目录」+ 一键复制安装命令;内置 Office 预览迁至推荐插件 | <a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="800" alt="添加插件截图" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a> | ### v0.12.3
| 🖱️ 标签页滚轮 | 标签页栏支持鼠标滚轮横向滚动 | |
| 🐛 修复 | 远程访问 403(信任栅栏改用 `trustedHosts`)、侧边栏崩溃 [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31)、Windows 下 HTML 预览盘符路径 | | **✨ 新功能**
- 🎨 **皮肤兼容(令牌驱动)**:全面消费 DSH 设计令牌,与 dsh-web-ui 皮肤中心 10 款皮肤兼容,换肤自动跟随;终端/编辑器表面在透明/半透明玻璃值下回退不透明底色,文字不叠在皮肤背景上([#110](https://github.com/omdsh-dev/DSH-better-sidebar/pull/110),修复 #106 #105 #90 #60,附带 #52 #57 #92)
- 🗂️ **统一路径处理**:UNC 路径 / 软链接分类(目录软链接可展开、失效链接标红)、HTML 路由平台守卫([#134](https://github.com/omdsh-dev/DSH-better-sidebar/pull/134),#65 #67 #43 #79 #115)
- 🖥️ **终端 shell 可配置**:设置项自定义 shell,Windows 自动探测 pwsh([#95](https://github.com/omdsh-dev/DSH-better-sidebar/pull/95))
- 📝 **编辑器新增语言**:C# / Kotlin / Swift 语法高亮([#120](https://github.com/omdsh-dev/DSH-better-sidebar/pull/120))
- 🧭 **设置页导航图标**:设置页导航图标与布局优化([#114](https://github.com/omdsh-dev/DSH-better-sidebar/pull/114))
- ➕ **推荐插件目录新增**:`dsh-git-remotes`——Git 远程 Tab(分支/上游/ahead-behind、fetch 可 prune、ff-only pull、确认后才 push,不替换内置暂存/提交)([#91](https://github.com/omdsh-dev/DSH-better-sidebar/pull/91));`dsh-video-preview`——视频内联预览(.mp4/.webm/.mov/.mkv/.avi 等,自带 /video 宿主路由支持 HTTP Range 206 拖进度条,不受 20MB mediaLimit 限制)([#126](https://github.com/omdsh-dev/DSH-better-sidebar/pull/126))
**🐛 修复**
- 🔧 **xterm 依赖迁移**:弃用的 xterm 迁移至 `@xterm/xterm`(Closes [#122](https://github.com/omdsh-dev/DSH-better-sidebar/issues/122),[#128](https://github.com/omdsh-dev/DSH-better-sidebar/pull/128))
- 📝 **Markdown 编辑器**:选区转对话弹窗恢复可用([#24](https://github.com/omdsh-dev/DSH-better-sidebar/pull/24))
- 🐛 **node-pty 加载失败不再拖垮 server**([#140](https://github.com/omdsh-dev/DSH-better-sidebar/issues/140)):宿主半改为懒加载 node-pty,缺失时插件照常挂载,终端以修复提示横幅(可复制命令 + 重试按钮)呈现,agent 终端工具自动跳过
- 🧪 测试工程:单元测试拆分(#141)+ smoke 偶发失败修复
**🚀 工程**
- 接入 GitHub Release 自动发版 npm(Trusted Publishing,产物带 provenance),本版起打 tag 即自动发布([#148](https://github.com/omdsh-dev/DSH-better-sidebar/pull/148))
### v0.12.2
- 📐 **位置兼容模式**:设置页新增开关:为 Windows 右上角原生标题栏预留顶部空间,侧边栏按钮与内容整体下移(默认关闭);下移距离可在齿轮弹窗中自定义(0–120px)
- 🔌 **服务化基座**:完整类型导出 + `version`/`features` 能力探测、状态订阅(`getSnapshot`/`subscribeState`)、tab 角标、`onOpen`/`onActivate`/`onClose` 生命周期回调、`updateTab`/`activateTab`/`openFile`、定向打开、`meta` 跨刷新持久化、插件自有设置(`pluginToggles`/`render`)、外链点击目标认领(`urlTarget`)
- ➕ **添加插件**:设置页「推荐插件目录」+ 一键复制安装命令;内置 Office 预览迁至推荐插件
- 🖱️ **标签页滚轮**:标签页栏支持鼠标滚轮横向滚动
- 🐛 **修复**:远程访问 403(信任栅栏改用 `trustedHosts`)、侧边栏崩溃 [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31)、Windows 下 HTML 预览盘符路径
### v0.12.1
- 🔌 **服务化基座**:完整类型导出 + `version`/`features` 能力探测、状态订阅(`getSnapshot`/`subscribeState`)、tab 角标、`onOpen`/`onActivate`/`onClose` 生命周期回调、`updateTab`/`activateTab`/`openFile`、定向打开、`meta` 跨刷新持久化、插件自有设置(`pluginToggles`/`render`)
- ➕ **添加插件**:设置页「推荐插件目录」+ 一键复制安装命令;内置 Office 预览迁至推荐插件
- 🖱️ **标签页滚轮**:标签页栏支持鼠标滚轮横向滚动
- 🐛 **修复**:远程访问 403(信任栅栏改用 `trustedHosts`)、侧边栏崩溃 [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31)、Windows 下 HTML 预览盘符路径
> 📝 说明:0.12.0 正式版因 npm 判定版本已发布无法复用,正式发布改用 0.12.1,两者内容一致。
### v0.12.0
- 🔌 **服务化基座**:完整类型导出 + `version`/`features` 能力探测、状态订阅、tab 角标、生命周期回调、定向打开、`meta` 跨刷新持久化、插件自有设置
- ➕ **添加插件**:设置页「推荐插件目录」+ 一键复制安装命令;内置 Office 预览迁至推荐插件
- 🖱️ **标签页滚轮**:标签页栏支持鼠标滚轮横向滚动
- 🐛 **修复**:远程访问 403(信任栅栏改用 `trustedHosts`)、侧边栏崩溃 [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31)、Windows 下 HTML 预览盘符路径
## 🚀 安装 ## 🚀 安装
**前置**:已装好 DSH(`dsh web` 能正常运行),Node.js ≥ 20、pnpm ≥ 10。 **前置**:已装好 DSH(`dsh web` 能正常运行),Node.js ≥ 20、pnpm ≥ 10。
**macOS / Linux**(Windows 装了 Git Bash 或 WSL 也可):
```sh ```sh
curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash dsh plugin --profile web add dsh-better-sidebar@latest
```
**Windows(PowerShell 5.1+ / pwsh)**:
```powershell
irm https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1 | iex
``` ```
装完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可看到侧边栏(DSH 对 client 改动热加载,无需重启;仅 host 半更新时需要重启)。 装完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可看到侧边栏(DSH 对 client 改动热加载,无需重启;仅 host 半更新时需要重启)。
<details>
<summary><b>指定版本 / 装完自动重启(可选)</b></summary>
```sh
# macOS / Linux
curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash -s 0.12.2 --restart
# Windows PowerShell
& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.12.2 -Restart
```
不确定的话,可先加 `--dry-run`(PowerShell 用 `-DryRun`)预览步骤再执行。
</details>
<details>
<summary><b>手动安装(逐步命令,想看清每一步)</b></summary>
与一键脚本等价。**第 ③ 步可重复执行;①② 只需做一次。**
**macOS / Linux(bash)**:
```sh
cd ~/.dsh/profiles/web
# ① 放行 node-pty / protobufjs 的构建脚本(pnpm 11 默认拦截;pnpm 10 可跳过)
pnpm approve-builds --all
# ② 放行「发布不足 24h」的新版本(装老版本可跳过;若已有该键,把下面那行并入其下即可)
cat >> pnpm-workspace.yaml <<'EOF'
minimumReleaseAgeExclude:
- dsh-better-sidebar
EOF
# ③ 安装并自动挂载(不带 @版本 = npm 的 latest;固定版本写 dsh-better-sidebar@0.12.2)
npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar
```
**Windows(PowerShell)**:
```powershell
cd ~\.dsh\profiles\web
# ① 放行构建脚本
pnpm approve-builds --all
# ② 放行新版本(一次性;若已有该键,把 - dsh-better-sidebar 并入其下即可)
Add-Content -Path pnpm-workspace.yaml -Value "`nminimumReleaseAgeExclude:`n - dsh-better-sidebar"
# ③ 安装并自动挂载
npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar
```
</details>
<details>
<summary><b>脚本内部做了什么(技术细节)</b></summary>
一键脚本自动完成 4 件事(全部幂等,可安全重复执行):
1. 预写 `allowBuilds`(node-pty / protobufjs),规避 pnpm 11 的构建脚本拦截;
2. 预写 `minimumReleaseAgeExclude`,放行「发布不足 24 小时」的新版本;
3. 执行 `dsh plugin --profile web add dsh-better-sidebar`:登记依赖 → 识别包内 `dsh.bundle.patch` → 自动注册进 `dsh.profile.bundles` 挂载;
4. 清理旧版残留的手动挂载行,避免「双挂载」(页面出现两个侧边栏)。
`curl | bash` / `irm | iex` 会执行远程代码——脚本已随仓库开源(`scripts/install.sh` / `scripts/install.ps1`),可先下载审阅。插件以 npm 包 `dsh-better-sidebar@0.12.2` 发布,通过 `dsh.bundle.patch`(随包的 `cordis.patch.yml`)由官方 CLI 自动挂载,**不修改 DSH 源码**。
</details>
<details> <details>
<summary><b>更新</b></summary> <summary><b>更新</b></summary>
```sh ```sh
dsh plugin --profile web add dsh-better-sidebar dsh plugin --profile web add dsh-better-sidebar@latest
``` ```
或重跑一次一键脚本;也可把 `~/.dsh/profiles/web/package.json` 里的版本号改高后 `pnpm install`。改完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可(client 改动无需重启 DSH)。 也可把 `~/.dsh/profiles/web/package.json` 里的版本号改高后 `pnpm install`。改完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可(client 改动无需重启 DSH)。
</details> </details>
@@ -151,12 +119,13 @@ dsh plugin --profile web add dsh-better-sidebar
| 现象 | 原因与解决 | | 现象 | 原因与解决 |
|---|---| |---|---|
| 报 `Ignored build scripts` | pnpm 11 拦截构建脚本。跑 `pnpm approve-builds --all`(一键脚本已自动处理)。 | | 报 `Ignored build scripts` | pnpm 11 拦截构建脚本。在 profile 目录(`~/.dsh/profiles/web`)跑 `pnpm approve-builds --all`。 |
| 报 `minimum release age` / 版本不足 24h | 装的版本发布不足 24 小时。等 24h 或重跑一次(pnpm 会自动补 `minimumReleaseAgeExclude`);一键脚本已自动处理。 | | 报 `minimum release age` / 版本不足 24h | 装的版本发布不足 24 小时。等 24h 或重跑一次(pnpm 会自动补 `minimumReleaseAgeExclude`)。 |
| 报「找不到 profile 目录」 | 先跑一次 `dsh web`,让它初始化 `~/.dsh/profiles/web`。 | | 报「找不到 profile 目录」 | 先跑一次 `dsh web`,让它初始化 `~/.dsh/profiles/web`。 |
| 页面出现**两个侧边栏** | 双挂载:`~/.dsh/profiles/web/cordis.patch.yml` 还留着旧的手动挂载行,删掉那段 `- insert: ... better-sidebar ...`(一键脚本会自动清)。 | | 页面出现**两个侧边栏** | 双挂载:`~/.dsh/profiles/web/cordis.patch.yml` 还留着旧的手动挂载行,删掉那段 `- insert: ... better-sidebar ...`。 |
| Windows 下终端无法使用 | `node-pty` 依赖预编译二进制;若当前 Node 版本没有对应产物,需装编译工具链(VS Build Tools)。主流 Node 版本一般已有预编译。 | | Windows 下终端无法使用 | `node-pty` 依赖预编译二进制;若当前 Node 版本没有对应产物,需装编译工具链(VS Build Tools)。主流 Node 版本一般已有预编译。 |
| Windows 没有 bash / curl | 直接用 PowerShell 一键命令;或安装 Git Bash / WSL 再跑 bash 命令。 | | 终端提示「node-pty 加载失败」 | `node-pty` 安装缺失/损坏(如 pnpm 拦截了构建脚本)。终端横幅会给出修复命令:复制到 DSH 所在环境的终端/cmd 执行(在 `~/.dsh/profiles/web` 下 `pnpm approve-builds --all && pnpm rebuild node-pty`),完成后重启 DSH 并点重试。插件与 DSH 核心使用同一 `node-pty@^1.1.0`,修复后两者同步恢复。 |
| 提示 `dsh: command not found` | 先安装 DSH;或直接用 `npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar@latest`。 |
</details> </details>
@@ -177,7 +146,7 @@ dsh plugin --profile web add dsh-better-sidebar
5. 硬刷新浏览器(Cmd/Ctrl+Shift+R)即可看到效果(client 改动无需重启 DSH;host 半改动才需重启) 5. 硬刷新浏览器(Cmd/Ctrl+Shift+R)即可看到效果(client 改动无需重启 DSH;host 半改动才需重启)
``` ```
更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.12.2"` 再 `pnpm install`。 更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.13.0"` 再 `pnpm install`。
</details> </details>
+64 -95
View File
@@ -21,7 +21,7 @@
## ✨ Features ## ✨ Features
- **🗂️ File Workbench**: file explorer (lazy-loading tree) + CodeMirror editor; inline preview for images / Markdown / HTML / PDF / Office - **🗂️ File Workbench**: file explorer (lazy-loading tree; symlinks show their target kind — directory links expand, dangling links flagged) + CodeMirror editor; inline preview for images / Markdown / HTML / PDF / Office
- **🌐 Embedded Browser**: multiple web tabs with back / forward / refresh; content runs in a sandboxed iframe; external links are routed by protocol by default — HTTP opens in the sidebar, HTTPS goes to the system browser (both adjustable in settings) - **🌐 Embedded Browser**: multiple web tabs with back / forward / refresh; content runs in a sandboxed iframe; external links are routed by protocol by default — HTTP opens in the sidebar, HTTPS goes to the system browser (both adjustable in settings)
- **💻 Real Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model - **💻 Real Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model
- **🌿 Git Panel**: real diff + VSCode-style diff tabs, history, right-click to stage / commit / revert - **🌿 Git Panel**: real diff + VSCode-style diff tabs, history, right-click to stage / commit / revert
@@ -36,113 +36,81 @@
## 🆕 Recent Updates ## 🆕 Recent Updates
<small>v0.12.2</small> <div align="center">
<a href="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0"><img width="45%" alt="Service API base screenshot" src="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0" /></a>
<a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="45%" alt="Add Plugins screenshot" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a>
</div>
> 📝 **Note**: this release adds "Position compat mode" — reserves top space for the native Windows title bar (top-right), shifting the sidebar buttons and content below it (0–120px, customizable in the gear popup). It ships together with the service API base introduced since v0.12.0: capability detection, state subscription, tab badges, lifecycle callbacks, external-link claiming (`urlTarget`) and plugin-owned settings are all in place for deep third-party integration; settings gained the "Add Plugins" recommended catalog. See the table below for details. ### v0.13.0
| Feature | Description | Screenshot | **✨ New features**
|---|---|---|
| 📐 Position compat mode | New "Position compatibility mode" setting: reserves top space for the native Windows title bar (top-right) so the sidebar buttons and content sit below it (off by default); the shift distance is customizable in the gear popup (0–120px) | | - 🔀 **Mutual exclusion with the dsh-web-ui family right panel**: reads the `aionui-panel` settings namespace's provider choice — when "Use aionui-panel" is selected, the whole better-sidebar (right sidebar / bottom panel / floating entry / all takeovers) does not mount; with DSH-better-sidebar (or no aionui installed) it behaves as before. Takes effect live after a settings save (settings-document push), no reload needed
| 🔌 Service API base | Complete type exports + `version`/`features` capability detection, state subscription (`getSnapshot`/`subscribeState`), tab `badge`, `onOpen`/`onActivate`/`onClose` lifecycle callbacks, `updateTab`/`activateTab`/`openFile`, targeted open, `meta` persisted across reloads, plugin-owned settings (`pluginToggles`/`render`), external-link claim (`urlTarget`) | <a href="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0"><img width="480" alt="Service API base screenshot" src="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0" /></a> |
| ➕ Add Plugins | Recommended plugin catalog in settings + one-click copy install command; built-in Office preview moved to the recommended plugin | <a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="480" alt="Add Plugins screenshot" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a> | ### v0.12.3
| 🖱️ Tab-bar scroll | Mouse-wheel horizontal scrolling on the tab bar | |
| 🐛 Fixes | Remote access 403 (trust fence now uses `trustedHosts`), sidebar crash [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31), Windows HTML-preview drive-path | | **✨ New features**
- 🎨 **Skin compatibility (token-driven)**: fully consumes DSH design tokens and follows the dsh-web-ui skin center's 10 skins automatically; terminal/editor surfaces fall back to opaque backgrounds under transparent/translucent-glass token values so text never scrolls over the skin art ([#110](https://github.com/omdsh-dev/DSH-better-sidebar/pull/110), fixes #106 #105 #90 #60, also #52 #57 #92)
- 🗂️ **Unified path handling**: UNC / symlink classification (directory symlinks expandable, broken links highlighted) + HTML-route platform guards ([#134](https://github.com/omdsh-dev/DSH-better-sidebar/pull/134), #65 #67 #43 #79 #115)
- 🖥️ **Configurable terminal shell**: custom shell setting with Windows pwsh auto-probe ([#95](https://github.com/omdsh-dev/DSH-better-sidebar/pull/95))
- 📝 **Editor languages**: C# / Kotlin / Swift syntax highlighting ([#120](https://github.com/omdsh-dev/DSH-better-sidebar/pull/120))
- 🧭 **Settings nav icon**: settings-page navigation icon and layout polish ([#114](https://github.com/omdsh-dev/DSH-better-sidebar/pull/114))
- ➕ **Recommended-plugin catalog**: added `dsh-git-remotes` — Git Remotes tab (branches/upstream/ahead-behind, fetch with prune, ff-only pull, confirm-before-push; does not replace the built-in stage/commit tab) ([#91](https://github.com/omdsh-dev/DSH-better-sidebar/pull/91)); and `dsh-video-preview` — inline video preview (.mp4/.webm/.mov/.mkv/.avi etc.) backed by a /video host route with HTTP Range (206) scrubbing, not capped by the 20MB mediaLimit ([#126](https://github.com/omdsh-dev/DSH-better-sidebar/pull/126))
**🐛 Fixes**
- 🔧 **xterm migration**: deprecated xterm dependency migrated to `@xterm/xterm` (Closes [#122](https://github.com/omdsh-dev/DSH-better-sidebar/issues/122), [#128](https://github.com/omdsh-dev/DSH-better-sidebar/pull/128))
- 📝 **Markdown editor**: selection-to-conversation popup restored ([#24](https://github.com/omdsh-dev/DSH-better-sidebar/pull/24))
- 🐛 **node-pty load failure no longer crashes the server** ([#140](https://github.com/omdsh-dev/DSH-better-sidebar/issues/140)): the host half now lazy-loads node-pty — when it is missing the plugin still mounts, the terminal shows a repair banner (copyable command + Retry button), and agent terminal tools are skipped
- 🧪 Test engineering: unit spec split (#141) + flaky smoke cleanup fix
**🚀 Engineering**
- npm publishing wired to GitHub Releases (Trusted Publishing, provenance-attached tarballs); tagging a release publishes automatically ([#148](https://github.com/omdsh-dev/DSH-better-sidebar/pull/148))
### v0.12.2
- 📐 **Position compat mode**: new "Position compatibility mode" setting: reserves top space for the native Windows title bar (top-right) so the sidebar buttons and content sit below it (off by default); the shift distance is customizable in the gear popup (0–120px)
- 🔌 **Service API base**: complete type exports + `version`/`features` capability detection, state subscription (`getSnapshot`/`subscribeState`), tab `badge`, `onOpen`/`onActivate`/`onClose` lifecycle callbacks, `updateTab`/`activateTab`/`openFile`, targeted open, `meta` persisted across reloads, plugin-owned settings (`pluginToggles`/`render`), external-link claim (`urlTarget`)
- ➕ **Add Plugins**: recommended plugin catalog in settings + one-click copy install command; built-in Office preview moved to the recommended plugin
- 🖱️ **Tab-bar scroll**: mouse-wheel horizontal scrolling on the tab bar
- 🐛 **Fixes**: remote access 403 (trust fence now uses `trustedHosts`), sidebar crash [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31), Windows HTML-preview drive-path
### v0.12.1
- 🔌 **Service API base**: complete type exports + `version`/`features` capability detection, state subscription (`getSnapshot`/`subscribeState`), tab `badge`, `onOpen`/`onActivate`/`onClose` lifecycle callbacks, `updateTab`/`activateTab`/`openFile`, targeted open, `meta` persisted across reloads, plugin-owned settings (`pluginToggles`/`render`)
- ➕ **Add Plugins**: recommended plugin catalog in settings + one-click copy install command; built-in Office preview moved to the recommended plugin
- 🖱️ **Tab-bar scroll**: mouse-wheel horizontal scrolling on the tab bar
- 🐛 **Fixes**: remote access 403 (trust fence now uses `trustedHosts`), sidebar crash [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31), Windows HTML-preview drive-path
> 📝 Note: the 0.12.0 final could not be reused (npm reported the version as already published), so the public release became 0.12.1 — both carry identical content.
### v0.12.0
- 🔌 **Service API base**: complete type exports + `version`/`features` capability detection, state subscription, tab badges, lifecycle callbacks, targeted open, `meta` persisted across reloads, plugin-owned settings
- ➕ **Add Plugins**: recommended plugin catalog in settings + one-click copy install command; built-in Office preview moved to the recommended plugin
- 🖱️ **Tab-bar scroll**: mouse-wheel horizontal scrolling on the tab bar
- 🐛 **Fixes**: remote access 403 (trust fence now uses `trustedHosts`), sidebar crash [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31), Windows HTML-preview drive-path
## 🚀 Installation ## 🚀 Installation
**Prerequisites**: DSH installed (`dsh web` boots), Node.js ≥ 20, pnpm ≥ 10. **Prerequisites**: DSH installed (`dsh web` boots), Node.js ≥ 20, pnpm ≥ 10.
**macOS / Linux** (also works in Git Bash / WSL on Windows):
```sh ```sh
curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash dsh plugin --profile web add dsh-better-sidebar@latest
```
**Windows (PowerShell 5.1+ / pwsh)**:
```powershell
irm https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1 | iex
``` ```
Then **hard-refresh the browser** (Cmd/Ctrl+Shift+R) to see the sidebar (DSH hot-reloads client changes; only host-half updates need a restart). Then **hard-refresh the browser** (Cmd/Ctrl+Shift+R) to see the sidebar (DSH hot-reloads client changes; only host-half updates need a restart).
<details>
<summary><b>Pin a version / auto-restart (optional)</b></summary>
```sh
# macOS / Linux
curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash -s 0.12.2 --restart
# Windows PowerShell
& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.12.2 -Restart
```
Not sure? Add `--dry-run` (`-DryRun` in PowerShell) to preview before running.
</details>
<details>
<summary><b>Manual install (step by step)</b></summary>
Equivalent to the one-click script. **Step ③ is repeatable; ①② only need to run once.**
**macOS / Linux (bash)**:
```sh
cd ~/.dsh/profiles/web
# ① Allow node-pty / protobufjs build scripts (pnpm 11 blocks them by default; skip on pnpm 10)
pnpm approve-builds --all
# ② Allow versions published less than 24h ago (skip for older releases; if the key already exists, merge the line under it instead)
cat >> pnpm-workspace.yaml <<'EOF'
minimumReleaseAgeExclude:
- dsh-better-sidebar
EOF
# ③ Install and auto-mount (no @version = npm's latest; pin with dsh-better-sidebar@0.12.2)
npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar
```
**Windows (PowerShell)**:
```powershell
cd ~\.dsh\profiles\web
# ① Allow build scripts
pnpm approve-builds --all
# ② Allow fresh releases (once; if the key already exists, merge - dsh-better-sidebar under it instead)
Add-Content -Path pnpm-workspace.yaml -Value "`nminimumReleaseAgeExclude:`n - dsh-better-sidebar"
# ③ Install and auto-mount
npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar
```
</details>
<details>
<summary><b>What the script does (technical details)</b></summary>
The one-click script does four things, all idempotent (safe to re-run):
1. Pre-writes `allowBuilds` (node-pty / protobufjs) to dodge pnpm 11's build-script block;
2. Pre-writes `minimumReleaseAgeExclude` to allow versions younger than 24 hours;
3. Runs `dsh plugin --profile web add dsh-better-sidebar`: registers the dependency → detects `dsh.bundle.patch` → auto-appends the plugin to `dsh.profile.bundles`;
4. Removes any leftover hand-written mount line to avoid double-mounting (two sidebars on the page).
`curl | bash` / `irm | iex` executes remote code — the scripts are open source in the repo (`scripts/install.sh` / `scripts/install.ps1`); download and review them first if you prefer. The plugin ships as npm package `dsh-better-sidebar@0.12.2` and mounts via `dsh.bundle.patch` (the shipped `cordis.patch.yml`), so the DSH source is never modified.
</details>
<details> <details>
<summary><b>Updating</b></summary> <summary><b>Updating</b></summary>
```sh ```sh
dsh plugin --profile web add dsh-better-sidebar dsh plugin --profile web add dsh-better-sidebar@latest
``` ```
or re-run the one-click script; or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.12.2"`) and run `pnpm install`. Then hard-refresh the browser (Cmd/Ctrl+Shift+R) — client changes do not need a DSH restart. or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.13.0"`) and run `pnpm install`. Then hard-refresh the browser (Cmd/Ctrl+Shift+R) — client changes do not need a DSH restart.
</details> </details>
@@ -151,12 +119,13 @@ or re-run the one-click script; or bump the version in `~/.dsh/profiles/web/pack
| Symptom | Cause & fix | | Symptom | Cause & fix |
|---|---| |---|---|
| `Ignored build scripts` | pnpm 11 blocked build scripts. Run `pnpm approve-builds --all` (the one-click script handles it). | | `Ignored build scripts` | pnpm 11 blocked build scripts. Run `pnpm approve-builds --all` in the profile directory (`~/.dsh/profiles/web`). |
| `minimum release age` / version `< 24h` | The release is younger than 24 hours. Wait, or re-run once (pnpm auto-adds `minimumReleaseAgeExclude`); the one-click script handles it. | | `minimum release age` / version `< 24h` | The release is younger than 24 hours. Wait, or re-run once (pnpm auto-adds `minimumReleaseAgeExclude`). |
| "profile directory not found" | Run `dsh web` once so it initializes `~/.dsh/profiles/web`. | | "profile directory not found" | Run `dsh web` once so it initializes `~/.dsh/profiles/web`. |
| Two sidebars on the page | Double-mount: `~/.dsh/profiles/web/cordis.patch.yml` still has the old hand-written `- insert: ... better-sidebar ...` line — delete it (the one-click script cleans it). | | Two sidebars on the page | Double-mount: `~/.dsh/profiles/web/cordis.patch.yml` still has the old hand-written `- insert: ... better-sidebar ...` line — delete it. |
| Terminal fails on Windows | `node-pty` relies on prebuilt binaries; if none match your Node version, install a build toolchain (VS Build Tools). Mainstream Node versions are usually covered. | | Terminal fails on Windows | `node-pty` relies on prebuilt binaries; if none match your Node version, install a build toolchain (VS Build Tools). Mainstream Node versions are usually covered. |
| No bash / curl on Windows | Use the PowerShell one-click command, or install Git Bash / WSL and run the bash commands. | | Terminal shows "node-pty failed to load" | The `node-pty` install is missing or broken (e.g. pnpm skipped its build script). The terminal banner shows a repair command — copy it into a terminal/cmd on the DSH machine and run it (in `~/.dsh/profiles/web`: `pnpm approve-builds --all && pnpm rebuild node-pty`), then restart DSH and click Retry. The plugin and DSH core share the same `node-pty@^1.1.0`, so the repair restores both. |
| `dsh: command not found` | Install DSH first, or run `npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar@latest`. |
</details> </details>
@@ -177,7 +146,7 @@ To debug local changes or track the dev branch, point the dependency at a local
5. Restart DSH and hard-refresh 5. Restart DSH and hard-refresh
``` ```
Update: `git pull && pnpm install && pnpm build` → just hard-refresh the browser (client changes hot-reload; only host-half changes need a DSH restart). To switch back to the npm channel, restore `"dsh-better-sidebar": "^0.12.2"` and re-run `pnpm install`. Update: `git pull && pnpm install && pnpm build` → just hard-refresh the browser (client changes hot-reload; only host-half changes need a DSH restart). To switch back to the npm channel, restore `"dsh-better-sidebar": "^0.13.0"` and re-run `pnpm install`.
</details> </details>
+1 -1
View File
@@ -480,7 +480,7 @@ const { value } = await res.json() // 错误时 { ok: false, error: { code, me
| 方法 | 说明 | | 方法 | 说明 |
|---|---| |---|---|
| `session.cwd` | 会话权威 cwd(`{ cwd, root, parent }`) | | `session.cwd` | 会话权威 cwd(`{ cwd, root, parent }`) |
| `fs.tree` | 目录列表(`{ path, entries: FsEntry[], truncated }`) | | `fs.tree` | 目录列表(`{ path, entries: FsEntry[], truncated }`;FsEntry 含 `isSymlink`/`broken`,目录软链接的 `isDir` 按目标类型) |
| `fs.read` | 读文件:文本返回 `{ kind: 'text', content, truncated }`;二进制返回 `{ kind: 'binary', size, truncated, head }`(head = base64 前 4KB) | | `fs.read` | 读文件:文本返回 `{ kind: 'text', content, truncated }`;二进制返回 `{ kind: 'binary', size, truncated, head }`(head = base64 前 4KB) |
| `fs.write` | 原子写文件 | | `fs.write` | 原子写文件 |
| `git.status` / `git.diff` / `git.log` 等 | 全套 Git 只读 + 写操作 | | `git.status` / `git.diff` / `git.log` 等 | 全套 Git 只读 + 写操作 |
@@ -260,7 +260,9 @@ if (officeKind === 'docx' || officeKind === 'xlsx') {
- `xlsx`(SheetJS,~400KB,xlsx 导入) - `xlsx`(SheetJS,~400KB,xlsx 导入)
- `@univerjs/core` + `@univerjs/sheets` + `@univerjs/sheets-ui` + `@univerjs/sheets-formula` + `@univerjs/sheets-conditional-formatting` + `@univerjs/sheets-chart` + `@univerjs/docs-ui` + `@univerjs/design` + `@univerjs/engine-render`(~5MB 合计) - `@univerjs/core` + `@univerjs/sheets` + `@univerjs/sheets-ui` + `@univerjs/sheets-formula` + `@univerjs/sheets-conditional-formatting` + `@univerjs/sheets-chart` + `@univerjs/docs-ui` + `@univerjs/design` + `@univerjs/engine-render`(~5MB 合计)
**不放 `peerDependencies`**:这些是插件自有运行时依赖,不由 web profile 提供(对比现有 `node-pty`/`xterm`/`CodeMirror` 也在 `dependencies`)。 **不放 `peerDependencies`**:这些是插件自有运行时依赖,不由 web profile 提供(对比现有 `node-pty`/`CodeMirror` 也在 `dependencies`)。
> **实施偏差记录(#122,2026-08)**:上文"`xterm` 也在 `dependencies`"已过时——`xterm` 与 `@xterm/addon-fit` 自 v0.13.0 起从 `dependencies` 迁至 `devDependencies`:二者始终内联进 `lib/client-terminal.js`(tsdown `noExternal` 全内联 + CSS 构建期解析),运行时零依赖;同时代码与开发依赖已从已弃用的 `xterm` 迁移到官方新包名 `@xterm/xterm@^5.5.0`(addon-fit@0.10.0 的 peer 约束锁 `^5.0.0`,不用 6.x),消除用户安装时的 deprecated 与 missing-peer 警告。Office 预览若落地,其依赖按同一原则处理。
### 5.2 tsdown.config.ts 调整 ### 5.2 tsdown.config.ts 调整
@@ -110,6 +110,7 @@ URL 采用**路径编码**(无 query)——WHATSG URL 解析对纯路径相
- 设计初期考虑过 srcdoc + `<base>` 注入以实现"预览反映未保存草稿",因 srcdoc 无沙箱时继承父源的致命风险与 base 注入的脆弱性,改采 route-src(预览 = 已保存文件);相对资源经路径编码 URL 天然解析,无需内容改写。 - 设计初期考虑过 srcdoc + `<base>` 注入以实现"预览反映未保存草稿",因 srcdoc 无沙箱时继承父源的致命风险与 base 注入的脆弱性,改采 route-src(预览 = 已保存文件);相对资源经路径编码 URL 天然解析,无需内容改写。
- `normalizeBrowserUrl` 的 scheme 判别:朴素正则会把 `example.com:8080` 误判为 scheme,最终采用「`://` 完整 URL + 危险 scheme 黑名单 + 解析后协议兜底」三段式。 - `normalizeBrowserUrl` 的 scheme 判别:朴素正则会把 `example.com:8080` 误判为 scheme,最终采用「`://` 完整 URL + 危险 scheme 黑名单 + 解析后协议兜底」三段式。
- `nextBrowser` 在 sanitize 中为宽松字段(缺失/畸形回退 1),避免旧持久化布局整体失效(与严格校验的 `nextTerminal` 不同)。 - `nextBrowser` 在 sanitize 中为宽松字段(缺失/畸形回退 1),避免旧持久化布局整体失效(与严格校验的 `nextTerminal` 不同)。
- **UNC 路径编码标记(PR #134 整合 #79/#115 时)**:`/sidebar/html` 对 `\\server\share\...` / `//server/share/...` 采用 sessionId 后的 `//` 标记,且解码统一重建为**前斜杠** `//server/share/...` 而非反斜杠——`node:path` 在 win32/POSIX 上分别解析为 `\\server\share\...` / `/server/share/...`,因此无需任何平台信号;原「400 双斜杠」约束改为「首个空段仅作 UNC 标记,其余空段仍 400」。
## 8.1 追加需求(2026-08-11,v0.5.0 内) ## 8.1 追加需求(2026-08-11,v0.5.0 内)
@@ -46,6 +46,8 @@
- 插件侧不可消除:不改 DSH 源码、不 patch node-pty 依赖是仓库硬约束(见 AGENTS.md §0),且 `IPty.kill()` 是 node-pty 的唯一 kill 通道; - 插件侧不可消除:不改 DSH 源码、不 patch node-pty 依赖是仓库硬约束(见 AGENTS.md §0),且 `IPty.kill()` 是 node-pty 的唯一 kill 通道;
- 建议:保持现状(无功能影响),上游如提供无控制台规避选项再跟进;reporter 若在意日志噪音,可反馈 microsoft/node-pty。 - 建议:保持现状(无功能影响),上游如提供无控制台规避选项再跟进;reporter 若在意日志噪音,可反馈 microsoft/node-pty。
> 交叉引用(issue #140):与本文档「仅刷日志」不同,**依赖缺失/损坏**(node-pty 安装不完整导致模块加载失败)已有插件侧降级处理——宿主半懒加载 node-pty,失败时终端 tab 显示可复制的修复命令(`scripts/install.sh --repair` / `install.ps1 -Repair`),详见 `src/pty-deps.ts` 与 `tests/pty-deps.spec.ts`。
## 4. 验证 ## 4. 验证
- 全量测试:`pnpm test` → 34 文件 / 430 例全部通过(基线 425 + 同期 PR #43 的 html 盘符修复 1 例 + 本次新增 4 例); - 全量测试:`pnpm test` → 34 文件 / 430 例全部通过(基线 425 + 同期 PR #43 的 html 盘符修复 1 例 + 本次新增 4 例);
@@ -0,0 +1,67 @@
# 皮肤兼容(令牌驱动):面板表面、终端透明度回退与独立 CSS 修复
**日期**:2026-08-15
**状态**:已实施(本文档含实施偏差记录)
**目标版本**:随发布 bump(本 PR 不改版本号、不更新 README——发布流程统一处理)
## 1. 目标
让 better-sidebar 与 **dsh-web-ui 的皮肤中心**兼容:用户切换皮肤时侧边栏自动跟随换肤,零每皮肤适配(KISS:不做 aionui 式"每款皮肤 remap 一套令牌"的重活)。附带修复同批 CSS 问题([#52](https://github.com/omdsh-dev/DSH-better-sidebar/issues/52) 弹出框遮挡、[#57](https://github.com/omdsh-dev/DSH-better-sidebar/issues/57) 前半图标尺寸、[#92](https://github.com/omdsh-dev/DSH-better-sidebar/issues/92) 拖拽回归防护),并覆盖 [#60](https://github.com/omdsh-dev/DSH-better-sidebar/issues/60)/[#105](https://github.com/omdsh-dev/DSH-better-sidebar/issues/105)/[#90](https://github.com/omdsh-dev/DSH-better-sidebar/issues/90) 的相关诉求。
## 2. 调研结论
dsh-web-ui(`zhu1090093659/dsh-web-ui`)的皮肤机制:
- 皮肤 = cordis 插件(`ui-skin-<id>`),皮肤中心通过 `cordis.patch.yml` 切换激活;皮肤插件 client half 在 `document.body` 上打 `data-dsh-<skin>` 属性并注入 CSS。
- **10 款皮肤全部覆盖两层令牌**:`--dsw-static-*` 基础色板 + `--dsw-alias-*` 语义层(`--dsw-alias-bg-layer-1`、`--dsw-alias-bg-base` 等做成半透明玻璃 rgba 0.16–0.7,`--dsw-specific-sidebar-fill` 也全部覆盖)。
- dsh-web-ui 自家右侧面板(aionui)用独立 `--aion-*` 令牌,每款皮肤单独 remap——**重**。
结论:better-sidebar 只要**消费 DSH 标准令牌**(alias 层),换肤就自动跟随,不需要任何每皮肤适配。此前围绕第三方皮肤插件(Aqua/deep-whale)设计的类名改名与 `data-bs-*` 语义钩子按 KISS 原则**全部回退**(见 §6)。
## 3. 设计
### 3.1 面板表面(`sidebar.module.css`)
```css
.panel { background: var(--dsw-alias-bg-layer-1); }
.bottomPanel { background: var(--dsw-alias-bg-layer-1); }
```
- 直接消费通用卡片表面令牌,**不新增自有令牌、不加属性钩子**。
- **绝不消费 `--dsw-specific-sidebar-fill`**:那是宿主左侧导航列专属令牌,皮肤系统按左导航语义覆盖它(dsh-web-ui 半透明玻璃/主题色、Aqua `transparent`),面板消费它会失去填充或与标签令牌冲突。
- stock 外观色阶变化仅 ±8 RGB(bluish-50→75 / 900→875),视觉无差;dsh-web-ui 换肤后面板获得与宿主一致的玻璃效果。
### 3.2 终端/编辑器透明度回退(`theme.ts` / `TerminalView.tsx`)
`effectiveTokenValue` 现在把以下值视为"未设置"(触发调用方 `||` 回退):
- 空串与 CSS 重置关键字(`transparent`/`initial`/`inherit`/`unset`);
- **alpha < 0.9 的半透明颜色**(dsh-web-ui 皮肤的 `--dsw-alias-bg-base` 是 rgba 0.16–0.7 玻璃)。
新增 `colorAlpha()` 解析 computed 颜色(rgb()/rgba()/hsl()/hsla() 逗号与空格语法、`#rgb/#rgba/#rrggbb/#rrggbbaa`);无法解析的格式(命名色、`color()`)视为不透明放行。阈值 0.9:≥ 0.9 的近不透明值(如皮肤作用域内 0.96 瓷器玻璃)放行,皮肤仍能控制终端表面。
### 3.3 独立修复(与皮肤兼容无关,保留)
- **z-index(#52)**:`.toggleCluster` 55→45、`.panel`/`.bottomPanel` 50→40;角手柄移入面板后为面板内局部 `z-index: 2`。依据:DSH 浮层栈为 100(Menu/HoverCard/Tooltip/PopupSelect/Modal=1000),30–99 无任何 DSH 元素。
- **角手柄几何 CSS 化**:移入右面板 DOM,`position: absolute; left: -6px; bottom: calc(var(--dsh-sidebar-height, 0px) + 6px)`——复用拖拽逐帧写入的布局变量,删除 `cornerRef` JS 内联坐标。
- **刷新图标统一 `size={14}`(#57 前半)**:Explorer/Git/Diff 的 `IconRefreshOutline16` 显式 14px,与 Subagent/Browser 一致。
- **拖拽逐帧回归 e2e(#92)**:`tests/e2e/drag-layout.e2e.ts` rAF 采样,断言拖拽期间 `data-dsh-sidebar-dragging` 存在、`#root` transition 为 none、会话列与面板边单调 1:1 跟随(实测通过)。拖条以 `cursor: col-resize` 语义定位(无属性钩子可依赖)。
## 4. 测试
- `tests/theme.spec.ts`:`colorAlpha`(hex/rgb/hsl/不可解析)与 `effectiveTokenValue`(transparent/关键词/半透明回退/0.96 放行/缺失)。
- `tests/e2e/mount.e2e.ts`:追加 `--dsh-sidebar-width` 布局变量随面板挂载生效的断言(令牌驱动契约的挂载级守护)。
- `tests/e2e/drag-layout.e2e.ts`:拖拽逐帧回归。
- 门禁:`pnpm typecheck && pnpm test && pnpm build && pnpm pack && pnpm test:mount`。
## 5. 文档
- AGENTS.md §8「皮肤兼容(令牌驱动)」:面板表面令牌、透明度阈值、z-index 层级、根锚点 `[data-dsh-better-sidebar]`、类名非契约。
- README 不在本批更新(发布时随版本条目更新)。
## 6. 实施偏差记录
- **首版契约(已回退)**:初版围绕第三方皮肤插件设计了 `--dsh-sidebar-surface`/`--dsh-resize-strip-offset` 自有令牌(review 后已删)、panel 家族类名 kebab-case 改名(breaking)与 `data-bs-*` 语义钩子。确认主目标是 dsh-web-ui 皮肤中心后,按 KISS 全部回退:令牌驱动下这些都不需要,改名还引入无谓 breaking。最终契约 = 消费标准令牌 + 透明度阈值,无自有概念。
- **角手柄 `z-index` 语义变化**:移入面板后 z-index 为面板内层叠(2,与拖条一致),对外绝对层级由面板(40)决定;两面板同开时手柄盒与底面板上沿无重叠(≥6px 间隙),行为不变。
- **拖拽禁用断言修正**:`transition: none` 的 computed 值为 `transition-property: none`(非 `all`),已按实测修正。
- **面板默认表面色阶微调**:±8 RGB,未做像素级补偿(e2e 不依赖具体颜色)。
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"id": "dsh-external/dsh-better-sidebar", "id": "dsh-external/dsh-better-sidebar",
"version": "0.12.2", "version": "0.13.0",
"main": "./lib/index.js", "main": "./lib/index.js",
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器", "description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
"engines": { "engines": {
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "dsh-better-sidebar", "name": "dsh-better-sidebar",
"version": "0.12.2", "version": "0.13.0",
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.", "description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.",
"type": "module", "type": "module",
"repository": { "repository": {
@@ -129,13 +129,11 @@
"@codemirror/state": "^6.7.1", "@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.8", "@codemirror/view": "^6.43.8",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@xterm/addon-fit": "^0.10.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"node-pty": "^1.1.0", "node-pty": "^1.1.0",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"schemastery": "^3.18.0", "schemastery": "^3.18.0",
"ws": "^8.18.0", "ws": "^8.18.0"
"xterm": "^5.3.0"
}, },
"devDependencies": { "devDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5", "@cordisjs/plugin-loader": "^1.0.0-rc.5",
@@ -159,6 +157,8 @@
"@types/react": "~18.3.1", "@types/react": "~18.3.1",
"@types/react-dom": "~18.3.1", "@types/react-dom": "~18.3.1",
"@types/ws": "^8.5.10", "@types/ws": "^8.5.10",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"cordis": "^4.0.0-rc.7", "cordis": "^4.0.0-rc.7",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
+12 -22
View File
@@ -74,9 +74,6 @@ importers:
'@lezer/highlight': '@lezer/highlight':
specifier: ^1.2.3 specifier: ^1.2.3
version: 1.2.3 version: 1.2.3
'@xterm/addon-fit':
specifier: ^0.10.0
version: 0.10.0(@xterm/xterm@5.4.0)
clsx: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
@@ -92,9 +89,6 @@ importers:
ws: ws:
specifier: ^8.18.0 specifier: ^8.18.0
version: 8.21.3 version: 8.21.3
xterm:
specifier: ^5.3.0
version: 5.3.0
devDependencies: devDependencies:
'@cordisjs/plugin-loader': '@cordisjs/plugin-loader':
specifier: ^1.0.0-rc.5 specifier: ^1.0.0-rc.5
@@ -159,6 +153,12 @@ importers:
'@types/ws': '@types/ws':
specifier: ^8.5.10 specifier: ^8.5.10
version: 8.18.1 version: 8.18.1
'@xterm/addon-fit':
specifier: ^0.11.0
version: 0.11.0
'@xterm/xterm':
specifier: ^5.5.0
version: 5.5.0
cordis: cordis:
specifier: ^4.0.0-rc.7 specifier: ^4.0.0-rc.7
version: 4.0.0-rc.8(@cordisjs/plugin-loader@1.0.0-rc.5) version: 4.0.0-rc.8(@cordisjs/plugin-loader@1.0.0-rc.5)
@@ -1186,13 +1186,11 @@ packages:
'@vitest/utils@4.1.10': '@vitest/utils@4.1.10':
resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==}
'@xterm/addon-fit@0.10.0': '@xterm/addon-fit@0.11.0':
resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==} resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
peerDependencies:
'@xterm/xterm': ^5.0.0
'@xterm/xterm@5.4.0': '@xterm/xterm@5.5.0':
resolution: {integrity: sha512-GlyzcZZ7LJjhFevthHtikhiDIl8lnTSgol6eTM4aoSNLcuXu3OEhnbqdCVIjtIil3jjabf3gDtb1S8FGahsuEw==} resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==}
'@yuku-codegen/binding-android-arm64@0.8.5': '@yuku-codegen/binding-android-arm64@0.8.5':
resolution: {integrity: sha512-+oEBxK35kdbskhxozjZhI8N6qrMOfphpIkXnqBj6N2DC08rdkg10MUMRNKZLBNU6uNyoYiLcLyCE6aFV2sfMKw==} resolution: {integrity: sha512-+oEBxK35kdbskhxozjZhI8N6qrMOfphpIkXnqBj6N2DC08rdkg10MUMRNKZLBNU6uNyoYiLcLyCE6aFV2sfMKw==}
@@ -2166,10 +2164,6 @@ packages:
xmlchars@2.2.0: xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
xterm@5.3.0:
resolution: {integrity: sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==}
deprecated: This package is now deprecated. Move to @xterm/xterm instead.
yuku-ast@0.8.5: yuku-ast@0.8.5:
resolution: {integrity: sha512-Ez2CI2BnPK/if0tVI7jB9UUDSNibcChjJDEPEKuWPJNRkbvzSoAQrSqpKtdzl8qTPp4ftVb87f/jx5HIKOieQw==} resolution: {integrity: sha512-Ez2CI2BnPK/if0tVI7jB9UUDSNibcChjJDEPEKuWPJNRkbvzSoAQrSqpKtdzl8qTPp4ftVb87f/jx5HIKOieQw==}
@@ -3390,11 +3384,9 @@ snapshots:
convert-source-map: 2.0.0 convert-source-map: 2.0.0
tinyrainbow: 3.1.1 tinyrainbow: 3.1.1
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.4.0)': '@xterm/addon-fit@0.11.0': {}
dependencies:
'@xterm/xterm': 5.4.0
'@xterm/xterm@5.4.0': {} '@xterm/xterm@5.5.0': {}
'@yuku-codegen/binding-android-arm64@0.8.5': '@yuku-codegen/binding-android-arm64@0.8.5':
optional: true optional: true
@@ -4385,8 +4377,6 @@ snapshots:
xmlchars@2.2.0: {} xmlchars@2.2.0: {}
xterm@5.3.0: {}
yuku-ast@0.8.5: yuku-ast@0.8.5:
dependencies: dependencies:
'@yuku-toolchain/types': 0.8.5 '@yuku-toolchain/types': 0.8.5
+87 -49
View File
@@ -1,4 +1,4 @@
# ============================================================================= ## =============================================================================
# dsh-better-sidebar 一键安装脚本(官方 CLI 方式,Windows PowerShell 5.1+ / pwsh) # dsh-better-sidebar 一键安装脚本(官方 CLI 方式,Windows PowerShell 5.1+ / pwsh)
# #
# 通过 DSH 官方插件命令安装 npm 包并自动挂载: # 通过 DSH 官方插件命令安装 npm 包并自动挂载:
@@ -16,9 +16,15 @@
# & ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.10.2 -Restart # & ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.10.2 -Restart
# # 本地保存后运行 # # 本地保存后运行
# powershell -ExecutionPolicy Bypass -File install.ps1 -Version 0.10.2 -DryRun # powershell -ExecutionPolicy Bypass -File install.ps1 -Version 0.10.2 -DryRun
# # 修复 node-pty 依赖(终端提示「node-pty 加载失败」时用,见 issue #140)
# powershell -ExecutionPolicy Bypass -File install.ps1 -Repair
# #
# 参数: # 参数:
# -Version npm 版本号/范围,缺省 latest(自动解析为最新)。 # -Version npm 版本号/范围,缺省 latest(自动解析为最新)。
# -Repair 修复模式:不重装插件,只确保 profile 的 pnpm-workspace.yaml
# 放行 node-pty 构建脚本,然后重跑 pnpm install + pnpm rebuild
# node-pty。
# -Profile 目标 profile 名(缺省 web);安装与修复模式均适用。
# -Restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅提示)。会断开当前页面会话。 # -Restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅提示)。会断开当前页面会话。
# -DryRun 只打印将要执行的操作,不写任何文件。 # -DryRun 只打印将要执行的操作,不写任何文件。
# #
@@ -39,7 +45,9 @@
param( param(
[string]$Version = '', [string]$Version = '',
[switch]$Restart, [switch]$Restart,
[switch]$DryRun [switch]$DryRun,
[switch]$Repair,
[string]$Profile = 'web'
) )
$PKG = 'dsh-better-sidebar' $PKG = 'dsh-better-sidebar'
@@ -53,7 +61,7 @@ if ($env:DSH_HOME) {
} else { } else {
$DSH_HOME = Join-Path $HOME '.dsh' $DSH_HOME = Join-Path $HOME '.dsh'
} }
$PROFILE_DIR = Join-Path $DSH_HOME 'profiles\web' $PROFILE_DIR = Join-Path $DSH_HOME "profiles\$Profile"
$WS_YML = Join-Path $PROFILE_DIR 'pnpm-workspace.yaml' $WS_YML = Join-Path $PROFILE_DIR 'pnpm-workspace.yaml'
$PATCH_YML = Join-Path $PROFILE_DIR 'cordis.patch.yml' $PATCH_YML = Join-Path $PROFILE_DIR 'cordis.patch.yml'
@@ -88,35 +96,10 @@ function Get-DshCli {
return $null return $null
} }
# 前置校验 # 步骤 1(安装与修复共用):预写 workspace 设置(幂等),保证 pnpm 不拦截
if (-not (Get-Command node -ErrorAction SilentlyContinue)) { # node-pty/protobufjs 构建脚本、放行本插件新版本
Die '未找到 node(DSH 运行需要 Node.js >= 20),请先安装 Node.js 并加入 PATH。' function Ensure-WorkspaceSettings {
} $wsScript = @'
if (-not (Test-Path $PROFILE_DIR)) {
Die "找不到 profile 目录:$PROFILE_DIR(请先安装并运行过一次 dsh web)"
}
if (-not (Test-Path $WS_YML)) {
Die "找不到 $WS_YML(请先初始化 web profile)"
}
$SPEC = Resolve-Spec $Version
$CLI = Get-DshCli
if (-not $CLI) {
Die '未找到 dsh 或 npx。请先安装 DSH(并确保 Node/npm 可用),或用 DSH_CMD 指定。'
}
Say "目标:$CLI plugin --profile web add $PKG@$SPEC(profile: $PROFILE_DIR)"
if ($DryRun) {
Say "[dry-run] 步骤 1:确保 $WS_YML 含 allowBuilds(node-pty/protobufjs: true)与 minimumReleaseAgeExclude($PKG)"
Say "[dry-run] 步骤 2:执行 $CLI plugin --profile web add $PKG@$SPEC(安装 + bundle 自动注册)"
Say "[dry-run] 步骤 3:校验 dsh.profile.bundles 含 $PKG"
Say "[dry-run] 步骤 4:幂等移除 $PATCH_YML 里旧的 better-sidebar 手动挂载行(避免双挂载)"
if ($Restart) { Say '[dry-run] 步骤 5:pm2 restart dsh-web' } else { Say '[dry-run] 步骤 5:提示用户手动重启 DSH' }
exit 0
}
# 步骤 1:预写 workspace 设置(幂等),保证 pnpm 不拦截构建、放行本插件新版本
$wsScript = @'
const fs = require("fs"); const fs = require("fs");
const p = process.argv[2]; const p = process.argv[2];
let t = fs.readFileSync(p, "utf8"); let t = fs.readFileSync(p, "utf8");
@@ -141,29 +124,84 @@ if (!/^\s*-\s+dsh-better-sidebar\s*$/m.test(t)) {
if (t !== before) fs.writeFileSync(p, t); if (t !== before) fs.writeFileSync(p, t);
console.log(t === before ? "unchanged" : "updated"); console.log(t === before ? "unchanged" : "updated");
'@ '@
# PowerShell 5.1 把含内嵌双引号的多行 JS 作为参数传给 `node -e` 时,引号会被 # PowerShell 5.1 把含内嵌双引号的多行 JS 作为参数传给 `node -e` 时,引号会被
# Windows 命令行解析吞掉,导致 JS 语法错误(Expected ',', got ':')。 # Windows 命令行解析吞掉,导致 JS 语法错误(Expected ',', got ':')。
# 改用临时文件方式,兼容 PS 5.1 与 pwsh 7。 # 改用临时文件方式,兼容 PS 5.1 与 pwsh 7。
$wsJs = Join-Path $env:TEMP ("dshbs-ws-" + [guid]::NewGuid().ToString("N") + ".js") $wsJs = Join-Path $env:TEMP ("dshbs-ws-" + [guid]::NewGuid().ToString("N") + ".js")
Set-Content -LiteralPath $wsJs -Value $wsScript -Encoding UTF8 Set-Content -LiteralPath $wsJs -Value $wsScript -Encoding UTF8
$wsOut = node $wsJs "$WS_YML" 2>&1 $wsOut = node $wsJs "$WS_YML" 2>&1
$wsCode = $LASTEXITCODE $wsCode = $LASTEXITCODE
Remove-Item -LiteralPath $wsJs -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $wsJs -Force -ErrorAction SilentlyContinue
$wsResult = (($wsOut | Out-String)).Trim() $wsResult = (($wsOut | Out-String)).Trim()
if ($wsCode -ne 0) { Die "处理 $WS_YML 失败(node 退出码 $wsCode):$wsResult" } if ($wsCode -ne 0) { Die "处理 $WS_YML 失败(node 退出码 $wsCode):$wsResult" }
if ($wsResult -eq 'updated') { if ($wsResult -eq 'updated') {
Say "已确保 $WS_YML:allowBuilds(node-pty/protobufjs: true)+ minimumReleaseAgeExclude($PKG)" Say "已确保 $WS_YML:allowBuilds(node-pty/protobufjs: true)+ minimumReleaseAgeExclude($PKG)"
} else { } else {
Say 'workspace 设置已就绪,跳过' Say 'workspace 设置已就绪,跳过'
}
} }
# 前置校验
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
Die '未找到 node(DSH 运行需要 Node.js >= 20),请先安装 Node.js 并加入 PATH。'
}
if (-not (Test-Path $PROFILE_DIR)) {
Die "找不到 profile 目录:$PROFILE_DIR(请先安装并运行过一次 dsh web)"
}
if (-not (Test-Path $WS_YML)) {
Die "找不到 $WS_YML(请先初始化 $Profile profile)"
}
# ── 修复模式(issue #140):不重装插件,只修复 node-pty 依赖 ────────────
# 终端提示「node-pty 加载失败」时运行:确保 allowBuilds 后重跑
# pnpm install + pnpm rebuild node-pty(重放被 pnpm 11 拦截的构建脚本)。
if ($Repair) {
if ($DryRun) {
Say "[dry-run] 修复:确保 $WS_YML 含 allowBuilds(node-pty: true)"
Say "[dry-run] 修复:cd $PROFILE_DIR; pnpm install; pnpm rebuild node-pty"
exit 0
}
Say "修复模式:重装 node-pty(profile: $Profile,$PROFILE_DIR)..."
Ensure-WorkspaceSettings
Push-Location $PROFILE_DIR
try {
pnpm install
if ($LASTEXITCODE -ne 0) { Die "pnpm install 失败(退出码 $LASTEXITCODE)。请确认 pnpm 在 PATH 上、网络可用,然后重试。" }
pnpm rebuild node-pty
if ($LASTEXITCODE -ne 0) { Die "pnpm rebuild node-pty 失败(退出码 $LASTEXITCODE)。请确认 pnpm 在 PATH 上,然后重试。" }
} finally {
Pop-Location
}
Say '修复完成:node-pty 已重装(与 DSH 核心保持同一版本)。请重启 DSH 后重试终端。'
exit 0
}
$SPEC = Resolve-Spec $Version
$CLI = Get-DshCli
if (-not $CLI) {
Die '未找到 dsh 或 npx。请先安装 DSH(并确保 Node/npm 可用),或用 DSH_CMD 指定。'
}
Say "目标:$CLI plugin --profile $Profile add $PKG@$SPEC(profile: $PROFILE_DIR)"
if ($DryRun) {
Say "[dry-run] 步骤 1:确保 $WS_YML 含 allowBuilds(node-pty/protobufjs: true)与 minimumReleaseAgeExclude($PKG)"
Say "[dry-run] 步骤 2:执行 $CLI plugin --profile $Profile add $PKG@$SPEC(安装 + bundle 自动注册)"
Say "[dry-run] 步骤 3:校验 dsh.profile.bundles 含 $PKG"
Say "[dry-run] 步骤 4:幂等移除 $PATCH_YML 里旧的 better-sidebar 手动挂载行(避免双挂载)"
if ($Restart) { Say '[dry-run] 步骤 5:pm2 restart dsh-web' } else { Say '[dry-run] 步骤 5:提示用户手动重启 DSH' }
exit 0
}
# 步骤 1:预写 workspace 设置(幂等),保证 pnpm 不拦截构建、放行本插件新版本
Ensure-WorkspaceSettings
# 步骤 2:官方 CLI 安装 + bundle 自动注册(含挂载) # 步骤 2:官方 CLI 安装 + bundle 自动注册(含挂载)
if ($CLI -eq 'dsh') { if ($CLI -eq 'dsh') {
$cliArgs = @('plugin', '--profile', 'web', 'add', "$PKG@$SPEC") $cliArgs = @('plugin', '--profile', $Profile, 'add', "$PKG@$SPEC")
} else { } else {
$cliArgs = @('-y', '--package', '@deepseek-ai/dsh', 'dsh', 'plugin', '--profile', 'web', 'add', "$PKG@$SPEC") $cliArgs = @('-y', '--package', '@deepseek-ai/dsh', 'dsh', 'plugin', '--profile', $Profile, 'add', "$PKG@$SPEC")
} }
Say "执行 $CLI plugin --profile web add $PKG@$SPEC ..." Say "执行 $CLI plugin --profile $Profile add $PKG@$SPEC ..."
$addOut = & $CLI @cliArgs 2>&1 $addOut = & $CLI @cliArgs 2>&1
$addCode = $LASTEXITCODE $addCode = $LASTEXITCODE
$addOut | ForEach-Object { $_ } $addOut | ForEach-Object { $_ }
+85 -44
View File
@@ -12,9 +12,14 @@
# #
# 用法: # 用法:
# bash scripts/install.sh [版本] [--restart] [--dry-run] # bash scripts/install.sh [版本] [--restart] [--dry-run]
# bash scripts/install.sh --repair [--profile <名>] [--dry-run]
# #
# 版本 npm 版本号/范围,缺省为 latest(自动解析为 ^<最新>)。 # 版本 npm 版本号/范围,缺省为 latest(自动解析为 ^<最新>)。
# 示例:0.10.2、^0.10.2、~0.10.2、latest # 示例:0.10.2、^0.10.2、~0.10.2、latest
# --repair 修复模式:不重装插件,只确保 profile 的 pnpm-workspace.yaml
# 放行 node-pty 构建脚本,然后重跑 pnpm install + pnpm rebuild
# node-pty(终端提示「node-pty 加载失败」时用它,见 issue #140)。
# --profile 目标 profile 名(缺省 web);安装与修复模式均适用。
# --restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅打印提示)。 # --restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅打印提示)。
# 注意:重启会断开当前 DSH 页面会话,默认不自动重启。 # 注意:重启会断开当前 DSH 页面会话,默认不自动重启。
# --dry-run 只打印将要执行的操作,不写任何文件。 # --dry-run 只打印将要执行的操作,不写任何文件。
@@ -42,11 +47,15 @@ set -euo pipefail
for arg in "$@"; do for arg in "$@"; do
if [ "$arg" = "-h" ] || [ "$arg" = "--help" ]; then if [ "$arg" = "-h" ] || [ "$arg" = "--help" ]; then
cat <<'EOF' cat <<'EOF'
dsh-better-sidebar 一键安装脚本 dsh-better-sidebar 一键安装 / 依赖修复脚本
用法:bash scripts/install.sh [版本] [--restart] [--dry-run] 用法:
bash scripts/install.sh [版本] [--restart] [--dry-run] [--profile <名>]
bash scripts/install.sh --repair [--profile <名>] [--dry-run]
版本 npm 版本号/范围,缺省 latest(自动解析为最新)。示例:0.10.2、^0.10.2、latest 版本 npm 版本号/范围,缺省 latest(自动解析为最新)。示例:0.10.2、^0.10.2、latest
--repair 修复模式:确保 profile 放行 node-pty 构建脚本并重装 node-pty(终端提示依赖加载失败时用)
--profile 目标 profile 名(缺省 web)
--restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅提示) --restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅提示)
--dry-run 只打印将要执行的操作,不写任何文件 --dry-run 只打印将要执行的操作,不写任何文件
@@ -57,30 +66,73 @@ EOF
done done
DSH_HOME="${DSH_HOME:-${HOME:-${USERPROFILE:-}}/.dsh}" DSH_HOME="${DSH_HOME:-${HOME:-${USERPROFILE:-}}/.dsh}"
PROFILE_DIR="$DSH_HOME/profiles/web"
WS_YML="$PROFILE_DIR/pnpm-workspace.yaml"
PATCH_YML="$PROFILE_DIR/cordis.patch.yml"
REGISTRY="${REGISTRY:-https://registry.npmjs.org}" REGISTRY="${REGISTRY:-https://registry.npmjs.org}"
PKG="dsh-better-sidebar" PKG="dsh-better-sidebar"
DSH_CMD="${DSH_CMD:-dsh}" DSH_CMD="${DSH_CMD:-dsh}"
RESTART=false RESTART=false
DRY_RUN=false DRY_RUN=false
REPAIR=false
VERSION_SPEC="" VERSION_SPEC=""
for arg in "$@"; do PROFILE_NAME="web"
case "$arg" in while [ $# -gt 0 ]; do
case "$1" in
--restart) RESTART=true ;; --restart) RESTART=true ;;
--dry-run) DRY_RUN=true ;; --dry-run) DRY_RUN=true ;;
--repair) REPAIR=true ;;
--profile)
if [ $# -lt 2 ]; then echo "--profile 需要一个 profile 名(如 web)" >&2; exit 2; fi
PROFILE_NAME="$2"; shift ;;
-h|--help) : ;; # 已在上面处理 -h|--help) : ;; # 已在上面处理
-*) echo "未知参数: ${arg}(用 -h 查看用法)" >&2; exit 2 ;; -*) echo "未知参数: ${1}(用 -h 查看用法)" >&2; exit 2 ;;
*) VERSION_SPEC="$arg" ;; *) VERSION_SPEC="$1" ;;
esac esac
shift
done done
PROFILE_DIR="$DSH_HOME/profiles/$PROFILE_NAME"
WS_YML="$PROFILE_DIR/pnpm-workspace.yaml"
PATCH_YML="$PROFILE_DIR/cordis.patch.yml"
say() { printf '\033[32m[install]\033[0m %s\n' "$*"; } say() { printf '\033[32m[install]\033[0m %s\n' "$*"; }
warn() { printf '\033[33m[warn]\033[0m %s\n' "$*" >&2; } warn() { printf '\033[33m[warn]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[31m[error]\033[0m %s\n' "$*" >&2; exit 1; } die() { printf '\033[31m[error]\033[0m %s\n' "$*" >&2; exit 1; }
# 步骤 1(安装与修复共用):预写 workspace 设置(幂等),保证 pnpm 不拦截
# node-pty/protobufjs 构建脚本、放行本插件新版本
ensure_workspace_settings() {
WS_RESULT="$(node -e '
const fs = require("fs");
const p = process.argv[1];
let t = fs.readFileSync(p, "utf8");
const before = t;
// allowBuilds:把 node-pty/protobufjs 归位为 true
t = t.replace(/^(\s*)(node-pty|protobufjs):.*$/gm, "$1$2: true");
if (!/^\s*allowBuilds:\s*$/m.test(t)) {
t += "\nallowBuilds:\n node-pty: true\n protobufjs: true\n";
} else {
for (const k of ["node-pty", "protobufjs"]) {
if (!new RegExp("^\\s*" + k + ":\\s*true\\s*$", "m").test(t)) {
t = t.replace(/^(\s*allowBuilds:\s*)$/m, "$1\n " + k + ": true");
}
}
}
// minimumReleaseAgeExclude:放行本插件(版本无关),避免 <24h 新版本被拒
if (!/^\s*-\s+dsh-better-sidebar\s*$/m.test(t)) {
if (/^\s*minimumReleaseAgeExclude:\s*$/m.test(t)) {
t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - dsh-better-sidebar");
} else {
t += "\nminimumReleaseAgeExclude:\n - dsh-better-sidebar\n";
}
}
if (t !== before) fs.writeFileSync(p, t);
console.log(t === before ? "unchanged" : "updated");
' "$WS_YML")"
[ "$WS_RESULT" = "updated" ] \
&& say "已确保 ${WS_YML}:allowBuilds(node-pty/protobufjs: true)+ minimumReleaseAgeExclude(${PKG})" \
|| say "workspace 设置已就绪,跳过"
}
# 解析用户给的版本 -> CLI 要用的 npm spec("x.y.z" / "^x.y.z" / latest) # 解析用户给的版本 -> CLI 要用的 npm spec("x.y.z" / "^x.y.z" / latest)
resolve_spec() { resolve_spec() {
local given="${1:-latest}" local given="${1:-latest}"
@@ -120,15 +172,33 @@ dsh_cli() {
command -v node >/dev/null 2>&1 || die "未找到 node(DSH 运行需要 Node.js ≥ 20),请先安装 Node.js 并加入 PATH。" command -v node >/dev/null 2>&1 || die "未找到 node(DSH 运行需要 Node.js ≥ 20),请先安装 Node.js 并加入 PATH。"
[ -d "$PROFILE_DIR" ] || die "找不到 profile 目录:${PROFILE_DIR}(请先安装并运行过一次 dsh web)" [ -d "$PROFILE_DIR" ] || die "找不到 profile 目录:${PROFILE_DIR}(请先安装并运行过一次 dsh web)"
[ -f "$WS_YML" ] || die "找不到 ${WS_YML}(请先初始化 web profile)" [ -f "$WS_YML" ] || die "找不到 ${WS_YML}(请先初始化 ${PROFILE_NAME} profile)"
# ── 修复模式(issue #140):不重装插件,只修复 node-pty 依赖 ────────────
# 终端提示「node-pty 加载失败」时运行:确保 allowBuilds 后重跑
# pnpm install + pnpm rebuild node-pty(重放被 pnpm 11 拦截的构建脚本)。
if [ "$REPAIR" = true ]; then
if [ "$DRY_RUN" = true ]; then
say "[dry-run] 修复:确保 $WS_YML 含 allowBuilds(node-pty: true)"
say "[dry-run] 修复:cd $PROFILE_DIR && pnpm install && pnpm rebuild node-pty"
exit 0
fi
say "修复模式:重装 node-pty(profile: ${PROFILE_NAME},${PROFILE_DIR})..."
ensure_workspace_settings
if ! (cd "$PROFILE_DIR" && pnpm install && pnpm rebuild node-pty); then
die "修复失败:pnpm install / pnpm rebuild node-pty 非零退出。请确认 pnpm 在 PATH 上、网络可用,然后重试。"
fi
say "修复完成:node-pty 已重装(与 DSH 核心保持同一版本)。请重启 DSH 后重试终端。"
exit 0
fi
SPEC="$(resolve_spec "$VERSION_SPEC")" SPEC="$(resolve_spec "$VERSION_SPEC")"
CLI="$(dsh_cli)" CLI="$(dsh_cli)"
say "目标:$CLI plugin --profile web add $PKG@${SPEC}(profile: ${PROFILE_DIR})" say "目标:$CLI plugin --profile $PROFILE_NAME add $PKG@${SPEC}(profile: ${PROFILE_DIR})"
if [ "$DRY_RUN" = true ]; then if [ "$DRY_RUN" = true ]; then
say "[dry-run] 步骤 1:确保 $WS_YML 含 allowBuilds(node-pty/protobufjs: true)与 minimumReleaseAgeExclude(${PKG})" say "[dry-run] 步骤 1:确保 $WS_YML 含 allowBuilds(node-pty/protobufjs: true)与 minimumReleaseAgeExclude(${PKG})"
say "[dry-run] 步骤 2:执行 $CLI plugin --profile web add $PKG@${SPEC}(安装 + bundle 自动注册)" say "[dry-run] 步骤 2:执行 $CLI plugin --profile $PROFILE_NAME add $PKG@${SPEC}(安装 + bundle 自动注册)"
say "[dry-run] 步骤 3:校验 dsh.profile.bundles 含 $PKG" say "[dry-run] 步骤 3:校验 dsh.profile.bundles 含 $PKG"
say "[dry-run] 步骤 4:幂等移除 $PATCH_YML 里旧的 better-sidebar 手动挂载行(避免双挂载)" say "[dry-run] 步骤 4:幂等移除 $PATCH_YML 里旧的 better-sidebar 手动挂载行(避免双挂载)"
if [ "$RESTART" = true ]; then say "[dry-run] 步骤 5:pm2 restart dsh-web"; else say "[dry-run] 步骤 5:提示用户手动重启 DSH"; fi if [ "$RESTART" = true ]; then say "[dry-run] 步骤 5:pm2 restart dsh-web"; else say "[dry-run] 步骤 5:提示用户手动重启 DSH"; fi
@@ -136,40 +206,11 @@ if [ "$DRY_RUN" = true ]; then
fi fi
# 步骤 1:预写 workspace 设置(幂等),保证 pnpm 不拦截构建、不放行旧版本策略 # 步骤 1:预写 workspace 设置(幂等),保证 pnpm 不拦截构建、不放行旧版本策略
WS_RESULT="$(node -e ' ensure_workspace_settings
const fs = require("fs");
const p = process.argv[1];
let t = fs.readFileSync(p, "utf8");
const before = t;
// allowBuilds:把 node-pty/protobufjs 归位为 true
t = t.replace(/^(\s*)(node-pty|protobufjs):.*$/gm, "$1$2: true");
if (!/^\s*allowBuilds:\s*$/m.test(t)) {
t += "\nallowBuilds:\n node-pty: true\n protobufjs: true\n";
} else {
for (const k of ["node-pty", "protobufjs"]) {
if (!new RegExp("^\\s*" + k + ":\\s*true\\s*$", "m").test(t)) {
t = t.replace(/^(\s*allowBuilds:\s*)$/m, "$1\n " + k + ": true");
}
}
}
// minimumReleaseAgeExclude:放行本插件(版本无关),避免 <24h 新版本被拒
if (!/^\s*-\s+dsh-better-sidebar\s*$/m.test(t)) {
if (/^\s*minimumReleaseAgeExclude:\s*$/m.test(t)) {
t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - dsh-better-sidebar");
} else {
t += "\nminimumReleaseAgeExclude:\n - dsh-better-sidebar\n";
}
}
if (t !== before) fs.writeFileSync(p, t);
console.log(t === before ? "unchanged" : "updated");
' "$WS_YML")"
[ "$WS_RESULT" = "updated" ] \
&& say "已确保 ${WS_YML}:allowBuilds(node-pty/protobufjs: true)+ minimumReleaseAgeExclude(${PKG})" \
|| say "workspace 设置已就绪,跳过"
# 步骤 2:官方 CLI 安装 + bundle 自动注册(含挂载) # 步骤 2:官方 CLI 安装 + bundle 自动注册(含挂载)
say "执行 $CLI plugin --profile web add $PKG@$SPEC ..." say "执行 $CLI plugin --profile $PROFILE_NAME add $PKG@$SPEC ..."
if ! $CLI plugin --profile web add "$PKG@$SPEC" 2>&1 | tail -n +1; then if ! $CLI plugin --profile "$PROFILE_NAME" add "$PKG@$SPEC" 2>&1 | tail -n +1; then
warn "dsh plugin add 失败。已预写 allowBuilds 与 minimumReleaseAgeExclude,仍失败的可能原因:" warn "dsh plugin add 失败。已预写 allowBuilds 与 minimumReleaseAgeExclude,仍失败的可能原因:"
warn " - 网络/登录问题:npm registry 不可达或需要登录。" warn " - 网络/登录问题:npm registry 不可达或需要登录。"
warn " - 依赖安装冲突:可手动重试 cd $PROFILE_DIR && pnpm install。" warn " - 依赖安装冲突:可手动重试 cd $PROFILE_DIR && pnpm install。"
+9 -4
View File
@@ -12,8 +12,9 @@
* Both registries share the same shell resolver and spawn-helper fix. * Both registries share the same shell resolver and spawn-helper fix.
*/ */
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
import * as nodePty from 'node-pty' import type { IPty } from 'node-pty'
import { ensureSpawnHelper, shellSpawnArgs } from './pty-manager.ts' import { ensureSpawnHelper, shellSpawnArgs } from './pty-manager.ts'
import { loadRequiredNodePty, type NodePtyModule } from './pty-deps.ts'
import { SidebarError } from './wire.ts' import { SidebarError } from './wire.ts'
/** Per-agent-terminal transcript bound (bytes kept for replay and reads). */ /** Per-agent-terminal transcript bound (bytes kept for replay and reads). */
@@ -106,7 +107,7 @@ export interface AgentTerminalHandle {
/** The working directory the process was spawned with. */ /** The working directory the process was spawned with. */
cwd: string cwd: string
/** The live pty process. */ /** The live pty process. */
pty: nodePty.IPty pty: IPty
/** Output accumulated since spawn (bounded; head dropped when over the limit). */ /** Output accumulated since spawn (bounded; head dropped when over the limit). */
transcript: string transcript: string
/** Whether the top-level process exited (transcript stays replayable). */ /** Whether the top-level process exited (transcript stays replayable). */
@@ -189,7 +190,11 @@ export class AgentPtyRegistry {
private readonly sessions = new Map<string, AgentTerminalHandle>() private readonly sessions = new Map<string, AgentTerminalHandle>()
private readonly changeListeners = new Set<() => void>() private readonly changeListeners = new Set<() => void>()
constructor(private readonly shell: string) { constructor(
private readonly shell: string,
/** The loaded node-pty module (injected so a broken install degrades instead of crashing the plugin). */
private readonly nodePty: NodePtyModule = loadRequiredNodePty(),
) {
ensureSpawnHelper() ensureSpawnHelper()
} }
@@ -211,7 +216,7 @@ export class AgentPtyRegistry {
): string { ): string {
const uuid = randomUUID() const uuid = randomUUID()
const dims = clampDims(cols, rows) const dims = clampDims(cols, rows)
const pty = nodePty.spawn(this.shell, shellSpawnArgs(), { const pty = this.nodePty.spawn(this.shell, shellSpawnArgs(), {
name: 'xterm-256color', name: 'xterm-256color',
cols: dims.cols, cols: dims.cols,
rows: dims.rows, rows: dims.rows,
+1 -1
View File
@@ -90,7 +90,7 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
title={t('refresh')} title={t('refresh')}
onClick={refresh} onClick={refresh}
> >
<IconRefreshOutline16 /> <IconRefreshOutline16 size={14} />
</button> </button>
</div> </div>
{loading && <div className={css.gitPlaceholder}>{t('loading')}</div>} {loading && <div className={css.gitPlaceholder}>{t('loading')}</div>}
+341 -23
View File
@@ -1,22 +1,43 @@
/** /**
* The editor tab host: resolves a file's previewer through the sidebar * The editor tab host: the single FILES WINDOW. It resolves a file's
* registry (`matchFileViewer`), fetches bytes per the matched viewer's * previewer through the sidebar registry (`matchFileViewer`), fetches bytes
* fetch strategy, and renders its component — or the shared download pane * per the matched viewer's fetch strategy, and renders its component — or
* when nothing can render the file. The header shows the file title; the * the shared download pane when nothing can render the file. A tab without
* editable code/markdown viewers render their own toolbar below it. * a path (the seeded "Files" home) renders an empty-state hint instead of
* the viewer loading flow; that path-less window IS the file explorer.
*
* The chrome depends on the `editorExplorer` mode (read reactively so
* toggling it re-renders without a reload):
* - merged (in-place): tree click / path-input Enter switch the CURRENT
* tab in place (updateTab rewrites path/title; the tab keeps its id and
* meta, so treeOpen/treeWidth survive the switch);
* - split: they open through `openSidebarFile` (a per-path dedupe tab),
* and a PATH-LESS window is the standalone explorer — it renders ONLY
* the tree panel (search + FileTree, full-window), no editor chrome.
* Editor tabs (with a path) keep the full chrome in both modes.
* The tree's context menu offers the explicit escapes in both modes: open
* in a new tab (per-path dedupe) or to the side (a fresh tab in a fresh
* rightward split of the current pane).
* *
* The strategy dispatch is pure (planFirstMatch / planFsReadOutcome in * The strategy dispatch is pure (planFirstMatch / planFsReadOutcome in
* editor-load.ts); this component only wires it to the host APIs. * editor-load.ts); this component only wires it to the host APIs.
*/ */
import { useEffect, useState } from 'react' import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
import { createElement } from 'react' import { createElement } from 'react'
import clsx from 'clsx'
import { IconCheckOutline16, IconFolderOpen16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../context-types.ts' import type { Context } from '../context-types.ts'
import { api, mediaUrl, type SessionScope } from './api.ts' import { api, mediaUrl, type SessionScope } from './api.ts'
import { BinaryDownload } from './binary-download.tsx' import { BinaryDownload } from './binary-download.tsx'
import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './editor-load.ts' import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './editor-load.ts'
import { baseName } from './FileTree.tsx'
import { openSidebarFile } from './intercept.tsx'
import { TreePanel } from './TreePanel.tsx'
import { t } from './locales.ts' import { t } from './locales.ts'
import type { FileViewerDescriptor } from './service.ts' import { relativeTo } from './paths.ts'
import type { SidebarStore } from './state.ts' import { resolveSidebarPath } from './produced-files.ts'
import type { EditorToolbarControls, EditorToolbarState, FileViewerDescriptor } from './service.ts'
import { firstLeaf, insertLeafAt, leafWithTab, mintTabId, treeOf, type SidebarStore, type SidebarTab } from './state.ts'
import css from './sidebar.module.css' import css from './sidebar.module.css'
type EditorLoad = type EditorLoad =
@@ -25,11 +46,156 @@ type EditorLoad =
| { status: 'ready'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown } | { status: 'ready'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown }
| { status: 'binary' } | { status: 'binary' }
export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: SessionScope; path: string; title: string }) { /** The docked tree panel's width bounds (drag-resize clamps into them). */
const { ctx, store, scope, path, title } = props const TREE_WIDTH_DEFAULT = 240
const TREE_WIDTH_MIN = 160
const TREE_WIDTH_MAX = 480
/** The tab's persisted meta object (a malformed meta reads as empty). */
function metaOf(tab: SidebarTab): Record<string, unknown> {
return tab.meta !== null && typeof tab.meta === 'object' && !Array.isArray(tab.meta)
? tab.meta as Record<string, unknown>
: {}
}
/** Read the persisted tree-panel flag of one editor tab: an explicit
* boolean meta wins; otherwise path-less tabs (the seeded home) default
* open and file tabs default closed. */
function treeOpenOf(tab: SidebarTab): boolean {
const treeOpen = metaOf(tab).treeOpen
return typeof treeOpen === 'boolean' ? treeOpen : (tab.path === undefined || tab.path === '')
}
/** Read the persisted tree-panel width (clamped; default 240). */
function treeWidthOf(tab: SidebarTab): number {
const width = metaOf(tab).treeWidth
return typeof width === 'number' && Number.isFinite(width)
? Math.min(TREE_WIDTH_MAX, Math.max(TREE_WIDTH_MIN, Math.round(width)))
: TREE_WIDTH_DEFAULT
}
/** Merge a patch into the tab's persisted meta (rides the layout). */
function patchMeta(ctx: Context, tab: SidebarTab, patch: Record<string, unknown>): void {
ctx.betterSidebar?.updateTab(tab.id, { meta: { ...metaOf(tab), ...patch } })
}
/** Clamp one dock width into the contract range. */
function clampTreeWidth(value: number): number {
return Math.min(TREE_WIDTH_MAX, Math.max(TREE_WIDTH_MIN, Math.round(value)))
}
export function EditorHost(props: {
ctx: Context
store: SidebarStore
scope: SessionScope
tab: SidebarTab
expanded: string[]
onToggleDir: (path: string) => void
onReferenceFile: (path: string) => void
}) {
const { ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile } = props
const path = tab.path ?? ''
const title = tab.title
const [load, setLoad] = useState<EditorLoad>({ status: 'loading' }) const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
// Reactive prefs read: flipping editorExplorer re-renders this tab with no
// reload. The snapshot is the bare boolean so unrelated store churn never
// re-renders the editor.
const inPlace = useSyncExternalStore(
useCallback((callback: () => void) => store.subscribe(callback), [store]),
useCallback(() => store.getSnapshot().prefs.editorExplorer, [store]),
)
// A path-less tab shows the empty-state hint in merged mode — and in split
// mode it is the standalone explorer (tree-only, see the render below).
const showEmpty = path === ''
const treeOnly = showEmpty && !inPlace
/**
* Open a file from THIS window (tree click / search row / path input):
* merged mode switches this tab in place (stable id, meta survives);
* split mode opens a per-path dedupe tab through openSidebarFile.
*/
const openFile = (absolute: string): void => {
if (inPlace) {
ctx.betterSidebar?.updateTab(tab.id, { path: absolute, title: baseName(absolute) })
} else {
openSidebarFile(ctx, store, scope.sessionId, absolute)
}
}
/** The context menu's explicit "new tab" escape (per-path dedupe). */
const openFileNewTab = (absolute: string): void => {
openSidebarFile(ctx, store, scope.sessionId, absolute)
}
/**
* The context menu's "open to the side": a fresh editor tab (uid id — the
* `'editor:' + path` convention would clash with the id safety net on a
* second side-open of the same file) in a rightward split of THIS pane.
*/
const openFileSide = (absolute: string): void => {
store.reduce((state) => {
const key = treeOf(state, tab.id)
const pane = leafWithTab(state[key], tab.id) ?? firstLeaf(state[key])
const fresh: SidebarTab = {
id: mintTabId(),
type: 'editor',
title: baseName(absolute),
path: absolute,
meta: { treeOpen: false },
}
const { node, leafId } = insertLeafAt(state[key], pane.id, 'row', fresh, false)
return { ...state, [key]: node, activePane: leafId }
})
}
// The viewer's toolbar, hoisted into THIS header: the text editor reports
// its state and registers its commands (both null/absent for viewers
// without a toolbar — image, pdf, binary download).
const [toolbar, setToolbar] = useState<EditorToolbarState | null>(null)
const controlsRef = useRef<EditorToolbarControls | null>(null)
const onToolbarState = useCallback((next: EditorToolbarState) => {
setToolbar(prev => prev !== null && JSON.stringify(prev) === JSON.stringify(next) ? prev : next)
}, [])
const onToolbarControls = useCallback((controls: EditorToolbarControls | null) => {
controlsRef.current = controls
}, [])
// The docked panel's drag-resize: pointer capture on the handle itself
// (no window listeners — the captured pointer keeps tracking even off the
// handle). Local width while dragging, persisted into meta.treeWidth on
// release. The panel docks right, so dragging LEFT widens it.
const [dragWidth, setDragWidth] = useState<number | null>(null)
const dragRef = useRef<{ startX: number; startWidth: number } | null>(null)
const treeWidth = dragWidth ?? treeWidthOf(tab)
const onResizeStart = (event: React.PointerEvent): void => {
event.preventDefault()
// jsdom lacks setPointerCapture — the tests dispatch plain MouseEvents.
event.currentTarget.setPointerCapture?.(event.pointerId)
dragRef.current = { startX: event.clientX, startWidth: treeWidth }
}
const onResizeMove = (event: React.PointerEvent): void => {
const drag = dragRef.current
if (drag === null) return
setDragWidth(clampTreeWidth(drag.startWidth + (drag.startX - event.clientX)))
}
const onResizeEnd = (event: React.PointerEvent): void => {
const drag = dragRef.current
if (drag === null) return
dragRef.current = null
setDragWidth(null)
const finalWidth = clampTreeWidth(drag.startWidth + (drag.startX - event.clientX))
if (finalWidth !== treeWidthOf(tab)) patchMeta(ctx, tab, { treeWidth: finalWidth })
}
useEffect(() => { useEffect(() => {
// A (re)load or a path-less tab clears any hoisted toolbar state — the
// fresh viewer re-registers its own.
setToolbar(null)
// The seeded home tab (no path) never loads a viewer — the empty-state
// hint renders until the user picks a file.
if (showEmpty) return
let cancelled = false let cancelled = false
// Aborts the matched viewer's `load` when the editor tears down (tab // Aborts the matched viewer's `load` when the editor tears down (tab
// closed, path changed, session switched) or re-matches the viewer. // closed, path changed, session switched) or re-matches the viewer.
@@ -81,24 +247,176 @@ export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: Se
} }
apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf)) apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf))
return () => { cancelled = true; controller.abort() } return () => { cancelled = true; controller.abort() }
}, [scope.sessionId, scope.cwd, path, ctx]) }, [scope.sessionId, scope.cwd, path, ctx, showEmpty])
const treeOpen = treeOpenOf(tab)
/** Persist the panel flag on the tab (survives reloads with the layout). */
const toggleTree = (): void => { patchMeta(ctx, tab, { treeOpen: !treeOpen }) }
const saveLabel = toolbar === null ? ''
: toolbar.saveState === 'saving' ? t('loading')
: toolbar.saveState === 'saved' ? t('saved')
: toolbar.saveState === 'failed' ? t('saveFailed') : ''
// Split mode: the path-less window IS the standalone explorer — the tree
// panel fills the whole tab (search + FileTree, full form), no editor
// chrome. File opens land in new per-path tabs through openFile above.
if (treeOnly) {
return (
<div className={css.editor}>
<TreePanel
full
sessionId={scope.sessionId}
cwd={scope.cwd}
expanded={expanded}
onToggle={onToggleDir}
onOpenFile={openFile}
onOpenFileNewTab={openFileNewTab}
onOpenFileSide={openFileSide}
onReferenceFile={onReferenceFile}
/>
</div>
)
}
return ( return (
<div className={css.editor}> <div className={css.editor}>
<div className={css.editorHeader}> <div className={css.editorHeader}>
<span className={css.editorTitle} title={path}>{title}</span> <EditorPathInput key={path} path={path} cwd={scope.cwd} onOpen={openFile} />
{toolbar?.modes === true && (
<div className={css.editorModeToggle}>
<button
type="button"
className={clsx(css.editorModeButton, toolbar.mode === 'preview' && css.editorModeActive)}
onClick={() => { controlsRef.current?.setMode('preview') }}
>
{t('preview')}
</button>
<button
type="button"
className={clsx(css.editorModeButton, toolbar.mode === 'edit' && css.editorModeActive)}
onClick={() => { controlsRef.current?.setMode('edit') }}
>
{t('edit')}
</button>
</div>
)}
{toolbar?.dirty === true && <span className={css.dirtyDot} title={t('unsaved')} />}
{toolbar?.editable === true && (
<button
type="button"
className={css.iconButton}
aria-label={t('save')}
title={`${t('save')} (Ctrl/Cmd+S)`}
onClick={() => { controlsRef.current?.save() }}
>
<IconCheckOutline16 size={14} />
</button>
)}
{saveLabel !== '' && (
<span className={clsx(css.editorStatus, toolbar?.saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>
)}
<button
type="button"
className={clsx(css.iconButton, treeOpen && css.editorTreeToggleActive)}
aria-label={t('editorTreeToggle')}
title={t('editorTreeToggle')}
aria-pressed={treeOpen}
onClick={toggleTree}
>
<IconFolderOpen16 size={14} />
</button>
</div>
<div className={css.editorBody}>
<div className={css.editorMain}>
{showEmpty && <div className={css.editorPlaceholder}>{t('editorEmptyHint')}</div>}
{!showEmpty && load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
{!showEmpty && load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
{!showEmpty && load.status === 'binary' && <BinaryDownload scope={scope} path={path} />}
{!showEmpty && load.status === 'ready' && createElement(load.viewer.component, {
ctx, store, scope, path, title,
viewerId: load.viewer.id,
content: load.content,
truncated: load.truncated,
mediaUrl: load.mediaUrl,
customData: load.customData,
// The viewer's toolbar always hoists into this host's header.
toolbar: 'host',
onToolbarState,
onToolbarControls,
})}
</div>
{treeOpen && (
<div className={css.editorTreeDock} style={{ width: treeWidth }}>
<div
className={css.editorTreeResize}
role="separator"
aria-orientation="vertical"
aria-label={t('editorTreeToggle')}
onPointerDown={onResizeStart}
onPointerMove={onResizeMove}
onPointerUp={onResizeEnd}
onPointerCancel={onResizeEnd}
/>
<TreePanel
sessionId={scope.sessionId}
cwd={scope.cwd}
expanded={expanded}
onToggle={onToggleDir}
onOpenFile={openFile}
onOpenFileNewTab={openFileNewTab}
onOpenFileSide={openFileSide}
onReferenceFile={onReferenceFile}
/>
</div>
)}
</div> </div>
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
{load.status === 'binary' && <BinaryDownload scope={scope} path={path} />}
{load.status === 'ready' && createElement(load.viewer.component, {
ctx, store, scope, path, title,
viewerId: load.viewer.id,
content: load.content,
truncated: load.truncated,
mediaUrl: load.mediaUrl,
customData: load.customData,
})}
</div> </div>
) )
} }
/**
* The header's path input: shows the current file relative to the session
* cwd (absolute when outside it). Enter resolves the typed path (relative
* input joins onto the cwd — the same resolution `openSidebarFile` uses)
* and opens it through the parent's mode-aware open (in-place switch or a
* per-path dedupe tab); Escape/blur restores the current value. The parent
* keys it by `path` so an in-place switch remounts and reseeds the draft.
*/
function EditorPathInput(props: { path: string; cwd: string | undefined; onOpen: (path: string) => void }) {
const { path, cwd, onOpen } = props
const display = path === '' ? '' : relativeTo(cwd ?? '', path)
const [value, setValue] = useState(display)
const commit = (): void => {
const input = value.trim()
if (input === '' || input === display) {
setValue(display)
return
}
onOpen(resolveSidebarPath(cwd, input))
// Split mode: the open lands in a NEW/deduped editor tab — THIS tab's
// path stays, so the input falls back to its own display value. (Merged
// mode remounts this input on the new path; the reset is harmless.)
setValue(display)
}
return (
<input
className={css.editorPathInput}
value={value}
placeholder={t('editorPathPlaceholder')}
title={path}
spellCheck={false}
onChange={(event) => { setValue(event.target.value) }}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
commit()
} else if (event.key === 'Escape') {
setValue(display)
}
}}
onBlur={() => { setValue(display) }}
/>
)
}
@@ -1,20 +1,25 @@
/** /**
* The file explorer: a lazy VSCode-style tree rooted at the session's * The controlled file tree behind the files window's tree panel (TreePanel
* working directory. Levels load on expansion (one API call per directory), * wraps it with the search box): a lazy VSCode-style tree rooted at the
* directories sort first, hidden entries render dimmed, and the expansion * session's working directory. Levels load on expansion (one API call per
* set lives in the per-session state. Clicking a file opens an editor tab. * directory), directories sort first, hidden entries render dimmed. The
* expansion set lives in the per-session state (owned by the caller); the
* caller also owns the refresh affordance — a `refreshTick` bump wipes the
* level cache so the visible set reloads.
* *
* Row actions: hovering a row reveals an @-reference button on the far * Row actions: hovering a row reveals an @-reference button on the far
* right (appends `@<relative path>` to the composer draft), and right-click * right (appends `@<relative path>` to the composer draft), and right-click
* opens a context menu to copy the relative or absolute path (with a brief * opens a context menu: file rows offer the caller's open escapes
* "copied" label replacing the button after a successful write); file rows * (new tab / to the side, only when the callbacks exist) and a download
* also offer a download action (the host serves raw bytes, binary-safe). * action (the host serves raw bytes, binary-safe); every row can copy the
* relative or absolute path (with a brief "copied" label replacing the
* button after a successful write).
*/ */
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react' import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { import {
IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16, IconFolderClose16, IconFolderOpen16, IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16, IconFolderClose16, IconFolderOpen16,
IconRefreshOutline16, Menu, writeClipboard, IconLinkOutline16, Menu, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives' } from '@deepseek-ai/dsh-client-ui-primitives'
import { api, downloadUrl, type FsEntry } from './api.ts' import { api, downloadUrl, type FsEntry } from './api.ts'
import { relativeTo } from './paths.ts' import { relativeTo } from './paths.ts'
@@ -27,7 +32,7 @@ interface LevelData {
} }
/** Root label: the last path segment (mirror of the host rootLabel). */ /** Root label: the last path segment (mirror of the host rootLabel). */
function baseName(path: string): string { export function baseName(path: string): string {
const trimmed = path.replace(/[\\/]+$/, '') const trimmed = path.replace(/[\\/]+$/, '')
const at = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\')) const at = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
return at === -1 ? trimmed : trimmed.slice(at + 1) return at === -1 ? trimmed : trimmed.slice(at + 1)
@@ -36,19 +41,24 @@ function baseName(path: string): string {
/** How long the row's "copied" label stays after a successful write. */ /** How long the row's "copied" label stays after a successful write. */
const COPIED_MS = 1200 const COPIED_MS = 1200
export function ExplorerView(props: { export function FileTree(props: {
sessionId: string sessionId: string
cwd: string | undefined cwd: string | undefined
expanded: string[] expanded: string[]
onToggle: (path: string) => void onToggle: (path: string) => void
onOpenFile: (path: string) => void onOpenFile: (path: string) => void
/** Context-menu "open in a new tab" (file rows; absent → no entry). */
onOpenFileNewTab?: (path: string) => void
/** Context-menu "open to the side" (file rows; absent → no entry). */
onOpenFileSide?: (path: string) => void
/** Insert `@<relative path>` into the composer draft. */ /** Insert `@<relative path>` into the composer draft. */
onReferenceFile: (path: string) => void onReferenceFile: (path: string) => void
/** Bump to wipe the level cache and reload the visible set. */
refreshTick: number
}) { }) {
const { sessionId, cwd, expanded, onToggle, onOpenFile, onReferenceFile } = props const { sessionId, cwd, expanded, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, onReferenceFile, refreshTick } = props
const [data, setData] = useState<Record<string, LevelData>>({}) const [data, setData] = useState<Record<string, LevelData>>({})
const dataRef = useRef(data) const dataRef = useRef(data)
const [refreshTick, setRefreshTick] = useState(0)
/** The row whose path was just copied ("copied" label replaces its button). */ /** The row whose path was just copied ("copied" label replaces its button). */
const [copiedPath, setCopiedPath] = useState<string | null>(null) const [copiedPath, setCopiedPath] = useState<string | null>(null)
/** Open context menu: the row path (and whether it is a directory) plus the cursor position. */ /** Open context menu: the row path (and whether it is a directory) plus the cursor position. */
@@ -69,9 +79,19 @@ export function ExplorerView(props: {
}) })
}, [sessionId, cwd, storeLevel]) }, [sessionId, cwd, storeLevel])
// The caller's refresh tick wipes the cache (declared BEFORE the load
// effect so the reload below sees the empty cache).
const lastTick = useRef(refreshTick)
useEffect(() => {
if (lastTick.current === refreshTick) return
lastTick.current = refreshTick
dataRef.current = {}
setData({})
}, [refreshTick])
useEffect(() => { useEffect(() => {
// Load the visible set; already-loaded levels (kept in the cache) are // Load the visible set; already-loaded levels (kept in the cache) are
// not refetched. Only the refresh button wipes the cache. // not refetched. Only the refresh tick wipes the cache.
const root = cwd const root = cwd
if (root === undefined) return if (root === undefined) return
loadDir(root) loadDir(root)
@@ -163,6 +183,7 @@ export function ExplorerView(props: {
> >
{isOpen ? <IconFolderOpen16 size={14} /> : <IconFolderClose16 size={14} />} {isOpen ? <IconFolderOpen16 size={14} /> : <IconFolderClose16 size={14} />}
<span className={css.explorerName}>{entry.name}</span> <span className={css.explorerName}>{entry.name}</span>
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
{rowActions(entry)} {rowActions(entry)}
</div> </div>
{isOpen && renderLevel(entry.path, depth + 1)} {isOpen && renderLevel(entry.path, depth + 1)}
@@ -174,9 +195,9 @@ export function ExplorerView(props: {
key={entry.path} key={entry.path}
role="button" role="button"
tabIndex={0} tabIndex={0}
className={clsx(css.explorerRow, entry.hidden && css.explorerHidden)} className={clsx(css.explorerRow, entry.hidden && css.explorerHidden, entry.broken && css.explorerBroken)}
style={{ paddingLeft: depth * 22 + 6 }} style={{ paddingLeft: depth * 22 + 6 }}
title={entry.path} title={entry.broken ? `${entry.path} — ${t('brokenSymlink')}` : entry.path}
onClick={() => { onOpenFile(entry.path) }} onClick={() => { onOpenFile(entry.path) }}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') { if (event.key === 'Enter' || event.key === ' ') {
@@ -188,6 +209,7 @@ export function ExplorerView(props: {
> >
<IconCodeOutline16 size={14} /> <IconCodeOutline16 size={14} />
<span className={css.explorerName}>{entry.name}</span> <span className={css.explorerName}>{entry.name}</span>
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
{rowActions(entry)} {rowActions(entry)}
</div> </div>
) )
@@ -195,64 +217,53 @@ export function ExplorerView(props: {
} }
return ( return (
<div className={css.explorer}> <div className={css.explorerBody}>
<div className={css.explorerHeader}> {root === undefined ? (
<span className={css.explorerRoot} title={root}>{root === undefined ? t('noSession') : baseName(root)}</span> <div className={css.explorerEmpty}>{t('noSession')}</div>
<button ) : (
type="button" <>
className={css.iconButton} <div
aria-label={t('refresh')} className={css.explorerRow}
title={t('refresh')} style={{ paddingLeft: 6 }}
onClick={() => { onContextMenu={(event) => { openRowMenu(event, root, true) }}
dataRef.current = {} >
setData({}) <IconFolderOpen16 size={14} />
setRefreshTick(tick => tick + 1) <span className={css.explorerName}>{baseName(root)}</span>
}} {copiedPath === root
> ? <span className={css.explorerCopied}>{t('copied')}</span>
<IconRefreshOutline16 /> : (
</button> <button
</div> type="button"
<div className={css.explorerBody}> className={css.explorerRef}
{root === undefined ? ( aria-label={t('referenceFile')}
<div className={css.explorerEmpty}>{t('noSession')}</div> title={t('referenceFile')}
) : ( onClick={(event) => {
<> event.stopPropagation()
<div onReferenceFile(root)
className={css.explorerRow} }}
style={{ paddingLeft: 6 }} >
onContextMenu={(event) => { openRowMenu(event, root, true) }} {t('referenceFile')}
> </button>
<IconFolderOpen16 size={14} /> )}
<span className={css.explorerName}>{baseName(root)}</span> </div>
{copiedPath === root {data[root] !== undefined && renderLevel(root, 1)}
? <span className={css.explorerCopied}>{t('copied')}</span> </>
: ( )}
<button
type="button"
className={css.explorerRef}
aria-label={t('referenceFile')}
title={t('referenceFile')}
onClick={(event) => {
event.stopPropagation()
onReferenceFile(root)
}}
>
{t('referenceFile')}
</button>
)}
</div>
{data[root] !== undefined && renderLevel(root, 1)}
</>
)}
</div>
{/* {/*
The one shared context menu, positioned at the right-click cursor The one shared context menu, positioned at the right-click cursor
(portal so the explorer's overflow clip cannot crop it). (portal so the tree's overflow clip cannot crop it).
*/} */}
<Menu <Menu
open={rowMenu !== null} open={rowMenu !== null}
onClose={() => { setRowMenu(null) }} onClose={() => { setRowMenu(null) }}
items={[ items={[
// The open escapes head the FILE menu (dirs only get copy).
...(rowMenu?.isDir === false && onOpenFileNewTab !== undefined
? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: <IconCodeOutline16 size={14} /> }]
: []),
...(rowMenu?.isDir === false && onOpenFileSide !== undefined
? [{ id: 'open-side', label: t('openFileSide'), icon: <IconFolderOpen16 size={14} /> }]
: []),
// Download applies to files only (the host route refuses directories). // Download applies to files only (the host route refuses directories).
...(rowMenu?.isDir === false ...(rowMenu?.isDir === false
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={14} /> }] ? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={14} /> }]
@@ -264,6 +275,14 @@ export function ExplorerView(props: {
const target = rowMenu const target = rowMenu
if (target === null) return if (target === null) return
setRowMenu(null) setRowMenu(null)
if (id === 'open-new-tab') {
onOpenFileNewTab?.(target.path)
return
}
if (id === 'open-side') {
onOpenFileSide?.(target.path)
return
}
if (id === 'download') { if (id === 'download') {
downloadFile(target.path) downloadFile(target.path)
return return
+1 -1
View File
@@ -297,7 +297,7 @@ export function GitView(props: {
title={t('refresh')} title={t('refresh')}
onClick={() => { void refresh() }} onClick={() => { void refresh() }}
> >
<IconRefreshOutline16 /> <IconRefreshOutline16 size={14} />
</button> </button>
</div> </div>
+69 -14
View File
@@ -24,12 +24,11 @@
flex-direction: column; flex-direction: column;
gap: 14px; gap: 14px;
width: 100%; width: 100%;
/* Tall inventories (7 tab cards + 9 viewer cards + general rows) must max-width: 760px;
never be clipped by the settings shell: fill the shell's options area /* The DSH settings shell's `.options` area owns vertical scrolling. Keep
and scroll inside the section when the content outgrows the panel. */ this page a plain content column like Plugins → Plugin list so its
height: 100%; scrollbar stays at the shell edge instead of being inset by the options
min-height: 0; area's right padding. */
overflow-y: auto;
} }
/* One-line section intro (the DSH section heading+intro recipe). */ /* One-line section intro (the DSH section heading+intro recipe). */
@@ -404,6 +403,59 @@
width: 76px; width: 76px;
} }
/* Select rows: the closed anchor is a compact button (selected option's
icon when the options carry icons, plus title and a chevron); the dropdown
itself is the primitives Menu (portal). */
.selectAnchor {
display: flex;
align-items: center;
gap: 6px;
max-width: 220px;
padding: 4px 8px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
background: var(--dsw-alias-bg-layer-3);
color: var(--dsw-alias-label-primary);
font-size: 13px;
line-height: 20px;
cursor: pointer;
}
.selectAnchor:hover {
border-color: var(--dsw-alias-label-dimmed);
}
.selectAnchorIcon {
display: inline-flex;
flex: none;
}
.selectAnchorText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* One big-icon option card inside the select dropdown. */
.selectOption {
display: flex;
align-items: center;
gap: 10px;
min-width: 200px;
}
.selectOptionIcon {
display: inline-flex;
flex: none;
color: var(--dsw-alias-label-secondary);
}
.selectOptionText {
display: flex;
flex-direction: column;
min-width: 0;
}
.suffix { .suffix {
font-size: 14px; font-size: 14px;
line-height: 22px; line-height: 22px;
@@ -417,10 +469,13 @@
width: min(460px, 100%); width: min(460px, 100%);
} }
/* Popup rows: the same settings-row recipe with switch controls. */ /* Popup rows: each setting gets the same outer chrome as a plugin-list
PluginCard (l2 hairline, 12px radius, layer-3 fill). The row's title,
description and control geometry stay unchanged inside that frame. */
.popupRows { .popupRows {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px;
width: 100%; width: 100%;
} }
@@ -429,16 +484,16 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 16px;
padding: 14px 2px; min-width: 0;
border-bottom: 1px solid var(--dsw-alias-border-l2); padding: 14px 16px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
background: var(--dsw-alias-bg-layer-3);
transition: border-color 0.16s, background 0.16s;
} }
.popupRow:hover { .popupRow:hover {
background: var(--dsw-alias-interactive-bg-hover); border-color: var(--dsw-alias-label-dimmed);
}
.popupRows > :last-child {
border-bottom: none;
} }
/* The popup's Done button: the settings primary-button recipe /* The popup's Done button: the settings primary-button recipe
+124 -2
View File
@@ -41,9 +41,11 @@
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react' import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
import { import {
IconCheckOutline16, IconCheckOutline16,
IconChevronDownOutline14,
IconPlusOutline16, IconPlusOutline16,
IconSettingsOutline16, IconSettingsOutline16,
Input, Input,
Menu,
Modal, Modal,
} from '@deepseek-ai/dsh-client-ui-primitives' } from '@deepseek-ai/dsh-client-ui-primitives'
import clsx from 'clsx' import clsx from 'clsx'
@@ -209,6 +211,10 @@ export function FeatureSettingsRows(props: {
* display (clamped for numbers, the current pref when the input is * display (clamped for numbers, the current pref when the input is
* invalid). Optional: rows with no handler keep their draft. */ * invalid). Optional: rows with no handler keep their draft. */
onCommit?: (toggle: SidebarSettingToggle, raw: string) => string onCommit?: (toggle: SidebarSettingToggle, raw: string) => string
/** Commit one select row: the picked option's value (single) or the array
* of picked values (`multi: true`). Optional: rows with no handler are
* display-only. */
onSelectValue?: (toggle: SidebarSettingToggle, next: unknown) => void
/** Explicit value source (v0.12.0+): when given, rows read their values /** Explicit value source (v0.12.0+): when given, rows read their values
* from it instead of the `prefs` face — plugin-owned rows read their * from it instead of the `prefs` face — plugin-owned rows read their
* own blob, so a plugin key can never collide with (or silently read) * own blob, so a plugin key can never collide with (or silently read)
@@ -216,12 +222,23 @@ export function FeatureSettingsRows(props: {
* the latter collides with the inherited Object.prototype.valueOf.) */ * the latter collides with the inherited Object.prototype.valueOf.) */
valueSource?: (key: string) => unknown valueSource?: (key: string) => unknown
}) { }) {
const { toggles, prefs, onToggle, onCommit, valueSource } = props const { toggles, prefs, onToggle, onCommit, onSelectValue, valueSource } = props
const read = valueSource ?? ((key: string): unknown => (prefs as unknown as Record<string, unknown>)[key]) const read = valueSource ?? ((key: string): unknown => (prefs as unknown as Record<string, unknown>)[key])
return ( return (
<div className={css.popupRows}> <div className={css.popupRows}>
{toggles.map(toggle => { {toggles.map(toggle => {
const title = textOf(toggle.title) const title = textOf(toggle.title)
if (toggle.type === 'select') {
return (
<SelectRow
key={toggle.key}
toggle={toggle}
title={title}
value={read(toggle.key)}
onSelectValue={onSelectValue}
/>
)
}
if ((toggle.type ?? 'switch') === 'switch') { if ((toggle.type ?? 'switch') === 'switch') {
return ( return (
<div key={toggle.key} className={css.popupRow}> <div key={toggle.key} className={css.popupRow}>
@@ -302,6 +319,99 @@ function TypedRow(props: {
) )
} }
/**
* One select row: a dropdown over the toggle's declared `options`. When any
* option carries an icon, the dropdown renders big-icon option cards (icon +
* title + desc) and the closed anchor shows the selected option's icon as
* well; without icons both are a single line of text. Single-pick commits the
* option's value and closes; `multi` toggles membership, commits the picked
* values as an array (in options order), and stays open.
*/
function SelectRow(props: {
toggle: SidebarSettingToggle
title: string
value: unknown
onSelectValue?: (toggle: SidebarSettingToggle, next: unknown) => void
}) {
const { toggle, title, value, onSelectValue } = props
const options = toggle.options ?? []
const multi = toggle.multi === true
const [open, setOpen] = useState(false)
const hasIcons = options.some(option => option.icon !== undefined)
const picked: readonly unknown[] = multi ? (Array.isArray(value) ? value : []) : [value]
const selected = options.filter(option => picked.includes(option.value))
/** Commit one picked option (toggle semantics under multi). */
const pick = (index: number): void => {
const option = options[index]
if (option === undefined) return
if (!multi) {
onSelectValue?.(toggle, option.value)
setOpen(false)
return
}
const current = Array.isArray(value) ? [...value] : []
const at = current.indexOf(option.value)
if (at >= 0) current.splice(at, 1)
else current.push(option.value)
// Stable wire order: follow the declared options order, not pick order.
onSelectValue?.(toggle, options.filter(o => current.includes(o.value)).map(o => o.value))
}
const anchor = (
<button
type="button"
className={css.selectAnchor}
aria-label={title}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => { setOpen(now => !now) }}
>
{!multi && hasIcons && selected[0] !== undefined && (
<span className={css.selectAnchorIcon}>{iconOf(selected[0].icon, 16)}</span>
)}
<span className={css.selectAnchorText}>
{selected.length === 0 ? '—' : selected.map(option => textOf(option.title)).join(', ')}
</span>
<IconChevronDownOutline14 size={12} />
</button>
)
return (
<div className={css.popupRow}>
<span className={css.rowText}>
<span className={css.title}>{title}</span>
{textOf(toggle.desc) !== '' && <span className={css.desc}>{textOf(toggle.desc)}</span>}
</span>
<span className={css.control}>
<Menu
open={open}
anchor={anchor}
items={options.map((option, index) => ({
id: String(index),
label: hasIcons
? (
<span className={css.selectOption}>
<span className={css.selectOptionIcon}>{iconOf(option.icon, 24)}</span>
<span className={css.selectOptionText}>
<span className={css.title}>{textOf(option.title)}</span>
{textOf(option.desc) !== '' && <span className={css.desc}>{textOf(option.desc)}</span>}
</span>
</span>
)
: textOf(option.title),
}))}
selectedId={!multi && selected[0] !== undefined ? String(options.indexOf(selected[0])) : undefined}
selectedIds={multi ? selected.map(option => String(options.indexOf(option))) : undefined}
onSelect={(id) => { pick(Number(id)) }}
onClose={() => { setOpen(false) }}
portal
/>
</span>
</div>
)
}
/** /**
* The secondary settings popup body of one feature (tab or viewer): * The secondary settings popup body of one feature (tab or viewer):
* - `settings.render` (custom panel) when declared — rendered with the * - `settings.render` (custom panel) when declared — rendered with the
@@ -318,12 +428,14 @@ export function SettingsBody(props: {
service: BetterSidebarService service: BetterSidebarService
onToggle: (toggle: SidebarSettingToggle, next: boolean) => void onToggle: (toggle: SidebarSettingToggle, next: boolean) => void
onCommit: (toggle: SidebarSettingToggle, raw: string) => string onCommit: (toggle: SidebarSettingToggle, raw: string) => string
onSelectValue: (toggle: SidebarSettingToggle, next: unknown) => void
onPluginToggle: (toggle: SidebarSettingToggle, next: boolean) => void onPluginToggle: (toggle: SidebarSettingToggle, next: boolean) => void
onPluginCommit: (toggle: SidebarSettingToggle, raw: string) => string onPluginCommit: (toggle: SidebarSettingToggle, raw: string) => string
onPluginSelectValue: (toggle: SidebarSettingToggle, next: unknown) => void
onPluginWrite: (key: string, value: unknown) => void onPluginWrite: (key: string, value: unknown) => void
onClose: () => void onClose: () => void
}) { }) {
const { feature, prefs, store, service, onToggle, onCommit, onPluginToggle, onPluginCommit, onPluginWrite, onClose } = props const { feature, prefs, store, service, onToggle, onCommit, onSelectValue, onPluginToggle, onPluginCommit, onPluginSelectValue, onPluginWrite, onClose } = props
const render = feature.settings?.render const render = feature.settings?.render
if (render !== undefined) { if (render !== undefined) {
return ( return (
@@ -356,6 +468,7 @@ export function SettingsBody(props: {
prefs={prefs} prefs={prefs}
onToggle={onToggle} onToggle={onToggle}
onCommit={onCommit} onCommit={onCommit}
onSelectValue={onSelectValue}
/> />
)} )}
{pluginToggles.length > 0 && ( {pluginToggles.length > 0 && (
@@ -364,6 +477,7 @@ export function SettingsBody(props: {
prefs={prefs} prefs={prefs}
onToggle={onPluginToggle} onToggle={onPluginToggle}
onCommit={onPluginCommit} onCommit={onPluginCommit}
onSelectValue={onPluginSelectValue}
valueSource={(key) => pluginBlob[key]} valueSource={(key) => pluginBlob[key]}
/> />
)} )}
@@ -493,6 +607,12 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
applyPref({ [toggle.key]: next }) applyPref({ [toggle.key]: next })
} }
/** Commit one declaratively-declared select row (the option's value, or an
* array of values under `multi`). */
const onSelectSetting = (toggle: SidebarSettingToggle, next: unknown): void => {
applyPref({ [toggle.key]: next })
}
/** /**
* Commit one declaratively-declared text/number row. Numbers are parsed * Commit one declaratively-declared text/number row. Numbers are parsed
* and clamped to the toggle's declared min/max (an unparsable input falls * and clamped to the toggle's declared min/max (an unparsable input falls
@@ -807,8 +927,10 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
prefs={prefs} prefs={prefs}
onToggle={onToggleSetting} onToggle={onToggleSetting}
onCommit={onCommitSetting} onCommit={onCommitSetting}
onSelectValue={onSelectSetting}
onPluginToggle={(toggle, next) => { onPluginToggle(settingsFor.id, toggle, next) }} onPluginToggle={(toggle, next) => { onPluginToggle(settingsFor.id, toggle, next) }}
onPluginCommit={(toggle, raw) => onPluginCommitSetting(settingsFor.id, toggle, raw)} onPluginCommit={(toggle, raw) => onPluginCommitSetting(settingsFor.id, toggle, raw)}
onPluginSelectValue={(toggle, next) => { applyPluginSetting(settingsFor.id, toggle.key, next) }}
onPluginWrite={(key, value) => { applyPluginSetting(settingsFor.id, key, value) }} onPluginWrite={(key, value) => { applyPluginSetting(settingsFor.id, key, value) }}
onClose={() => { setSettingsFor(null) }} onClose={() => { setSettingsFor(null) }}
store={store} store={store}
+51 -49
View File
@@ -462,7 +462,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// pointer up (clamping + persistence). // pointer up (clamping + persistence).
const panelRef = useRef<HTMLDivElement | null>(null) const panelRef = useRef<HTMLDivElement | null>(null)
const bottomRef = useRef<HTMLDivElement | null>(null) const bottomRef = useRef<HTMLDivElement | null>(null)
const cornerRef = useRef<HTMLDivElement | null>(null)
const widthDrag = useRef({ startX: 0, startWidth: 0 }) const widthDrag = useRef({ startX: 0, startWidth: 0 })
const [draggingWidth, setDraggingWidth] = useState(false) const [draggingWidth, setDraggingWidth] = useState(false)
const bottomDrag = useRef({ startY: 0, startHeight: 0 }) const bottomDrag = useRef({ startY: 0, startHeight: 0 })
@@ -501,10 +500,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRect.right) + (width - (state?.width ?? 0))}px`) 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-width', `${width}px`)
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`) document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
if (cornerRef.current !== null) { // The corner handle positions itself relative to the panel (CSS
cornerRef.current.style.left = `${window.innerWidth - width - 6}px` // `bottom: calc(var(--dsh-sidebar-height) + 6px)`), so these two layout
cornerRef.current.style.top = `${window.innerHeight - height - 6}px` // variables are all it needs — no viewport coordinates written here
} // (issue #106: skins that inset the panels must not fight JS coords).
} }
// Drags write at most once per frame: pointer events fire several times // Drags write at most once per frame: pointer events fire several times
@@ -775,11 +774,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
ref={panelRef} ref={panelRef}
className={clsx(css.panel, !state.panelOpen && css.panelHidden)} 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) }}
data-dragging={anyDragging || undefined} data-dragging={anyDragging || undefined}
> >
{!narrow && ( {!narrow && (
<div <div
className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)} className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)}
onPointerDown={(event) => { onPointerDown={(event) => {
event.preventDefault() event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId) event.currentTarget.setPointerCapture(event.pointerId)
@@ -814,6 +815,49 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
getTabBadge={tabBadgeOf} getTabBadge={tabBadgeOf}
/> />
</div> </div>
{/*
The shared corner (only while BOTH panels are open): the
intersection of the right panel's left edge and the bottom panel's
top edge. Horizontal drags resize the right panel's width, vertical
drags the bottom panel's height — the two panels drag against each
other. Rendered INSIDE the right panel and positioned by CSS
relative to it (left edge + the bottom panel's height via the
--dsh-sidebar-height layout variable) — no JS-written viewport
coordinates to keep in sync. (Never on narrow viewports: the
bottom panel does not exist there.)
*/}
{!narrow && state.panelOpen && state.bottomOpen && (
<div
className={css.cornerHandle}
data-dragging={draggingCorner || undefined}
onPointerDown={(event) => {
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
cornerDrag.current = {
startX: event.clientX,
startY: event.clientY,
startWidth: state.width,
startHeight: state.bottomHeight,
}
setDraggingCorner(true)
}}
onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const { startX, startY, startWidth, startHeight } = cornerDrag.current
const width = clampWidth(startWidth + (startX - event.clientX))
const height = clampHeight(startHeight + (startY - event.clientY))
scheduleDrag(width, height)
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
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)))
setDraggingCorner(false)
}}
/>
)}
</div> </div>
{/* {/*
The bottom panel: a second, independent workbench. It squeezes ONLY The bottom panel: a second, independent workbench. It squeezes ONLY
@@ -842,10 +886,12 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// fill — without it the corner looks cut off). // fill — without it the corner looks cut off).
borderRight: state.panelOpen ? '1px solid var(--dsw-alias-border-l2)' : undefined, borderRight: state.panelOpen ? '1px solid var(--dsw-alias-border-l2)' : undefined,
}} }}
data-dragging={(draggingBottom || draggingCorner) || undefined} data-dragging={(draggingBottom || draggingCorner) || undefined}
> >
<div <div
className={clsx(css.bottomResize, draggingBottom && css.bottomResizeActive)} className={clsx(css.bottomResize, draggingBottom && css.bottomResizeActive)}
onPointerDown={(event) => { onPointerDown={(event) => {
event.preventDefault() event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId) event.currentTarget.setPointerCapture(event.pointerId)
@@ -896,50 +942,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
</div> </div>
</div> </div>
)} )}
{/*
The shared corner (only while BOTH panels are open): the intersection
of the right panel's left edge and the bottom panel's top edge.
Horizontal drags resize the right panel's width, vertical drags the
bottom panel's height — the two panels drag against each other.
(Never on narrow viewports: the bottom panel does not exist there.)
*/}
{!narrow && state.panelOpen && state.bottomOpen && (
<div
ref={cornerRef}
className={css.cornerHandle}
style={{
left: window.innerWidth - state.width - 6,
top: window.innerHeight - state.bottomHeight - 6,
}}
data-dragging={draggingCorner || undefined}
onPointerDown={(event) => {
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
cornerDrag.current = {
startX: event.clientX,
startY: event.clientY,
startWidth: state.width,
startHeight: state.bottomHeight,
}
setDraggingCorner(true)
}}
onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const { startX, startY, startWidth, startHeight } = cornerDrag.current
const width = clampWidth(startWidth + (startX - event.clientX))
const height = clampHeight(startHeight + (startY - event.clientY))
scheduleDrag(width, height)
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
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)))
setDraggingCorner(false)
}}
/>
)}
</> </>
) )
} }
+88 -7
View File
@@ -20,20 +20,32 @@
* reconnect grace. * reconnect grace.
*/ */
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Terminal, type ITheme } from 'xterm' import { Terminal, type ITheme } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit' import { FitAddon } from '@xterm/addon-fit'
import 'xterm/css/xterm.css' import { writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
import '@xterm/xterm/css/xterm.css'
import { t } from './locales.ts' import { t } from './locales.ts'
import { openWhenSized } from './open-when-sized.ts' import { openWhenSized } from './open-when-sized.ts'
import type { SessionScope } from './api.ts' import { api, type SessionScope, type TerminalDepsStatus } from './api.ts'
import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts' import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts'
import { isDarkScheme, subscribeColorScheme, tokenValue } from './theme.ts' import { isDarkScheme, subscribeColorScheme, effectiveTokenValue, tokenValue } from './theme.ts'
import { resolveTerminalFont } from './terminal-font.ts' import { resolveTerminalFont } from './terminal-font.ts'
import css from './sidebar.module.css' import css from './sidebar.module.css'
/** How many consecutive unreasoned failures before showing the error banner. */ /** How many consecutive unreasoned failures before showing the error banner. */
const FAILURE_LIMIT = 3 const FAILURE_LIMIT = 3
/**
* The WS close-code-1011 reason the host sends when node-pty is unavailable
* (mirror of the host's PTY_DEPS_MISSING; the value is a wire contract, so
* the two sides keep the literal in lockstep). The view then fetches the
* full repair details from /sidebar/api/terminal.deps.
*/
const PTY_DEPS_MISSING = 'pty-deps-missing'
/** The degraded-mode payload rendered by {@link TerminalDepsBanner}. */
type TerminalDepsInfo = Extract<TerminalDepsStatus, { ok: false }>
/** /**
* Curated ANSI palettes for the terminal. The surface colors (background, * Curated ANSI palettes for the terminal. The surface colors (background,
* foreground, cursor, selection) ride the theme tokens so the terminal * foreground, cursor, selection) ride the theme tokens so the terminal
@@ -61,8 +73,14 @@ const ANSI_LIGHT: Record<string, string> = {
/** The xterm theme for the current scheme (surface from tokens, ANSI curated). */ /** The xterm theme for the current scheme (surface from tokens, ANSI curated). */
function xtermTheme(): ITheme { function xtermTheme(): ITheme {
const dark = isDarkScheme() const dark = isDarkScheme()
const background = tokenValue('--dsw-alias-bg-base') || (dark ? '#111114' : '#ffffff') // Skin systems set --dsw-alias-bg-base to `transparent` or translucent
const foreground = tokenValue('--dsw-alias-label-primary') || (dark ? '#e6e6e6' : '#1a1a1a') // glass values (the dsh-web-ui skins use rgba 0.16–0.7); effectiveTokenValue
// treats those as unset below the opacity floor, so the opaque fallback
// engages and the terminal never renders see-through over the skin's
// backdrop (issue #90). Effectively opaque scoped surfaces (e.g. a skin's
// 0.96 porcelain) pass through — the skin still controls the terminal.
const background = effectiveTokenValue('--dsw-alias-bg-base') || (dark ? '#111114' : '#ffffff')
const foreground = effectiveTokenValue('--dsw-alias-label-primary') || (dark ? '#e6e6e6' : '#1a1a1a')
return { return {
background, background,
foreground, foreground,
@@ -78,6 +96,7 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
const hostRef = useRef<HTMLDivElement>(null) const hostRef = useRef<HTMLDivElement>(null)
const [connected, setConnected] = useState(false) const [connected, setConnected] = useState(false)
const [fatal, setFatal] = useState<string | null>(null) const [fatal, setFatal] = useState<string | null>(null)
const [depsFatal, setDepsFatal] = useState<TerminalDepsInfo | null>(null)
const [lastUrl, setLastUrl] = useState<string | null>(null) const [lastUrl, setLastUrl] = useState<string | null>(null)
const connectRef = useRef<(() => void) | null>(null) const connectRef = useRef<(() => void) | null>(null)
@@ -151,6 +170,25 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
} }
socket.onclose = (event) => { socket.onclose = (event) => {
setConnected(false) setConnected(false)
// node-pty dependency missing/broken (issue #140): the host closed
// with the short marker. Fetch the full repair details over HTTP —
// a WS close reason is capped at 123 bytes, too small for the
// pasteable command. A failed fetch falls back to the plain banner.
if (event.code === 1011 && event.reason === PTY_DEPS_MISSING) {
void api.terminalDeps().then((status) => {
if (status.ok) {
// The host recovered between the close and the fetch — the
// plain banner with a retry is the honest state.
setFatal(t('terminalDepsFailed'))
return
}
setFatal(null)
setDepsFatal(status)
}).catch(() => {
setFatal(t('terminalDepsFailed'))
})
return
}
// A server-side refusal carries a close code + reason; retrying it // A server-side refusal carries a close code + reason; retrying it
// forever would only spin the banner, so surface it with a retry. // forever would only spin the banner, so surface it with a retry.
if (event.code === 1011 && event.reason !== '') { if (event.code === 1011 && event.reason !== '') {
@@ -257,6 +295,9 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
return ( return (
<div className={css.terminalWrap}> <div className={css.terminalWrap}>
{depsFatal !== null && (
<TerminalDepsBanner deps={depsFatal} onRetry={() => { setDepsFatal(null); connectRef.current?.() }} />
)}
{fatal !== null && ( {fatal !== null && (
<div className={css.terminalBanner}> <div className={css.terminalBanner}>
{t('terminalError')}: {fatal} {t('terminalError')}: {fatal}
@@ -270,8 +311,48 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
</button> </button>
</div> </div>
)} )}
{fatal === null && !connected && <div className={css.terminalBanner}>{t('disconnected')}</div>} {fatal === null && depsFatal === null && !connected && <div className={css.terminalBanner}>{t('disconnected')}</div>}
<div ref={hostRef} className={css.terminal} /> <div ref={hostRef} className={css.terminal} />
</div> </div>
) )
} }
/**
* The node-pty dependency failure banner (issue #140): explains that the
* terminal's native dependency failed to load and shows the PASTEABLE repair
* command (bash / cmd / PowerShell) with a copy button — the user pastes it
* into a terminal where their DSH profile lives and runs it, then retries.
* Extracted as a standalone component for direct testing.
*/
export function TerminalDepsBanner(props: { deps: TerminalDepsInfo; onRetry: () => void }) {
const { deps, onRetry } = props
const [copied, setCopied] = useState(false)
const copy = async (): Promise<void> => {
const written = await writeClipboard(deps.command)
if (written) {
setCopied(true)
window.setTimeout(() => setCopied(false), 2000)
}
}
return (
<div className={css.terminalDepsBanner}>
<div className={css.terminalDepsTitle}>{t('terminalDepsFailed')}</div>
<div className={css.terminalDepsHint}>
{t('terminalDepsHint')}
{deps.profile !== null ? t('terminalDepsProfile', { profile: deps.profile }) : ''}
</div>
<div className={css.terminalDepsCommandRow}>
<pre className={css.terminalRepairCommand}>{deps.command}</pre>
<button type="button" className={css.terminalRetry} onClick={() => { void copy() }} aria-label={t('copy')}>
{copied ? t('copied') : t('copy')}
</button>
</div>
{deps.note !== undefined && <div className={css.terminalDepsNote}>{deps.note}</div>}
<div className={css.terminalDepsActions}>
<button type="button" className={css.terminalRetry} onClick={onRetry}>
{t('terminalRetry')}
</button>
</div>
</div>
)
}
+32 -4
View File
@@ -7,7 +7,11 @@
* so this component never fetches or dispatches — it only edits. * so this component never fetches or dispatches — it only edits.
* *
* The toolbar (mode toggle / dirty dot / save / status) renders as its own * The toolbar (mode toggle / dirty dot / save / status) renders as its own
* row below the host's title bar, VSCode-style. * row below the host's title bar, VSCode-style — unless the host passes
* `toolbar: 'host'` (the merged editor-explorer mode), in which case this
* component skips the row and reports state + registers commands through
* the FileViewerProps toolbar callbacks so the host's path-input header
* renders the controls instead.
*/ */
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
@@ -24,7 +28,7 @@ import { SandboxStatusBar } from './SandboxStatusBar.tsx'
import { appendToDraft } from './conversation-draft.ts' import { appendToDraft } from './conversation-draft.ts'
import { buildSelectionInsert, linesOfSelection } from './selection-payload.ts' import { buildSelectionInsert, linesOfSelection } from './selection-payload.ts'
import { t } from './locales.ts' import { t } from './locales.ts'
import type { FileViewerProps } from './service.ts' import type { EditorToolbarState, FileViewerProps } from './service.ts'
import css from './sidebar.module.css' import css from './sidebar.module.css'
/** Previewable files (rendered output vs source editing). */ /** Previewable files (rendered output vs source editing). */
@@ -141,11 +145,11 @@ export function TextEditor(props: FileViewerProps) {
...defaultKeymap, ...defaultKeymap,
...historyKeymap, ...historyKeymap,
]), ]),
// Selection popup (the catch-all code viewer only): a non-empty // Selection popup (the code and markdown editors): a non-empty
// selection anchors the floating "add to conversation" button above // selection anchors the floating "add to conversation" button above
// its head. Scrolling (geometry/viewport change) or losing focus // its head. Scrolling (geometry/viewport change) or losing focus
// hides it; typing collapses the selection and hides it too. // hides it; typing collapses the selection and hides it too.
...(viewerId === 'code' ? [ ...(viewerId === 'code' || viewerId === 'markdown' ? [
CodeMirrorView.updateListener.of((update) => { CodeMirrorView.updateListener.of((update) => {
if (update.geometryChanged || update.viewportChanged) { if (update.geometryChanged || update.viewportChanged) {
hidePopup() hidePopup()
@@ -278,8 +282,31 @@ export function TextEditor(props: FileViewerProps) {
const [localUnlock, setLocalUnlock] = useState(() => props.store?.getPrefs().htmlViewerDefaultUnsafe === true) const [localUnlock, setLocalUnlock] = useState(() => props.store?.getPrefs().htmlViewerDefaultUnsafe === true)
const htmlNoSandbox = props.store?.getPrefs().htmlViewerNoSandbox === true || localUnlock const htmlNoSandbox = props.store?.getPrefs().htmlViewerNoSandbox === true || localUnlock
// Host-toolbar mode (the merged editor header renders the controls): skip
// the own toolbar row, report the state after every relevant render (the
// JSON key guards redundant calls), and register the commands on mount.
const hostToolbar = props.toolbar === 'host'
const lastToolbarRef = useRef('')
useEffect(() => {
if (!hostToolbar) return
const state: EditorToolbarState = { modes: markdown || html, mode, dirty, editable, saveState }
const key = JSON.stringify(state)
if (lastToolbarRef.current === key) return
lastToolbarRef.current = key
props.onToolbarState?.(state)
})
useEffect(() => {
if (!hostToolbar) return
// `save` reads live refs only, and `setMode` is the stable state setter —
// registering this render's closures is safe for the mount's lifetime.
props.onToolbarControls?.({ setMode, save })
return () => { props.onToolbarControls?.(null) }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hostToolbar])
return ( return (
<> <>
{!hostToolbar && (
<div className={css.editorHeader}> <div className={css.editorHeader}>
{(markdown || html) && ( {(markdown || html) && (
<div className={css.editorModeToggle}> <div className={css.editorModeToggle}>
@@ -313,6 +340,7 @@ export function TextEditor(props: FileViewerProps) {
)} )}
{saveLabel !== '' && <span className={clsx(css.editorStatus, saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>} {saveLabel !== '' && <span className={clsx(css.editorStatus, saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>}
</div> </div>
)}
{editable && ( {editable && (
<> <>
{truncated === true && mode === 'edit' && <div className={css.editorBanner}>{t('truncation')}</div>} {truncated === true && mode === 'edit' && <div className={css.editorBanner}>{t('truncation')}</div>}
+121
View File
@@ -0,0 +1,121 @@
/**
* The files window's tree surface: a global file-name search box on top
* (300ms debounce; an in-flight search is aborted by the next keystroke)
* over either the shared controlled FileTree (empty query) or the flat
* result list (relative paths; click opens through the caller's mode-aware
* open). Owns its refresh tick: the icon next to the search input clears
* the tree cache. EditorHost docks it as the tab's right panel (wrapped in
* a drag-resize handle) and provides the file context-menu open escapes.
*/
import { useEffect, useState } from 'react'
import clsx from 'clsx'
import { IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { api } from './api.ts'
import { FileTree } from './FileTree.tsx'
import { t } from './locales.ts'
import { resolveSidebarPath } from './produced-files.ts'
import css from './sidebar.module.css'
export function TreePanel(props: {
sessionId: string
cwd: string | undefined
expanded: string[]
onToggle: (path: string) => void
onOpenFile: (path: string) => void
/** File context-menu "open in a new tab" (passed through to FileTree). */
onOpenFileNewTab?: (path: string) => void
/** File context-menu "open to the side" (passed through to FileTree). */
onOpenFileSide?: (path: string) => void
onReferenceFile: (path: string) => void
/** Full-window presentation: the panel fills its host instead of docking
* at a fixed width. */
full?: boolean
}) {
const { sessionId, cwd, expanded, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, onReferenceFile, full } = props
const [query, setQuery] = useState('')
const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null)
const [error, setError] = useState<string | null>(null)
const [refreshTick, setRefreshTick] = useState(0)
const needle = query.trim()
useEffect(() => {
if (needle === '') {
setResults(null)
setError(null)
return
}
const controller = new AbortController()
const timer = window.setTimeout(() => {
api.fsSearch({ sessionId, cwd }, needle, controller.signal).then((found) => {
setResults(found)
setError(null)
}).catch((failure: unknown) => {
if (controller.signal.aborted) return
setResults(null)
setError(failure instanceof Error ? failure.message : String(failure))
})
}, 300)
return () => {
window.clearTimeout(timer)
controller.abort()
}
}, [sessionId, cwd, needle])
return (
<div className={clsx(css.editorTreePanel, full === true && css.editorTreePanelFull)}>
<div className={css.editorTreeSearch}>
<input
className={css.editorSearchInput}
value={query}
placeholder={t('editorSearchPlaceholder')}
spellCheck={false}
onChange={(event) => { setQuery(event.target.value) }}
/>
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
onClick={() => { setRefreshTick(tick => tick + 1) }}
>
<IconRefreshOutline16 size={14} />
</button>
</div>
{needle === '' ? (
<FileTree
sessionId={sessionId}
cwd={cwd}
expanded={expanded}
onToggle={onToggle}
onOpenFile={onOpenFile}
onOpenFileNewTab={onOpenFileNewTab}
onOpenFileSide={onOpenFileSide}
onReferenceFile={onReferenceFile}
refreshTick={refreshTick}
/>
) : (
<div className={css.explorerBody}>
{error !== null && <div className={clsx(css.editorSearchHint, css.editorError)}>{error}</div>}
{error === null && results === null && <div className={css.editorSearchHint}>{t('loading')}</div>}
{error === null && results !== null && results.matches.length === 0 && (
<div className={css.editorSearchHint}>{t('editorSearchNoResults')}</div>
)}
{error === null && results !== null && results.matches.map(rel => (
<button
key={rel}
type="button"
className={css.editorSearchResult}
title={rel}
onClick={() => { onOpenFile(resolveSidebarPath(cwd, rel)) }}
>
{rel}
</button>
))}
{error === null && results?.truncated === true && (
<div className={css.editorSearchHint}>{t('editorSearchTruncated')}</div>
)}
</div>
)}
</div>
)
}
+37 -4
View File
@@ -25,6 +25,10 @@ export interface FsEntry {
path: string path: string
isDir: boolean isDir: boolean
hidden: boolean hidden: boolean
/** Whether the row is a symlink; `isDir` then describes the link's target. */
isSymlink: boolean
/** For symlinks: the target is missing or unreadable (stat failed). */
broken: boolean
} }
/** Git status entry (host git shape). */ /** Git status entry (host git shape). */
@@ -75,6 +79,21 @@ export interface JobOutputResult {
read: boolean read: boolean
} }
/** Terminal dependency status (mirror of the host's depsStatus; issue #140). */
export type TerminalDepsStatus =
| { ok: true }
| {
ok: false
/** The require-time error message (module missing, native binding broken…). */
cause: string
/** The pasteable repair command (terminal/cmd). */
command: string
/** The detected profile name (null when undetected → the command defaults to web). */
profile: string | null
/** Optional supplementary hint (fallback command only). */
note?: string
}
async function call<T>(method: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<T> { async function call<T>(method: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
let response: Response let response: Response
try { try {
@@ -116,6 +135,10 @@ export const api = {
call<{ sessionId: string; cwd: string; root: string; parent: string | null }>('session.cwd', scopePayload(scope, {}), signal), call<{ sessionId: string; cwd: string; root: string; parent: string | null }>('session.cwd', scopePayload(scope, {}), signal),
fsTree: (scope: SessionScope, path: string, signal?: AbortSignal) => fsTree: (scope: SessionScope, path: string, signal?: AbortSignal) =>
call<{ path: string; entries: FsEntry[]; truncated: boolean }>('fs.tree', scopePayload(scope, { path }), signal), call<{ path: string; entries: FsEntry[]; truncated: boolean }>('fs.tree', scopePayload(scope, { path }), signal),
/** Global recursive file-name search rooted at the session cwd (the editor
* side panel's search box); matches are cwd-relative '/'-separated paths. */
fsSearch: (scope: SessionScope, query: string, signal?: AbortSignal) =>
call<{ matches: string[]; truncated: boolean }>('fs.search', scopePayload(scope, { query }), signal),
fsRead: (scope: SessionScope, path: string, signal?: AbortSignal) => fsRead: (scope: SessionScope, path: string, signal?: AbortSignal) =>
call<FsTextResult | FsBinaryResult>('fs.read', scopePayload(scope, { path }), signal), call<FsTextResult | FsBinaryResult>('fs.read', scopePayload(scope, { path }), signal),
fsWrite: (scope: SessionScope, path: string, content: string) => fsWrite: (scope: SessionScope, path: string, content: string) =>
@@ -160,6 +183,11 @@ export const api = {
/** Release an agent terminal by uuid (tab closed while WS was down). */ /** Release an agent terminal by uuid (tab closed while WS was down). */
agentPtyClose: (uuid: string) => agentPtyClose: (uuid: string) =>
call<{ ok: true }>('agent-pty.close', { uuid }), call<{ ok: true }>('agent-pty.close', { uuid }),
/** Terminal dependency status (issue #140): after a WS close 1011 with
* reason `pty-deps-missing` the view fetches the full repair details here
* (the close reason itself is capped at 123 bytes). */
terminalDeps: () =>
call<TerminalDepsStatus>('terminal.deps', {}),
/** /**
* The output the model has read so far for one background job (replayed * The output the model has read so far for one background job (replayed
* from the owner session's event log — never the model's job_output * from the owner session's event log — never the model's job_output
@@ -175,7 +203,7 @@ export const api = {
})), })),
/** Read the side card preferences (plugin-global, no session scope). */ /** Read the side card preferences (plugin-global, no session scope). */
settingsGet: () => settingsGet: () =>
call<{ value?: unknown; revision?: number }>('settings.get', {}), call<{ value?: unknown; revision?: number; externalDisable?: boolean }>('settings.get', {}),
/** Merge a patch into the side card preferences (revision-guarded). */ /** Merge a patch into the side card preferences (revision-guarded). */
settingsUpdate: (patch: Record<string, unknown>, expectedRevision?: number) => settingsUpdate: (patch: Record<string, unknown>, expectedRevision?: number) =>
call<{ value?: unknown; revision?: number }>('settings.update', { call<{ value?: unknown; revision?: number }>('settings.update', {
@@ -207,9 +235,14 @@ function fileUrl(scope: SessionScope, path: string, download: boolean): string {
return `/sidebar/file?${params.toString()}` return `/sidebar/file?${params.toString()}`
} }
/** Absolute URL of the HTML preview route (see html-route.ts): the path is /**
* fully encoded so the previewed page's relative assets resolve back into * Absolute URL of the HTML preview route (see html-route.ts): the path is
* the same route with the session scope intact. */ * fully encoded so the previewed page's relative assets resolve back into
* the same route with the session scope intact. The UNC marker is
* platform-neutral — the host's requireAbsolute resolves the decoded
* forward-slash `//server/share/...` form on both win32 and POSIX — so no
* client-side platform signal is needed.
*/
export function htmlUrl(scope: SessionScope, path: string): string { export function htmlUrl(scope: SessionScope, path: string): string {
return encodeHtmlUrl(scope.sessionId, path) return encodeHtmlUrl(scope.sessionId, path)
} }
+44 -25
View File
@@ -1,17 +1,17 @@
/** /**
* The 7 built-in tab descriptors: the plugin registers its own pages * The 6 built-in tab descriptors: the plugin registers its own pages
* (explorer / git / terminal / browser / subagent / editor / diff) through * (editor / git / terminal / browser / subagent / diff) through
* the same {@link BetterSidebarService} external plugins use — eating its * the same {@link BetterSidebarService} external plugins use — eating its
* own dogfood. The terminal descriptor owns its quota (`TERMINAL_LIMIT`) * own dogfood. The terminal descriptor owns its quota (`TERMINAL_LIMIT`)
* and mints `terminal:<n>` ids through `createTab`; the browser mints * and mints `terminal:<n>` ids through `createTab`; the browser mints
* `browser:<n>` the same way (no quota). * `browser:<n>` the same way (no quota). The editor IS the files window
* (the old standalone explorer merged into it).
*/ */
import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconPanelLeftOutline16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../../context-types.ts' import type { Context } from '../../context-types.ts'
import { allLeaves, isAgentTabId, type SidebarState } from '../state.ts' import { allLeaves, isAgentTabId, type SidebarState } from '../state.ts'
import { t } from '../locales.ts' import { t } from '../locales.ts'
import { openSidebarFile } from '../intercept.tsx' import { openSidebarFile } from '../intercept.tsx'
import { ExplorerView } from '../ExplorerView.tsx'
import { EditorHost } from '../EditorHost.tsx' import { EditorHost } from '../EditorHost.tsx'
import { lazyChunkComponent } from '../lazy-chunk.tsx' import { lazyChunkComponent } from '../lazy-chunk.tsx'
import { GitView } from '../GitView.tsx' import { GitView } from '../GitView.tsx'
@@ -59,33 +59,52 @@ function uiTerminalCount(state: SidebarState): number {
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length .filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
} }
/** The 7 built-in tab descriptors. */ /** The 6 built-in tab descriptors. */
export function builtinTabs(ctx: Context): readonly TabDescriptor[] { export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
return [ return [
{ {
id: 'editor', id: 'editor',
title: () => t('editor'), // The single files window: an editor tab with no path IS the file
icon: (size: number) => <IconCodeOutline16 size={size} />, // explorer (empty hint + docked tree); with a path it previews/edits
order: -1, // the file. Visible in the + menu in the explorer's old slot.
hidden: true, title: () => t('files'),
dedupeKey: (tab) => tab.path,
component: ({ ctx, store, scope, tab }) => (
<EditorHost ctx={ctx} store={store} scope={scope} path={tab.path ?? ''} title={tab.title} />
),
},
{
id: 'explorer',
title: () => t('explorer'),
icon: (size: number) => <IconFolderOpen16 size={size} />, icon: (size: number) => <IconFolderOpen16 size={size} />,
order: 10, order: 10,
single: true, hidden: false,
component: ({ ctx, store, scope, expanded, onToggleDir, onReferenceFile }) => ( dedupeKey: (tab) => tab.path,
<ExplorerView // Declarative settings: the file-open behavior picker (in-place switch
sessionId={scope.sessionId} // vs per-path windows) renders as an iconed select row under the
cwd={scope.cwd} // editor card's gear in the Side card settings page.
settings: {
toggles: [{
key: 'editorExplorer',
type: 'select',
title: () => t('editorExplorer'),
desc: () => t('editorExplorerDesc'),
options: [
{
value: true,
icon: (size: number) => <IconPanelLeftOutline16 size={size} />,
title: () => t('editorExplorerMerged'),
desc: () => t('editorExplorerMergedDesc'),
},
{
value: false,
icon: (size: number) => <IconCodeOutline16 size={size} />,
title: () => t('editorExplorerSplit'),
desc: () => t('editorExplorerSplitDesc'),
},
],
}],
},
component: ({ ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile }) => (
<EditorHost
ctx={ctx}
store={store}
scope={scope}
tab={tab}
expanded={expanded ?? []} expanded={expanded ?? []}
onToggle={onToggleDir ?? (() => { /* no-op */ })} onToggleDir={onToggleDir ?? (() => { /* no-op */ })}
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })} onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })}
/> />
), ),
+55 -14
View File
@@ -20,7 +20,8 @@ import { RenderBoundary } from './RenderBoundary.tsx'
import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx' import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx'
import { registerLinkInterception } from './link-intercept.ts' import { registerLinkInterception } from './link-intercept.ts'
import { registerImeGuard } from './ime-guard.ts' import { registerImeGuard } from './ime-guard.ts'
import { loadPrefs } from './prefs.ts' import { registerSettingsNavIcon } from './settings-nav-icon.ts'
import { loadExternalDisable, loadPrefs } from './prefs.ts'
import { SideCardSection } from './SideCardSection.tsx' import { SideCardSection } from './SideCardSection.tsx'
import { api } from './api.ts' import { api } from './api.ts'
import { LOCALE_NS, attachLocale, t, zh, en } from './locales.ts' import { LOCALE_NS, attachLocale, t, zh, en } from './locales.ts'
@@ -97,7 +98,30 @@ export function apply(ctx: Context): void {
let disposed = false let disposed = false
let root: Root | undefined let root: Root | undefined
let host: HTMLDivElement | undefined let host: HTMLDivElement | undefined
void (async () => { let mounted = false
const unmount = (): void => {
if (!mounted) return
mounted = false
root?.unmount()
root = undefined
host?.remove()
host = undefined
}
const mount = (): void => {
if (mounted || disposed) return
try {
host = document.createElement('div')
host.setAttribute('data-dsh-better-sidebar', '')
document.body.appendChild(host)
root = createRoot(host)
root.render(createElement(RenderBoundary, { className: css.boundaryError }, createElement(Sidebar, { ctx, store: sidebarStore })))
mounted = true
} catch (error) {
fail('mount', error)
}
}
const sync = async (): Promise<void> => {
if (disposed) return
// Resolve the user's side card prefs BEFORE the first session seeds, // Resolve the user's side card prefs BEFORE the first session seeds,
// so a brand-new conversation opens (or stays closed) at the chosen // so a brand-new conversation opens (or stays closed) at the chosen
// width from first paint. A settings route failure falls back to the // width from first paint. A settings route failure falls back to the
@@ -109,20 +133,26 @@ export function apply(ctx: Context): void {
]) ])
if (prefs !== null) sidebarStore.setPrefs(prefs) if (prefs !== null) sidebarStore.setPrefs(prefs)
if (disposed) return if (disposed) return
try { // Mutual exclusion with the dsh-web-ui family right panel: while the
host = document.createElement('div') // aionui-panel provider is selected, the sidebar must not mount at
host.setAttribute('data-dsh-better-sidebar', '') // all. Re-evaluated on every settings-document update (live switch).
document.body.appendChild(host) const suspended = await loadExternalDisable(api)
root = createRoot(host) if (disposed) return
root.render(createElement(RenderBoundary, { className: css.boundaryError }, createElement(Sidebar, { ctx, store: sidebarStore }))) sidebarStore.setSuspended(suspended)
} catch (error) { if (suspended) unmount()
fail('mount', error) else mount()
} }
})() void sync()
// Live re-evaluation: the runtime broadcasts settings-document updates
// (the aionui card saves through the same document). Best effort —
// deployments without the 'remote' service fall back to boot-time
// evaluation only.
const remote = ctx.get('remote') as { $on?: (event: string, listener: () => void) => () => void } | undefined
const offRemote = remote?.$on?.('settings/document-updated', () => { void sync() })
return () => { return () => {
disposed = true disposed = true
root?.unmount() offRemote?.()
host?.remove() unmount()
} }
}, 'dsh-better-sidebar: sidebar mount') }, 'dsh-better-sidebar: sidebar mount')
@@ -167,6 +197,7 @@ export function apply(ctx: Context): void {
} }
return registerLinkInterception({ return registerLinkInterception({
takeoverEnabled: (url) => { takeoverEnabled: (url) => {
if (sidebarStore.getSuspended()) return false
const prefs = sidebarStore.getPrefs() const prefs = sidebarStore.getPrefs()
if (prefs.browserInterceptLinks === false) return false if (prefs.browserInterceptLinks === false) return false
const protocolOn = url.protocol === 'https:' const protocolOn = url.protocol === 'https:'
@@ -213,6 +244,16 @@ export function apply(ctx: Context): void {
'dsh-better-sidebar: IME composition guard', 'dsh-better-sidebar: IME composition guard',
) )
// DSH 0.1.x does not yet carry an icon through the settings.section
// registration contract: its shell renders a generic gear for every
// external section. Mark only this plugin's localized nav row so
// layout.css can paint the requested Side card SVG; the disposer clears
// the marker for HMR / plugin disable.
ctx.effect(
() => registerSettingsNavIcon(() => t('settingsNav')),
'dsh-better-sidebar: settings navigation icon',
)
// The "Side card" settings section: appears in the DSH Settings shell // The "Side card" settings section: appears in the DSH Settings shell
// once the shell's declaration is on the ledger (slots.inject waits for // once the shell's declaration is on the ledger (slots.inject waits for
// it); the section reads/writes the prefs through the plugin's own // it); the section reads/writes the prefs through the plugin's own
+5 -2
View File
@@ -76,8 +76,10 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
name: 'conversation.chat.turnTail', name: 'conversation.chat.turnTail',
// Decline the takeover while the editor tab type is disabled in the side // Decline the takeover while the editor tab type is disabled in the side
// card settings: the produced-files row falls back to the default // card settings: the produced-files row falls back to the default
// deliverables behavior instead of offering chips that cannot open. // deliverables behavior instead of offering chips that cannot open. Also
// while the sidebar is externally disabled (aionui-panel chosen).
select: (owner) => { select: (owner) => {
if (store.getSuspended()) return null
if (store.getPrefs().tabsEnabled['editor'] === false) return null if (store.getPrefs().tabsEnabled['editor'] === false) return null
return selectProducedFiles(owner) return selectProducedFiles(owner)
}, },
@@ -99,7 +101,8 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
*/ */
export function registerOpenPathInterception(ctx: Context, store: SidebarStore): () => void { export function registerOpenPathInterception(ctx: Context, store: SidebarStore): () => void {
return wrapOpenPath(ctx.workspaces, { return wrapOpenPath(ctx.workspaces, {
takeoverEnabled: () => store.getPrefs().interceptOpenPath !== false takeoverEnabled: () => !store.getSuspended()
&& store.getPrefs().interceptOpenPath !== false
&& store.getPrefs().tabsEnabled['editor'] !== false, && store.getPrefs().tabsEnabled['editor'] !== false,
currentSessionId: () => ctx.sessions.list.getSnapshot().current, currentSessionId: () => ctx.sessions.list.getSnapshot().current,
openInSidebar: (path, sessionId) => { openSidebarFile(ctx, store, sessionId, path) }, openInSidebar: (path, sessionId) => { openSidebarFile(ctx, store, sessionId, path) },
+8
View File
@@ -23,6 +23,8 @@ import { toml } from '@codemirror/legacy-modes/mode/toml'
import { nginx } from '@codemirror/legacy-modes/mode/nginx' import { nginx } from '@codemirror/legacy-modes/mode/nginx'
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile' import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile'
import { properties } from '@codemirror/legacy-modes/mode/properties' import { properties } from '@codemirror/legacy-modes/mode/properties'
import { csharp, kotlin } from '@codemirror/legacy-modes/mode/clike'
import { swift } from '@codemirror/legacy-modes/mode/swift'
/** The lowercased file extension of a path ('' when none). */ /** The lowercased file extension of a path ('' when none). */
export function extOf(path: string): string { export function extOf(path: string): string {
@@ -48,6 +50,9 @@ export function languageKeyForExt(ext: string): string | null {
case 'yaml': case 'yml': return 'yaml' case 'yaml': case 'yml': return 'yaml'
case 'sql': return 'sql' case 'sql': return 'sql'
case 'java': return 'java' case 'java': return 'java'
case 'cs': return 'csharp'
case 'kt': case 'kts': return 'kotlin'
case 'swift': return 'swift'
case 'c': case 'h': return 'c' case 'c': case 'h': return 'c'
case 'cc': case 'cpp': case 'cxx': case 'hpp': case 'hh': case 'hxx': return 'cpp' case 'cc': case 'cpp': case 'cxx': case 'hpp': case 'hh': case 'hxx': return 'cpp'
case 'rs': return 'rust' case 'rs': return 'rust'
@@ -76,6 +81,9 @@ const FACTORIES: Record<string, () => Language | LanguageSupport> = {
yaml: () => yaml(), yaml: () => yaml(),
sql: () => sql(), sql: () => sql(),
java: () => java(), java: () => java(),
csharp: () => StreamLanguage.define(csharp),
kotlin: () => StreamLanguage.define(kotlin),
swift: () => StreamLanguage.define(swift),
c: () => cpp(), c: () => cpp(),
cpp: () => cpp(), cpp: () => cpp(),
rust: () => rust(), rust: () => rust(),
+19
View File
@@ -51,6 +51,25 @@ body[data-dsh-sidebar-dragging] #root > div[data-slot="root"] > div > div:nth-ch
transition: none; transition: none;
} }
/* DSH 0.1.x gives external settings sections a generic gear and exposes no
icon field in the settings.section contract. settings-nav-icon.ts marks
only this plugin's localized row; render the requested Lucide
gallery-horizontal-end SVG as a currentColor mask so it follows the native
nav hover/active colors without changing the shell's 16px icon rhythm. */
[data-dsh-better-sidebar-settings-nav] > svg:first-child {
display: none;
}
[data-dsh-better-sidebar-settings-nav]::before {
content: '';
flex: none;
width: 16px;
height: 16px;
background: currentColor;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 7v10'/%3E%3Cpath d='M6 5v14'/%3E%3Crect width='12' height='18' x='10' y='3' rx='2'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 7v10'/%3E%3Cpath d='M6 5v14'/%3E%3Crect width='12' height='18' x='10' y='3' rx='2'/%3E%3C/svg%3E") center / contain no-repeat;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
#root, #root,
#root > div[data-slot="root"] > div > div:nth-child(2) { #root > div[data-slot="root"] > div > div:nth-child(2) {
+42
View File
@@ -11,12 +11,28 @@
/** The zh dictionary (also registered into the DSH locale registry under {@link LOCALE_NS}). */ /** The zh dictionary (also registered into the DSH locale registry under {@link LOCALE_NS}). */
export const zh = { export const zh = {
files: '文件',
explorer: '资源管理器', explorer: '资源管理器',
git: '源代码管理', git: '源代码管理',
terminal: '终端', terminal: '终端',
editor: '编辑器', editor: '编辑器',
editorExplorer: '文件打开方式',
editorExplorerDesc: '控制文件打开方式',
editorExplorerMerged: '合并',
editorExplorerMergedDesc: '文件在同一窗口内原地切换;新窗口默认展开文件树',
editorExplorerSplit: '独立',
editorExplorerSplitDesc: '无路径窗口即资源管理器(仅文件树);文件各自新开窗口(带文件树,默认收起)',
editorTreeToggle: '文件树面板',
editorPathPlaceholder: '输入文件路径(相对会话目录或绝对路径),Enter 打开',
editorSearchPlaceholder: '按文件名搜索…',
editorSearchNoResults: '无匹配文件',
editorSearchTruncated: '结果过多,仅显示部分匹配',
editorEmptyHint: '从右侧文件树或上方路径输入框选择文件开始预览',
openFileNewTab: '在新 Tab 中打开',
openFileSide: '在侧边打开',
newTab: '新建标签页', newTab: '新建标签页',
openExplorer: '资源管理器', openExplorer: '资源管理器',
brokenSymlink: '失效的软链接',
openGit: 'Git 面板', openGit: 'Git 面板',
newTerminal: '新终端', newTerminal: '新终端',
terminalLimit: '终端数量已达上限 (3)', terminalLimit: '终端数量已达上限 (3)',
@@ -28,6 +44,9 @@ export const zh = {
terminalError: '终端连接失败', terminalError: '终端连接失败',
terminalConnectFailed: '终端多次连接失败', terminalConnectFailed: '终端多次连接失败',
terminalRetry: '重试', terminalRetry: '重试',
terminalDepsFailed: '终端依赖 node-pty 加载失败',
terminalDepsHint: '在 DSH 所在环境的终端或 cmd 中执行以下命令修复,然后点重试(node-pty 与 DSH 核心保持同一版本):',
terminalDepsProfile: '(检测到 profile:{profile})',
preview: '预览', preview: '预览',
edit: '编辑', edit: '编辑',
refresh: '刷新', refresh: '刷新',
@@ -226,18 +245,36 @@ export const zh = {
openPlugin: '跳转', openPlugin: '跳转',
copyInstall: '复制安装命令', copyInstall: '复制安装命令',
pluginOfficeDesc: '为 better-sidebar 编辑器提供 Office 三件套预览(.docx / .xlsx / .pptx),把重型 Office 渲染库拆出主包、按需安装', pluginOfficeDesc: '为 better-sidebar 编辑器提供 Office 三件套预览(.docx / .xlsx / .pptx),把重型 Office 渲染库拆出主包、按需安装',
pluginGitRemotesDesc: 'better-sidebar Git 远程 Tab:看分支/上游/ahead-behind,fetch(可 prune)、ff-only pull、确认后才 push。不替换内置 Git 的暂存/提交,也不提供 force-push 或模型自动推送',
pluginSentinelDesc: '条件驱动的 agent 唤醒系统:文件/进程/端口/HTTP/命令/webhook 传感器,条件达成自动唤醒休眠会话;注册「哨兵」Tab 展示服务器全局监控表', pluginSentinelDesc: '条件驱动的 agent 唤醒系统:文件/进程/端口/HTTP/命令/webhook 传感器,条件达成自动唤醒休眠会话;注册「哨兵」Tab 展示服务器全局监控表',
pluginSidebarQaDesc: '基于 better-sidebar 的划选提问tab分页: 对话划选 → 右侧面板提问 → 同工作区独立追问会话(❓追问·主题):快速无思考模型压缩主对话上下文后与引文一起注入,不打断主对话;追问可嵌套、可继续、可归档', pluginSidebarQaDesc: '基于 better-sidebar 的划选提问tab分页: 对话划选 → 右侧面板提问 → 同工作区独立追问会话(❓追问·主题):快速无思考模型压缩主对话上下文后与引文一起注入,不打断主对话;追问可嵌套、可继续、可归档',
pluginVideoPreviewDesc: '在 better-sidebar 编辑器内联预览视频文件(.mp4/.webm/.mov/.mkv/.avi 等),自带支持 HTTP Range(206)的 /video 宿主路由,可拖动进度条、不受 20MB mediaLimit 限制',
} }
/** The en dictionary (key-set-equal to zh, enforced by the type annotation). */ /** The en dictionary (key-set-equal to zh, enforced by the type annotation). */
export const en: Record<keyof typeof zh, string> = { export const en: Record<keyof typeof zh, string> = {
files: 'Files',
explorer: 'Explorer', explorer: 'Explorer',
git: 'Source Control', git: 'Source Control',
terminal: 'Terminal', terminal: 'Terminal',
editor: 'Editor', editor: 'Editor',
editorExplorer: 'File open behavior',
editorExplorerDesc: 'Controls how files open',
editorExplorerMerged: 'Merged',
editorExplorerMergedDesc: 'Files switch in place in the same window; new windows start with the tree open',
editorExplorerSplit: 'Separate',
editorExplorerSplitDesc: 'Path-less windows are the standalone explorer (tree only); each file opens its own window (tree docked, closed by default)',
editorTreeToggle: 'File tree panel',
editorPathPlaceholder: 'File path (relative to the session directory or absolute), Enter to open',
editorSearchPlaceholder: 'Search files by name…',
editorSearchNoResults: 'No matching files',
editorSearchTruncated: 'Too many results — showing a partial list',
editorEmptyHint: 'Pick a file from the tree panel or the path input above to start previewing',
openFileNewTab: 'Open in New Tab',
openFileSide: 'Open to the Side',
newTab: 'New tab', newTab: 'New tab',
openExplorer: 'Explorer', openExplorer: 'Explorer',
brokenSymlink: 'Broken symlink',
openGit: 'Git panel', openGit: 'Git panel',
newTerminal: 'New terminal', newTerminal: 'New terminal',
terminalLimit: 'Terminal limit reached (3)', terminalLimit: 'Terminal limit reached (3)',
@@ -249,6 +286,9 @@ export const en: Record<keyof typeof zh, string> = {
terminalError: 'Terminal connection failed', terminalError: 'Terminal connection failed',
terminalConnectFailed: 'Terminal failed to connect repeatedly', terminalConnectFailed: 'Terminal failed to connect repeatedly',
terminalRetry: 'Retry', terminalRetry: 'Retry',
terminalDepsFailed: 'Terminal dependency node-pty failed to load',
terminalDepsHint: 'Run the command below in a terminal or cmd on the DSH machine to repair it, then retry (node-pty stays in sync with the DSH core version):',
terminalDepsProfile: ' (detected profile: {profile})',
preview: 'Preview', preview: 'Preview',
edit: 'Edit', edit: 'Edit',
refresh: 'Refresh', refresh: 'Refresh',
@@ -447,8 +487,10 @@ export const en: Record<keyof typeof zh, string> = {
openPlugin: 'Open', openPlugin: 'Open',
copyInstall: 'Copy install command', copyInstall: 'Copy install command',
pluginOfficeDesc: 'Office-suite preview (.docx / .xlsx / .pptx) for the better-sidebar editor, keeping the heavy Office render libraries out of the core bundle', pluginOfficeDesc: 'Office-suite preview (.docx / .xlsx / .pptx) for the better-sidebar editor, keeping the heavy Office render libraries out of the core bundle',
pluginGitRemotesDesc: 'Git Remotes tab: branch/upstream/ahead-behind, fetch (optional prune), ff-only pull, and push only after an in-tab confirm. Does not replace the built-in Git stage/commit tab, and does not offer force-push or a model auto-push tool',
pluginSentinelDesc: 'Condition-driven agent wakeup: file/process/port/http/command/webhook sensors wake dormant sessions when conditions fire; registers a "Sentinel" tab with the server-wide watch table', pluginSentinelDesc: 'Condition-driven agent wakeup: file/process/port/http/command/webhook sensors wake dormant sessions when conditions fire; registers a "Sentinel" tab with the server-wide watch table',
pluginSidebarQaDesc: 'Select-and-ask: Select conversation text → ask in the right-side panel → a dedicated follow-up session (❓追问) in the same workspace; a fast no-thinking model compresses the main context and injects it with the quote, without interrupting the main conversation. Follow-ups nest, continue, and archive', pluginSidebarQaDesc: 'Select-and-ask: Select conversation text → ask in the right-side panel → a dedicated follow-up session (❓追问) in the same workspace; a fast no-thinking model compresses the main context and injects it with the quote, without interrupting the main conversation. Follow-ups nest, continue, and archive',
pluginVideoPreviewDesc: 'Inline video preview (.mp4/.webm/.mov/.mkv/.avi etc.) for the better-sidebar editor, backed by a dedicated /video host route with HTTP Range (206) support — scrubbing works and files are not capped by the 20MB mediaLimit',
} }
/** /**
+19
View File
@@ -3,8 +3,27 @@
* the session cwd (for the @-reference button and "copy relative path"). * the session cwd (for the @-reference button and "copy relative path").
* The fs-tree joins with '/' even on Windows, so both separators normalize * The fs-tree joins with '/' even on Windows, so both separators normalize
* to '/' before comparison. * to '/' before comparison.
*
* This module is dependency-free (no node:path in the client bundle): the
* host is the authority for path semantics, so this mirror deliberately
* accepts a SUPERSET of absolute forms — anything a Windows host would emit
* (drive letters, UNC) plus POSIX roots. A form the host would reject
* (e.g. a backslash UNC path on a POSIX host) passes through here and then
* fails loudly in the host's requireAbsolute instead of being silently
* joined onto the cwd.
*/ */
/**
* Mirror of the host's absolute-path notion (see fs-tree.requireAbsolute):
* POSIX roots, Windows drive letters, and Windows UNC network shares in
* both backslash (`\\server\share\...`) and forward-slash
* (`//server/share/...`) form. Deliberately a superset — see the module
* comment — so a produced UNC path is never joined onto the cwd.
*/
export function isAbsolutePath(path: string): boolean {
return path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path) || /^[\\/]{2}[^\\/]/.test(path)
}
/** /**
* The path relative to the session's working directory. * The path relative to the session's working directory.
* @param cwd - the explorer root (absolute). * @param cwd - the explorer root (absolute).
+7
View File
@@ -24,6 +24,13 @@ export const builtinTabPlugins: readonly PluginEntry[] = [
// context consistent with the other entries. // context consistent with the other entries.
install: 'cd ~/.dsh && dsh plugin --profile web add "github:fuhefei/dsh-sentinel#v0.7.0"', install: 'cd ~/.dsh && dsh plugin --profile web add "github:fuhefei/dsh-sentinel#v0.7.0"',
}, },
{
id: 'dsh-git-remotes',
name: 'dsh-git-remotes Git 远程',
url: 'https://github.com/yq04/dsh-git-remotes',
description: () => t('pluginGitRemotesDesc'),
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-better-sidebar && dsh plugin --profile web add git+https://github.com/yq04/dsh-git-remotes.git',
},
{ {
id: 'dsh-sidebar-qa', id: 'dsh-sidebar-qa',
name: 'dsh-sidebar-qa 划选追问', name: 'dsh-sidebar-qa 划选追问',
+7
View File
@@ -20,4 +20,11 @@ export const builtinViewerPlugins: readonly PluginEntry[] = [
description: () => t('pluginOfficeDesc'), description: () => t('pluginOfficeDesc'),
install: 'cd ~/.dsh && dsh plugin --profile web add @huanlin/dsh-plugin-better-sidebar-plugin-office', install: 'cd ~/.dsh && dsh plugin --profile web add @huanlin/dsh-plugin-better-sidebar-plugin-office',
}, },
{
id: 'dsh-video-preview',
name: '视频预览插件',
url: 'https://github.com/zemul/dsh-video-preview',
description: () => t('pluginVideoPreviewDesc'),
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-video-preview',
},
] ]
+22
View File
@@ -60,6 +60,9 @@ export function parsePrefs(value: unknown): SidebarPrefs {
interceptOpenPath: typeof record.interceptOpenPath === 'boolean' interceptOpenPath: typeof record.interceptOpenPath === 'boolean'
? record.interceptOpenPath ? record.interceptOpenPath
: SIDEBAR_PREFS_DEFAULTS.interceptOpenPath, : SIDEBAR_PREFS_DEFAULTS.interceptOpenPath,
editorExplorer: typeof record.editorExplorer === 'boolean'
? record.editorExplorer
: SIDEBAR_PREFS_DEFAULTS.editorExplorer,
titleBarCompat: typeof record.titleBarCompat === 'boolean' titleBarCompat: typeof record.titleBarCompat === 'boolean'
? record.titleBarCompat ? record.titleBarCompat
: SIDEBAR_PREFS_DEFAULTS.titleBarCompat, : SIDEBAR_PREFS_DEFAULTS.titleBarCompat,
@@ -136,3 +139,22 @@ export async function loadPrefs(settings: SidebarSettingsClient): Promise<Sideba
return { ...SIDEBAR_PREFS_DEFAULTS } return { ...SIDEBAR_PREFS_DEFAULTS }
} }
} }
/**
* Read the external-disable flag from the same settings route: the
* dsh-web-ui family's aionui-panel provider choice. True only when the host
* resolved `aionui-panel.rightPanel` to 'aionui-panel' — while true the
* sidebar must not mount (the two right panels are mutually exclusive). Any
* failure (route rejected, aionui absent, malformed response) reads false,
* so a missing family never hides the sidebar.
* @param settings - the settings wire face (the plugin api by default).
* @returns the external-disable flag (false on any failure).
*/
export async function loadExternalDisable(settings: SidebarSettingsClient): Promise<boolean> {
try {
const view = await settings.settingsGet()
return view.externalDisable === true
} catch {
return false
}
}
+7 -3
View File
@@ -6,6 +6,7 @@
* Kept dependency-free so the takeover logic is unit-testable and the * Kept dependency-free so the takeover logic is unit-testable and the
* replica is easy to diff against upstream when it drifts. * replica is easy to diff against upstream when it drifts.
*/ */
import { isAbsolutePath } from './paths.ts'
/** Paths a tool-result view reports as produced, by render intent. */ /** Paths a tool-result view reports as produced, by render intent. */
export function producedPaths(view: unknown): readonly string[] { export function producedPaths(view: unknown): readonly string[] {
@@ -76,10 +77,13 @@ export function selectProducedFiles(owner: unknown): readonly string[] | null {
return paths.length === 0 ? null : paths return paths.length === 0 ? null : paths
} }
/** Resolve a (possibly relative) path against the session cwd for the sidebar. */ /**
* Resolve a (possibly relative) path against the session cwd for the sidebar.
* Absolute detection mirrors the host (see client/paths.isAbsolutePath):
* POSIX roots, drive letters and UNC shares must not be joined onto the cwd.
*/
export function resolveSidebarPath(cwd: string | undefined, path: string): string { export function resolveSidebarPath(cwd: string | undefined, path: string): string {
const absolute = path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path) if (isAbsolutePath(path)) return path
if (absolute) return path
const base = cwd ?? '' const base = cwd ?? ''
if (base === '') return path if (base === '') return path
const separator = base.includes('\\') ? '\\' : '/' const separator = base.includes('\\') ? '\\' : '/'
+55 -3
View File
@@ -48,14 +48,32 @@ export type { SessionScope } from './api.ts'
export type { SidebarPrefs } from '../prefs-shared.ts' export type { SidebarPrefs } from '../prefs-shared.ts'
/** The row control a declarative setting renders as in the settings popup. */ /** The row control a declarative setting renders as in the settings popup. */
export type SidebarSettingToggleType = 'switch' | 'text' | 'number' export type SidebarSettingToggleType = 'switch' | 'text' | 'number' | 'select'
/** One option of a `type: 'select'` setting row. */
export interface SidebarSettingSelectOption {
/** The value written to the setting key when this option is picked
* (JSON-serializable: string / number / boolean). */
value: string | number | boolean
/** Option title (i18n friendly: string or () => string). */
title: string | (() => string)
/** Option description (i18n friendly); rendered under the title in the
* icon dropdown. */
desc?: string | (() => string)
/** Option icon: when ANY option declares one, the dropdown renders
* big-icon option cards and the closed control shows the selected
* option's icon too; without icons both are a single line of text. */
icon?: ReactNode | ((size: number) => ReactNode)
}
/** One declarative setting of a tab/viewer, rendered as a nested row in the /** One declarative setting of a tab/viewer, rendered as a nested row in the
* Side card settings page (e.g. the Subagent page's "auto-open when a * Side card settings page (e.g. the Subagent page's "auto-open when a
* subagent appears" switch, or the terminal's custom font rows). `type` * subagent appears" switch, or the terminal's custom font rows). `type`
* selects the control: 'switch' (default) renders the custom switch, * selects the control: 'switch' (default) renders the custom switch,
* 'text' a free-form input committed on blur/Enter, 'number' a numeric * 'text' a free-form input committed on blur/Enter, 'number' a numeric
* input clamped to `min`/`max`. */ * input clamped to `min`/`max`, 'select' a dropdown over the declared
* `options` (single-pick writes the option's value; `multi: true` writes
* the array of picked values and defaults to false). */
export interface SidebarSettingToggle { export interface SidebarSettingToggle {
/** The SidebarPrefs field this toggle reads and writes ('autoOpenSubagent'). */ /** The SidebarPrefs field this toggle reads and writes ('autoOpenSubagent'). */
key: string key: string
@@ -73,6 +91,11 @@ export interface SidebarSettingToggle {
placeholder?: string placeholder?: string
/** Unit suffix rendered after the input (e.g. 'px' for a size row). */ /** Unit suffix rendered after the input (e.g. 'px' for a size row). */
unit?: string unit?: string
/** Options of a `type: 'select'` row. */
options?: readonly SidebarSettingSelectOption[]
/** Whether a `type: 'select'` row allows picking several options (the
* stored value is then an array of option values); defaults to false. */
multi?: boolean
} }
/** Props of a descriptor's custom settings panel (`settings.render`). */ /** Props of a descriptor's custom settings panel (`settings.render`). */
@@ -240,6 +263,33 @@ export interface FileViewerProps {
mediaUrl?: string mediaUrl?: string
/** custom load() return value (fetchStrategy='custom'). */ /** custom load() return value (fetchStrategy='custom'). */
customData?: unknown customData?: unknown
/** Internal (built-in text editor): 'host' asks the viewer to skip its own
* toolbar row — the editor host's merged-mode header renders it instead,
* fed through the two callbacks below. Viewers that ignore these fields
* render exactly as before. */
toolbar?: 'self' | 'host'
/** Internal: the viewer reports its toolbar state (mode/dirty/save). */
onToolbarState?: (state: EditorToolbarState) => void
/** Internal: the viewer registers its toolbar commands on mount (null on
* unmount). */
onToolbarControls?: (controls: EditorToolbarControls | null) => void
}
/** The toolbar state a text editor reports to the host's merged-mode header. */
export interface EditorToolbarState {
/** Whether the preview/edit mode toggle applies (markdown/html). */
modes: boolean
mode: 'preview' | 'edit'
dirty: boolean
/** Whether saving applies (text content loaded). */
editable: boolean
saveState: 'idle' | 'saving' | 'saved' | 'failed'
}
/** The commands the host's merged-mode header sends back to the viewer. */
export interface EditorToolbarControls {
setMode(mode: 'preview' | 'edit'): void
save(): void
} }
/** Describes one file previewer (builtins register themselves too). */ /** Describes one file previewer (builtins register themselves too). */
@@ -420,7 +470,7 @@ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDes
* The plugin version this service instance reports. Keep in lockstep with * The plugin version this service instance reports. Keep in lockstep with
* `package.json`'s version — `tests/service.spec.ts` asserts the pair. * `package.json`'s version — `tests/service.spec.ts` asserts the pair.
*/ */
export const SIDEBAR_SERVICE_VERSION = '0.12.2' export const SIDEBAR_SERVICE_VERSION = '0.13.0'
/** /**
* Monotonic capability list consumers use to gate new API usage (features * Monotonic capability list consumers use to gate new API usage (features
@@ -434,6 +484,7 @@ export const SIDEBAR_SERVICE_VERSION = '0.12.2'
* - 'tabMeta': SidebarTab.meta (seeds, createTab, updateTab, persistence) * - 'tabMeta': SidebarTab.meta (seeds, createTab, updateTab, persistence)
* - 'pluginSettings': SidebarSettingsDeclaration.pluginToggles/render * - 'pluginSettings': SidebarSettingsDeclaration.pluginToggles/render
* - 'urlTarget' (v0.13.0): TabDescriptor.urlTarget (external-link claims) * - 'urlTarget' (v0.13.0): TabDescriptor.urlTarget (external-link claims)
* - 'settingSelect': SidebarSettingToggle type 'select' (options/multi)
*/ */
export const SIDEBAR_FEATURES = [ export const SIDEBAR_FEATURES = [
'badge', 'badge',
@@ -445,6 +496,7 @@ export const SIDEBAR_FEATURES = [
'tabMeta', 'tabMeta',
'pluginSettings', 'pluginSettings',
'urlTarget', 'urlTarget',
'settingSelect',
] as const ] as const
/** Run one plugin callback; a throw is logged and never breaks the caller. */ /** Run one plugin callback; a throw is logged and never breaks the caller. */
+45
View File
@@ -0,0 +1,45 @@
/**
* Mark this plugin's row in the DSH settings navigation so its bundled CSS
* can replace the shell's fallback gear with the Side card glyph.
*
* DSH 0.1.x projects only `id`, `order`, and `label` from a
* `settings.section` registration, then chooses icons inside the settings
* shell from a closed list of built-in ids. Until that public contract grows
* an icon field, the plugin identifies only its own localized row after the
* dialog mounts. The marker owns no shell structure and is removed on fiber
* disposal, so the adaptation remains HMR-safe.
*/
export const SETTINGS_NAV_MARKER = 'data-dsh-better-sidebar-settings-nav'
/**
* Keep the marker on the settings-nav button whose visible text is this
* plugin's current localized section label.
* @param label - locale-aware label resolver used by the section registration.
* @returns disposer that disconnects observation and removes owned markers.
*/
export function registerSettingsNavIcon(label: () => string): () => void {
let disposed = false
const sync = (): void => {
if (disposed) return
const currentLabel = label().trim()
const buttons = document.querySelectorAll<HTMLButtonElement>('[role="dialog"] nav button')
for (const button of buttons) {
const matches = currentLabel.length > 0 && button.textContent?.trim() === currentLabel
if (matches) button.setAttribute(SETTINGS_NAV_MARKER, '')
else button.removeAttribute(SETTINGS_NAV_MARKER)
}
}
sync()
const observer = new MutationObserver(sync)
observer.observe(document.body, { childList: true, subtree: true, characterData: true })
return () => {
disposed = true
observer.disconnect()
document.querySelectorAll(`[${SETTINGS_NAV_MARKER}]`)
.forEach(element => { element.removeAttribute(SETTINGS_NAV_MARKER) })
}
}
+257 -13
View File
@@ -10,16 +10,29 @@
/* ── Shell ─────────────────────────────────────────────────────────────── */ /* ── Shell ─────────────────────────────────────────────────────────────── */
/* Skinning contract (token-driven): every surface here consumes DSH's
generic tokens (`--dsw-alias-*`), NEVER `--dsw-specific-sidebar-fill` —
skin systems override that token to restyle the APP's own left-nav column
(dsh-web-ui skins make it translucent glass or theme colors, Aqua makes it
transparent), and a panel consuming it would lose its fill or clash with
the label tokens. The panels use the generic card surface
`--dsw-alias-bg-layer-1`, which every dsh-web-ui skin overrides — so
switching skins in the dsh-web-ui skin center re-skins the sidebar panels
automatically (see AGENTS.md §8). */
/* The persistent expand/collapse cluster at the top-right corner: two rail /* 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). 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 Always pinned to the viewport corner — inside the right panel's top-right
while it is open, sitting flush in the 34px tab strip (28px buttons at while it is open, sitting flush in the 34px tab strip (28px buttons at
top: 3) whose right end it really squeezes. */ 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). */
.toggleCluster { .toggleCluster {
position: fixed; position: fixed;
top: 3px; top: 3px;
right: 10px; right: 10px;
z-index: 55; z-index: 45;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 4px; gap: 4px;
@@ -70,12 +83,18 @@
/* Full height: the bottom panel squeezes only the center column, so the /* Full height: the bottom panel squeezes only the center column, so the
right panel never gives up any vertical position. */ right panel never gives up any vertical position. */
bottom: 0; bottom: 0;
z-index: 50; /* z-index 40: above the app's in-flow content, below the app's overlay
stack (100+), so menus/tooltips/modals always win over the panels
(issue #52). The toggle cluster (45) sits above it; the corner handle
is a child and stacks within the panel. */
z-index: 40;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
/* The native sidebar surface: bluish-50 over white / bluish-900 over dark, /* The generic card surface (see the root contract above). Never
exactly like the app's own sidebar column (SidebarRoot .root). */ `--dsw-specific-sidebar-fill` — skin plugins restyle that token for the
background: var(--dsw-specific-sidebar-fill); 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); border-left: 1px solid var(--dsw-alias-border-l2);
/* Expand/collapse slides the panel; fullscreen animates the width. */ /* Expand/collapse slides the panel; fullscreen animates the width. */
transition: transition:
@@ -101,6 +120,8 @@
.panelResize { .panelResize {
position: absolute; position: absolute;
/* Centered on the panel edge (an 8px hit strip straddling the border, the
same pattern as the host's own frame handles). */
left: -4px; left: -4px;
top: 0; top: 0;
bottom: 0; bottom: 0;
@@ -127,14 +148,16 @@
column only (the Sidebar shell writes left/right inline: from the app's column only (the Sidebar shell writes left/right inline: from the app's
own left sidebar to the right panel's left edge) — the sidebars never own left sidebar to the right panel's left edge) — the sidebars never
give up any position. Same surface as the right panel, with a top give up any position. Same surface as the right panel, with a top
hairline instead of the left one. */ hairline instead of the left one. z-index 40 mirrors the right panel
(issue #52: below the app's overlay stack so popups win over the
workbench). */
.bottomPanel { .bottomPanel {
position: fixed; position: fixed;
bottom: 0; bottom: 0;
z-index: 50; z-index: 40;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--dsw-specific-sidebar-fill); background: var(--dsw-alias-bg-layer-1);
border-top: 1px solid var(--dsw-alias-border-l2); border-top: 1px solid var(--dsw-alias-border-l2);
/* Expand/collapse slides the panel; height drags animate the height. */ /* Expand/collapse slides the panel; height drags animate the height. */
transition: transition:
@@ -159,7 +182,7 @@
} }
/* The bottom panel's height drag: the top edge strip (mirror of the right /* The bottom panel's height drag: the top edge strip (mirror of the right
panel's width strip). */ panel's width strip), centered on the border like the width strip. */
.bottomResize { .bottomResize {
position: absolute; position: absolute;
left: 0; left: 0;
@@ -235,12 +258,18 @@
/* The shared corner (both panels open): the intersection of the right /* The shared corner (both panels open): the intersection of the right
panel's left edge and the bottom panel's top edge. Horizontal drags panel's left edge and the bottom panel's top edge. Horizontal drags
resize the width, vertical drags the height — the two panels drag resize the width, vertical drags the height — the two panels drag
against each other. */ against each other. Rendered INSIDE the right panel and positioned
relative to it (left edge + the bottom panel's height from the layout
variable), so no JS-written viewport coordinates are involved. The
z-index is panel-local (above the panel's content; the panel itself sits
at 40, below the app's overlay stack). */
.cornerHandle { .cornerHandle {
position: fixed; position: absolute;
left: -6px;
bottom: calc(var(--dsh-sidebar-height, 0px) + 6px);
width: 12px; width: 12px;
height: 12px; height: 12px;
z-index: 52; z-index: 2;
cursor: nwse-resize; cursor: nwse-resize;
touch-action: none; touch-action: none;
} }
@@ -637,6 +666,8 @@
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
/* Deep trees truncate their labels instead of scrolling horizontally. */
overflow-x: hidden;
padding: 2px 6px 8px; padding: 2px 6px 8px;
} }
@@ -648,7 +679,12 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
/* border-box keeps the inline indent padding (depth * 22 + 6) INSIDE the
row's 100% width — without it deep rows overflow and force a horizontal
scrollbar on the tree body. */
box-sizing: border-box;
width: 100%; width: 100%;
max-width: 100%;
height: 34px; height: 34px;
padding: 0 8px; padding: 0 8px;
border: none; border: none;
@@ -674,9 +710,25 @@
opacity: 0.45; opacity: 0.45;
} }
/* The link badge on symlink rows (folder and file rows alike). */
.explorerSymlink {
flex: none;
color: var(--dsw-alias-label-tertiary);
}
/* A symlink whose target is missing: error ink on the name (the row stays
clickable — the editor surfaces the read failure). */
.explorerBroken .explorerName {
color: var(--dsw-alias-state-error-primary);
}
.explorerName { .explorerName {
/* min-width: 0 lets the flex item shrink below its content so the label
truncates with an ellipsis instead of widening the row. */
min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap;
} }
/* The hover-revealed @-reference button at the row's far right (and the /* The hover-revealed @-reference button at the row's far right (and the
@@ -758,6 +810,139 @@
white-space: nowrap; white-space: nowrap;
} }
/* Merged mode (the editorExplorer pref): the header's path input replaces
the plain title, and a toggleable file tree docks at the tab's right edge. */
.editorPathInput {
flex: 1;
min-width: 0;
height: 28px;
padding: 0 10px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 6px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-12);
}
.editorPathInput:focus {
outline: none;
border-color: var(--dsw-alias-border-l2);
}
/* The tree-panel toggle in the header: active = panel open (the selected
fill of the tab strip). */
.editorTreeToggleActive {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-active);
}
.editorBody {
flex: 1;
min-height: 0;
display: flex;
}
.editorMain {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
/* The docked file tree: the wrapper owns the (drag-adjustable) width and
the hairline separation; the resize handle is a wider invisible strip on
its left edge with a hover affordance. */
.editorTreeDock {
position: relative;
flex: none;
min-height: 0;
display: flex;
border-left: 1px solid var(--dsw-alias-border-l1);
}
.editorTreeResize {
position: absolute;
top: 0;
bottom: 0;
/* Inside the dock (left edge): pointer capture on the handle keeps the
drag alive even when the pointer leaves the strip. */
left: 0;
width: 6px;
cursor: col-resize;
touch-action: none;
z-index: 3;
}
.editorTreeResize:hover {
background: var(--dsw-alias-border-l2);
}
.editorTreePanel {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
/* The tree panel's full-window form (the `full` prop): fill the tab body. */
.editorTreePanelFull {
flex: 1;
}
.editorTreeSearch {
flex: none;
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
}
.editorSearchInput {
flex: 1;
min-width: 0;
height: 26px;
padding: 0 10px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 6px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-12);
}
.editorSearchInput:focus {
outline: none;
border-color: var(--dsw-alias-border-l2);
}
.editorSearchHint {
padding: 8px 12px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
}
.editorSearchResult {
display: block;
width: 100%;
padding: 4px 8px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-12);
text-align: left;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.editorSearchResult:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.editorStatus { .editorStatus {
font: var(--dsw-font-xxxs-11); font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary); color: var(--dsw-alias-label-tertiary);
@@ -1264,6 +1449,65 @@
color: var(--dsw-alias-label-primary); color: var(--dsw-alias-label-primary);
} }
/* The node-pty dependency failure banner (issue #140): a taller warn block
carrying the pasteable repair command (bash / cmd / PowerShell). */
.terminalDepsBanner {
flex: none;
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-state-warn-label);
background: var(--dsw-alias-state-warn-tertiary);
}
.terminalDepsTitle {
font: var(--dsw-font-xxs-strong-12);
color: var(--dsw-alias-state-warn-primary);
}
.terminalDepsHint {
opacity: 0.9;
}
.terminalDepsCommandRow {
display: flex;
align-items: flex-start;
gap: 8px;
}
/* The command the user pastes into a terminal / cmd. Selectable, wrapping,
and scrollable when long (Windows paths can get very long). */
.terminalRepairCommand {
flex: 1;
min-width: 0;
margin: 0;
padding: 6px 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
user-select: text;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-bg-layer-2);
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 4px;
max-height: 160px;
overflow: auto;
}
.terminalDepsNote {
opacity: 0.85;
}
.terminalDepsActions {
display: flex;
align-items: center;
gap: 8px;
}
/* The PER-TAB error strip: fills one tab's content area (unlike /* The PER-TAB error strip: fills one tab's content area (unlike
.boundaryError, the full-height fixed rail for shell-level crashes), so a .boundaryError, the full-height fixed rail for shell-level crashes), so a
crashing viewer/editor never hides the toggle cluster or the other tabs. */ crashing viewer/editor never hides the toggle cluster or the other tabs. */
+69 -18
View File
@@ -13,8 +13,8 @@ import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts'
import { isNarrowWidth } from './breakpoints.ts' import { isNarrowWidth } from './breakpoints.ts'
/** /**
* Tab type identifier. Builtins register their ids (explorer / git / editor * Tab type identifier. Builtins register their ids (editor / git / terminal
* / terminal / subagent / diff) through the sidebar service; external * / subagent / browser / diff) through the sidebar service; external
* plugins register their own (e.g. `'my-plugin:db'`). Kept as `string` so * plugins register their own (e.g. `'my-plugin:db'`). Kept as `string` so
* the registry stays open. * the registry stays open.
*/ */
@@ -25,7 +25,7 @@ export type SidebarDiffRef =
| { kind: 'worktree'; path: string; staged: boolean; untracked?: boolean } | { kind: 'worktree'; path: string; staged: boolean; untracked?: boolean }
| { kind: 'commit'; hash: string; hashFull: string; subject: string } | { kind: 'commit'; hash: string; hashFull: string; subject: string }
/** One open tab. `path` carries the file (editor) or is absent (explorer/git); /** One open tab. `path` carries the file (editor) or is absent (git/terminal);
* `diff` carries the change a diff tab shows; `meta` (v0.12.0+) carries * `diff` carries the change a diff tab shows; `meta` (v0.12.0+) carries
* plugin-owned JSON-serializable state, preserved across reloads. */ * plugin-owned JSON-serializable state, preserved across reloads. */
export interface SidebarTab { export interface SidebarTab {
@@ -105,6 +105,13 @@ function uid(prefix: string): string {
return `${prefix}:${nextIdCounter}` return `${prefix}:${nextIdCounter}`
} }
/** Mint a fresh uid-based tab id. The `'editor:' + path` convention only
* covers openSidebarFile opens (per-path dedupe); opens that must not
* dedupe (the tree's "open to the side") mint through here. */
export function mintTabId(): string {
return uid('tab')
}
/** /**
* The largest numeric suffix across a raw persisted state's counter ids * The largest numeric suffix across a raw persisted state's counter ids
* (`pane:N` / `tab:N` / `split:N`). The uid counter is module-global and * (`pane:N` / `tab:N` / `split:N`). The uid counter is module-global and
@@ -139,17 +146,24 @@ function maxCounterId(parsed: unknown): number {
return max return max
} }
/** A fresh default state: one explorer tab in one pane, open per the caller's /** The default tab a fresh session seeds. */
export type DefaultSeed = 'editor-home' | 'none'
/** A fresh default state: one seeded tab in one pane, open per the caller's
* preference. `width` is the caller's preferred panel width (default * preference. `width` is the caller's preferred panel width (default
* PANEL_DEFAULT) and `panelOpen` whether the panel starts expanded (default * PANEL_DEFAULT) and `panelOpen` whether the panel starts expanded (default
* true); the store seeds new sessions from the user's side card prefs. * true); the store seeds new sessions from the user's side card prefs.
* `seedExplorer` places the default explorer tab — the store passes false * `seed` picks the seeded tab: 'editor-home' places the EMPTY files window
* when the user disabled the explorer tab type in settings, so a fresh * (an editor tab with no path whose tree panel starts open,
* session starts with an empty pane instead of a tab they turned off. */ * `meta.treeOpen: true`) — in BOTH editorExplorer modes that window is the
export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seedExplorer = true): SidebarState { * file explorer page — and 'none' starts with an empty pane (the store
* passes it when the user disabled the editor tab type in settings). */
export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seed: DefaultSeed = 'editor-home'): SidebarState {
const leaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null } const leaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
if (seedExplorer) { if (seed === 'editor-home') {
leaf.tabs = [{ id: uid('tab'), type: 'explorer', title: 'Explorer' }] // No path: the editor host renders its empty-state hint and the docked
// tree panel (treeOpen defaults open for path-less tabs; meta pins it).
leaf.tabs = [{ id: uid('tab'), type: 'editor', title: 'Files', meta: { treeOpen: true } }]
leaf.active = leaf.tabs[0]!.id leaf.active = leaf.tabs[0]!.id
} }
// The bottom panel starts closed with an empty pane (its welcome cards // The bottom panel starts closed with an empty pane (its welcome cards
@@ -764,19 +778,21 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
// New sessions seed from the user's side card prefs: the width is the // New sessions seed from the user's side card prefs: the width is the
// chosen percent of the window (clamped to the panel floor and the // chosen percent of the window (clamped to the panel floor and the
// viewport so a huge percent can never crush the app shell), the panel // viewport so a huge percent can never crush the app shell), the panel
// starts open only when the preference says so, and the default explorer // starts open only when the preference says so, and the seed tab is the
// tab is skipped when the user disabled the explorer tab type. On a // empty files window (tree panel open) in BOTH editorExplorer modes — a
// NARROW viewport a brand-new session starts collapsed instead — the // disabled editor type seeds nothing. On a NARROW viewport a brand-new
// panel is a full-screen drawer there, and auto-opening it on first // session starts collapsed instead — the panel is a full-screen drawer
// paint would cover the conversation before the user asked. Only the // there, and auto-opening it on first paint would cover the conversation
// first seeding is affected: once the user expands the drawer, // before the user asked. Only the first seeding is affected: once the
// `panelOpen: true` persists like any other state. // user expands the drawer, `panelOpen: true` persists like any other
// state.
const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
const width = viewport === undefined const width = viewport === undefined
? PANEL_DEFAULT ? PANEL_DEFAULT
: defaultWidthFor(viewport, prefs.defaultWidthPercent) : defaultWidthFor(viewport, prefs.defaultWidthPercent)
const openByDefault = prefs.openByDefault && (viewport === undefined || !isNarrowWidth(viewport)) const openByDefault = prefs.openByDefault && (viewport === undefined || !isNarrowWidth(viewport))
return makeDefaultState(width, openByDefault, prefs.tabsEnabled['explorer'] !== false) const seed: DefaultSeed = prefs.tabsEnabled['editor'] === false ? 'none' : 'editor-home'
return makeDefaultState(width, openByDefault, seed)
} }
/** /**
@@ -891,6 +907,21 @@ function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string
// accept any string type here — an unregistered type renders an // accept any string type here — an unregistered type renders an
// <OrphanedTab/> at view time and recovers if its plugin loads later. // <OrphanedTab/> at view time and recovers if its plugin loads later.
if (typeof candidate.type !== 'string') return undefined if (typeof candidate.type !== 'string') return undefined
// The standalone explorer tab type merged INTO the editor (the single
// files window): a persisted explorer tab reopens as an editor home
// tab — no path, tree panel open (an existing meta object survives).
if (candidate.type === 'explorer') {
const meta = candidate.meta !== null && typeof candidate.meta === 'object' && !Array.isArray(candidate.meta)
? candidate.meta as Record<string, unknown>
: undefined
tabs.push({
id: candidate.id,
type: 'editor',
title: 'Files',
meta: { treeOpen: true, ...meta },
})
continue
}
// `meta` is plugin-owned JSON-serializable state (v0.12.0+): the // `meta` is plugin-owned JSON-serializable state (v0.12.0+): the
// persisted value already went through JSON.parse, so it is inherently // persisted value already went through JSON.parse, so it is inherently
// serializable — carry it through verbatim (absent on older states). // serializable — carry it through verbatim (absent on older states).
@@ -943,6 +974,26 @@ export class SidebarStore {
private readonly persistTimers = new Map<string, number>() private readonly persistTimers = new Map<string, number>()
/** User-facing side card prefs seeding brand-new session states (defaults until the settings RPC resolves). */ /** User-facing side card prefs seeding brand-new session states (defaults until the settings RPC resolves). */
private prefs: SidebarPrefs = { ...SIDEBAR_PREFS_DEFAULTS } private prefs: SidebarPrefs = { ...SIDEBAR_PREFS_DEFAULTS }
/**
* External disable (the dsh-web-ui family's aionui-panel provider choice):
* while true the sidebar must not mount at all. Not part of the snapshot —
* nothing renders on it; the mount gate and the intercept predicates read
* it directly.
*/
private suspended = false
/**
* Set the external-disable flag (from the settings route) and remember it
* for the mount gate and the intercept predicates.
*/
setSuspended(suspended: boolean): void {
this.suspended = suspended
}
/** Whether the sidebar is externally disabled (aionui-panel chosen). */
getSuspended(): boolean {
return this.suspended
}
/** /**
* Replace the side card prefs (the settings RPC result / settings page * Replace the side card prefs (the settings RPC result / settings page
+66
View File
@@ -30,6 +30,72 @@ export function tokenValue(name: string): string {
return getComputedStyle(document.body).getPropertyValue(name).trim() return getComputedStyle(document.body).getPropertyValue(name).trim()
} }
/** Minimal alpha for a token color to count as effectively opaque. Skin
* systems turn `--dsw-alias-bg-base` translucent for glass panels (the
* dsh-web-ui skins use rgba 0.16–0.7; `transparent` is 0); below this
* floor a text surface (terminal, editor) would render over the skin's
* backdrop art, so callers fall back to an opaque color. Values at or
* above the floor (e.g. a skin's scoped 0.96 porcelain) pass through —
* the skin still controls the surface. */
const OPAQUE_ALPHA_MIN = 0.9
/** The alpha channel of a computed CSS color, or null when the format is
* not parseable (named colors, `color()`… — treated as opaque). Handles
* the shapes getComputedStyle actually returns: the rgb()/rgba() and
* hsl()/hsla() function forms (comma or space syntax, with or without the
* `/ alpha` slot) and the #rgb/#rgba/#rrggbb/#rrggbbaa hex family. */
export function colorAlpha(color: string): number | null {
const s = color.trim()
// #rgb / #rgba / #rrggbb / #rrggbbaa
const hex = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(s)
if (hex !== null) {
const digits = hex[1]!
if (digits.length === 3 || digits.length === 4) {
const a = digits.length === 4 ? digits[3]! : 'f'
return parseInt(a + a, 16) / 255
}
const alphaHex = digits.length === 8 ? digits.slice(6) : 'ff'
return parseInt(alphaHex, 16) / 255
}
// rgb(r g b) / rgb(r g b / a) / rgba(r, g, b, a) — the trailing slot is alpha.
const fn = /^(rgba?|hsla?)\(([^)]+)\)$/i.exec(s)
if (fn !== null) {
const parts = fn[2]!.split(/[,\s/]+/).filter(Boolean)
const alphaPart = parts[3]
if (alphaPart === undefined) return 1
const alpha = Number.parseFloat(alphaPart)
return Number.isFinite(alpha) ? alpha : 1
}
return null
}
/**
* A token value that actually PAINTS something — the guard for text
* surfaces (issue #90). Skin systems routinely set global tokens to
* `transparent` (glass skins) or translucent glass values (`rgba(…,0.16–0.7)`,
* e.g. the dsh-web-ui skins) — both are truthy strings, so callers using
* `|| fallback` never fire and the terminal/editor goes see-through over
* the skin's backdrop. This returns '' for visually inert values (unset
* keywords, transparent, and any color below the opacity floor) so the
* caller's fallback chain engages; effectively opaque values pass through.
*/
export function effectiveTokenValue(name: string): string {
const raw = tokenValue(name)
switch (raw) {
case '':
case 'transparent':
case 'initial':
case 'inherit':
case 'unset':
return ''
default: {
const alpha = colorAlpha(raw)
if (alpha !== null && alpha < OPAQUE_ALPHA_MIN) return ''
return raw
}
}
}
/** /**
* Subscribe to color-scheme flips (the presenter toggles the body * Subscribe to color-scheme flips (the presenter toggles the body
* attribute). The callback fires after the attribute changed; re-read the * attribute). The callback fires after the attribute changed; re-read the
+13 -1
View File
@@ -48,6 +48,14 @@ export interface SidebarConfig {
terminalsPerSession?: number terminalsPerSession?: number
/** How long a disconnected terminal process survives awaiting a reconnect. */ /** How long a disconnected terminal process survives awaiting a reconnect. */
reconnectGraceMs?: number reconnectGraceMs?: number
/**
* Terminal shell (absolute path or bare executable name) for BOTH the UI
* terminal tabs and the model-facing `terminal_*` tools. Empty = auto:
* POSIX follows `$SHELL` then the account login shell; Windows follows
* `DSH_SIDEBAR_SHELL`, then probes for `pwsh.exe`, then falls back to the
* inbox `powershell.exe` (5.1).
*/
shell?: string
} }
/** Schemastery schema for the plugin configuration. */ /** Schemastery schema for the plugin configuration. */
@@ -57,6 +65,7 @@ export const Config: z<SidebarConfig> = z.object({
listLimit: z.number().step(1).min(1).default(1000), listLimit: z.number().step(1).min(1).default(1000),
terminalsPerSession: z.number().step(1).min(1).default(3), terminalsPerSession: z.number().step(1).min(1).default(3),
reconnectGraceMs: z.number().step(1).min(0).default(30_000), reconnectGraceMs: z.number().step(1).min(0).default(30_000),
shell: z.string().default(''),
}) })
/** Fully defaulted sidebar host settings. */ /** Fully defaulted sidebar host settings. */
@@ -66,6 +75,7 @@ export interface ResolvedSidebarConfig {
listLimit: number listLimit: number
terminalsPerSession: number terminalsPerSession: number
reconnectGraceMs: number reconnectGraceMs: number
shell: string
} }
/** /**
@@ -81,6 +91,7 @@ export function resolveSidebarConfig(config: SidebarConfig | undefined): Resolve
listLimit: config?.listLimit ?? 1000, listLimit: config?.listLimit ?? 1000,
terminalsPerSession: config?.terminalsPerSession ?? 3, terminalsPerSession: config?.terminalsPerSession ?? 3,
reconnectGraceMs: config?.reconnectGraceMs ?? 30_000, reconnectGraceMs: config?.reconnectGraceMs ?? 30_000,
shell: config?.shell?.trim() ?? '',
} }
} }
@@ -88,7 +99,7 @@ export function resolveSidebarConfig(config: SidebarConfig | undefined): Resolve
/** Schemastery schema for the user-facing preferences (validated by the settings service). */ /** Schemastery schema for the user-facing preferences (validated by the settings service). */
export const PrefsSchema: z<SidebarPrefs> = z.object({ export const PrefsSchema: z<SidebarPrefs> = z.object({
openByDefault: z.boolean().default(true), openByDefault: z.boolean().default(false),
defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT), defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT),
autoOpenSubagent: z.boolean().default(true), autoOpenSubagent: z.boolean().default(true),
autoOpenJobs: z.boolean().default(true), autoOpenJobs: z.boolean().default(true),
@@ -97,6 +108,7 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
terminalFontFamily: z.string().default(''), terminalFontFamily: z.string().default(''),
terminalFontSize: z.number().step(1).min(TERMINAL_FONT_SIZE_MIN).max(TERMINAL_FONT_SIZE_MAX).default(TERMINAL_FONT_SIZE_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), interceptOpenPath: z.boolean().default(true),
editorExplorer: z.boolean().default(true),
titleBarCompat: z.boolean().default(false), 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), 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), htmlViewerNoSandbox: z.boolean().default(false),
+86
View File
@@ -0,0 +1,86 @@
/**
* Recursive file-name search for the editor's merged-mode side panel.
* Streams the tree with opendir and matches the query as a case-insensitive
* substring of each entry's NAME (paths stay relative to the search root —
* the client resolves them against the session cwd). No .gitignore semantics
* (this is a name lookup, not a code search), but `.git` directories are
* skipped outright (VCS internals are never useful results) and symlink
* directories are NOT descended (cycle safety).
*
* Two performance budgets bound the walk: `maxMatches` (the client renders
* the flat list) and `maxVisited` (a runaway tree — a home directory root,
* a node_modules forest — must not stall the host). Exceeding either stops
* early with `truncated: true`.
*/
import { opendir } from 'node:fs/promises'
import { join, relative, sep } from 'node:path'
/** One search: the relative paths of the matching entries (dirs included so
* the client can hint where matches live) plus the truncation flag. */
export interface FsSearchResult {
matches: string[]
truncated: boolean
}
/** Search budgets (both injectable for tests). */
export interface FsSearchOptions {
/** Row cap of the result list (default 200). */
maxMatches?: number
/** Total entries visited before the walk gives up (default 100_000). */
maxVisited?: number
}
const DEFAULT_MAX_MATCHES = 200
const DEFAULT_MAX_VISITED = 100_000
/**
* Search `root` recursively for entries whose name contains `query`
* (case-insensitive).
* @param root - absolute search root.
* @param query - the name substring; empty matches nothing.
* @param opts - budget overrides (tests).
* @returns the matching paths RELATIVE to `root` ('/'-separated), sorted,
* plus whether a budget cut the walk short. An unreadable level is skipped
* (permission errors never fail the whole search).
*/
export async function searchFiles(root: string, query: string, opts: FsSearchOptions = {}): Promise<FsSearchResult> {
const needle = query.trim().toLowerCase()
if (needle === '') return { matches: [], truncated: false }
const maxMatches = opts.maxMatches ?? DEFAULT_MAX_MATCHES
const maxVisited = opts.maxVisited ?? DEFAULT_MAX_VISITED
const matches: string[] = []
let visited = 0
let truncated = false
const walk = async (dir: string): Promise<void> => {
if (truncated) return
const level = await opendir(dir).catch(() => undefined)
if (level === undefined) return
for await (const dirent of level) {
visited += 1
if (visited > maxVisited) {
truncated = true
return
}
// .git is VCS-internal noise: never matched, never descended.
if (dirent.isDirectory() && dirent.name === '.git') continue
if (dirent.name.toLowerCase().includes(needle)) {
matches.push(join(relative(root, dir), dirent.name))
if (matches.length >= maxMatches) {
truncated = true
return
}
}
// Descend real directories only: a symlinked directory may point back
// up the tree (cycle).
if (dirent.isDirectory() && !dirent.isSymbolicLink()) {
await walk(join(dir, dirent.name))
if (truncated) return
}
}
}
await walk(root)
// '/' separators on every platform: the client joins onto the cwd itself.
return { matches: matches.sort().map(path => path.split(sep).join('/')), truncated }
}
+51 -8
View File
@@ -2,11 +2,13 @@
* Single-level directory listing for the sidebar explorer. Streams the level * Single-level directory listing for the sidebar explorer. Streams the level
* with opendir, sorts directories first then names (case-insensitive), and * with opendir, sorts directories first then names (case-insensitive), and
* marks POSIX-hidden entries (dot-prefixed) for dimmed display. Symlinks are * marks POSIX-hidden entries (dot-prefixed) for dimmed display. Symlinks are
* reported as files without probing their target — the explorer shows what * stat'ed once to expose their target kind — a symlink to a directory
* dirent says, keeping the read cheap for arbitrarily large levels. * expands like a directory — and dangling links are flagged broken. The
* probe runs only for entries that are actually symlinks, so levels without
* links stay as cheap as before.
*/ */
import { opendir } from 'node:fs/promises' import { opendir, stat } from 'node:fs/promises'
import { basename, dirname, join, resolve } from 'node:path' import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
import { SidebarError } from './wire.ts' import { SidebarError } from './wire.ts'
/** One explorer row. */ /** One explorer row. */
@@ -15,6 +17,10 @@ export interface SidebarFsEntry {
path: string path: string
isDir: boolean isDir: boolean
hidden: boolean hidden: boolean
/** Whether the row is a symlink; `isDir` then describes the link's target. */
isSymlink: boolean
/** For symlinks: the target is missing or unreadable (stat failed). */
broken: boolean
} }
/** One listed level. */ /** One listed level. */
@@ -52,22 +58,53 @@ export async function listDirectory(path: string, maxEntries = 1000): Promise<Si
overflow += 1 overflow += 1
continue continue
} }
// Platform join: on Windows the level path uses '\' — a hardcoded '/'
// would leak mixed separators into every row's path.
rows.push({ rows.push({
name: dirent.name, name: dirent.name,
// Platform join: on Windows the level path uses '\' — a hardcoded '/'
// would leak mixed separators into every row's path.
path: join(path, dirent.name), path: join(path, dirent.name),
isDir: dirent.isDirectory(), isDir: dirent.isDirectory(),
isSymlink: dirent.isSymbolicLink(),
broken: false,
hidden: dirent.name.startsWith('.'), hidden: dirent.name.startsWith('.'),
}) })
} }
} catch (error) { } catch (error) {
throw new SidebarError('fs-error', `cannot list "${path}": ${messageOf(error)}`, 400) throw new SidebarError('fs-error', `cannot list "${path}": ${messageOf(error)}`, 400)
} }
// Probe symlink targets AFTER the readdir stream closes, with bounded
// concurrency: a symlink-heavy level (UNC/network targets) would otherwise
// serialize up to maxEntries stat calls and stall the explorer. Non-symlink
// rows are skipped by the probe, so levels without links stay as cheap as
// before.
await probeSymlinkTargets(rows)
rows.sort(compareEntries) rows.sort(compareEntries)
return { path, entries: rows, truncated: overflow > 0 } return { path, entries: rows, truncated: overflow > 0 }
} }
/** How many symlink target stats run in flight during one level listing. */
const SYMLINK_PROBE_CONCURRENCY = 32
/** Probe each symlink row's target once (bounded concurrency, order-preserving). */
async function probeSymlinkTargets(rows: SidebarFsEntry[], concurrency = SYMLINK_PROBE_CONCURRENCY): Promise<void> {
let next = 0
const workers = Array.from({ length: Math.min(concurrency, rows.length) }, async () => {
for (;;) {
const index = next
next += 1
if (index >= rows.length) return
const row = rows[index]!
if (!row.isSymlink) continue
// stat follows the chain; any failure (missing target, ELOOP, permission)
// leaves the row as a broken file-shaped link the editor refuses to read.
const info = await stat(row.path).catch(() => undefined)
row.isDir = info !== undefined ? info.isDirectory() : row.isDir
row.broken = info === undefined
}
})
await Promise.all(workers)
}
/** The root row label of a listing: the last path segment (or the full path at the filesystem root). */ /** The root row label of a listing: the last path segment (or the full path at the filesystem root). */
export function rootLabel(path: string): string { export function rootLabel(path: string): string {
const base = basename(path) const base = basename(path)
@@ -80,9 +117,15 @@ export function parentOf(path: string): string | undefined {
return parent === path ? undefined : parent return parent === path ? undefined : parent
} }
/** Normalize a caller-supplied path to an absolute, resolved path or throw fs-error. */ /**
* Normalize a caller-supplied path to an absolute, resolved path or throw
* fs-error. `path.isAbsolute()` is the OS's own notion of absolute: POSIX
* roots (`/...`), Windows drive letters (`C:\...`) and — on win32 — UNC
* network shares (`\\server\share\...`); drive-relative forms (`C:foo`)
* stay rejected.
*/
export function requireAbsolute(path: string): string { export function requireAbsolute(path: string): string {
if (!path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(path)) { if (!isAbsolute(path)) {
throw new SidebarError('fs-error', `"${path}" is not an absolute path`, 400) throw new SidebarError('fs-error', `"${path}" is not an absolute path`, 400)
} }
return resolve(path) return resolve(path)
+43 -16
View File
@@ -13,6 +13,17 @@
* /sidebar/html/S/Users/me/proj/index.html * /sidebar/html/S/Users/me/proj/index.html
* + ./style.css → /sidebar/html/S/Users/me/proj/style.css * + ./style.css → /sidebar/html/S/Users/me/proj/style.css
* Windows: C:\Users\me\a.html → /sidebar/html/S/C%3A/Users/me/a.html * Windows: C:\Users\me\a.html → /sidebar/html/S/C%3A/Users/me/a.html
* UNC (\\server\share\... or //server/share/...):
* → /sidebar/html/S//server/share/proj/a.html ('//' right after the
* sessionId marks the UNC prefix; the WHATWG URL keeps '//' intact so
* relative assets still resolve inside the same route)
*
* The decoder rebuilds the marker as a forward-slash `//server/share/...`
* path. That form is intentionally platform-neutral: `node:path` resolves it
* to `\\server\share\...` on win32 and `/server/share/...` on POSIX, so the
* host's existing requireAbsolute + isWithin fence needs no platform signal
* (a leading `//` is a legal POSIX absolute path, so no data is lost on
* either platform).
* *
* This module is intentionally dependency-free (no node imports, no wire * This module is intentionally dependency-free (no node imports, no wire
* helpers) so the client bundle can import `encodeHtmlUrl` without tripping * helpers) so the client bundle can import `encodeHtmlUrl` without tripping
@@ -37,23 +48,24 @@ export const HTML_ROUTE_PREFIX = '/sidebar/html/'
/** Build the route URL for one absolute file path (client + tests). */ /** Build the route URL for one absolute file path (client + tests). */
export function encodeHtmlUrl(sessionId: string, path: string): string { export function encodeHtmlUrl(sessionId: string, path: string): string {
const unc = /^[\\/]{2}[^\\/]/.test(path)
const segments = path.split(/[\\/]+/).filter(segment => segment !== '') const segments = path.split(/[\\/]+/).filter(segment => segment !== '')
return `${HTML_ROUTE_PREFIX}${encodeURIComponent(sessionId)}/${segments.map(encodeURIComponent).join('/')}` return `${HTML_ROUTE_PREFIX}${encodeURIComponent(sessionId)}/${unc ? '/' : ''}${segments.map(encodeURIComponent).join('/')}`
} }
/** /**
* Decode a route pathname into the session + absolute file path. Rejects * Decode a route pathname into the session + absolute file path. Rejects
* a wrong prefix (404), an empty or double-slash path, malformed percent * a wrong prefix (404), an empty path, malformed percent encoding, and a
* encoding, and a missing sessionId or file path (400). The caller still * missing sessionId or file path (400). The caller still must bound the
* must bound the decoded path with requireAbsolute + isWithin(cwd) — a * decoded path with requireAbsolute + isWithin(cwd) — a decoded `..`
* decoded `..` segment resolves outside the cwd and is refused there. * segment resolves outside the cwd and is refused there.
*/ */
export function decodeHtmlUrl(pathname: string): HtmlDecodeResult { export function decodeHtmlUrl(pathname: string): HtmlDecodeResult {
if (!pathname.startsWith(HTML_ROUTE_PREFIX)) { if (!pathname.startsWith(HTML_ROUTE_PREFIX)) {
return { ok: false, status: 404, message: 'not an html route' } return { ok: false, status: 404, message: 'not an html route' }
} }
const rest = pathname.slice(HTML_ROUTE_PREFIX.length) const rest = pathname.slice(HTML_ROUTE_PREFIX.length)
if (rest === '' || rest.includes('//')) { if (rest === '') {
return { ok: false, status: 400, message: 'invalid html route path' } return { ok: false, status: 400, message: 'invalid html route path' }
} }
let segments: string[] let segments: string[]
@@ -63,17 +75,32 @@ export function decodeHtmlUrl(pathname: string): HtmlDecodeResult {
return { ok: false, status: 400, message: 'malformed URL encoding' } return { ok: false, status: 400, message: 'malformed URL encoding' }
} }
const [sessionId, ...pathSegments] = segments const [sessionId, ...pathSegments] = segments
if (sessionId === undefined || sessionId === '' || pathSegments.length === 0 || pathSegments.some(segment => segment === '')) { if (sessionId === undefined || sessionId === '') {
return { ok: false, status: 400, message: 'sessionId and file path are required' } return { ok: false, status: 400, message: 'sessionId and file path are required' }
} }
// A Windows drive segment ('D:') is the FIRST path segment of an encoded // An empty FIRST path segment is the UNC marker (encodeHtmlUrl emits
// drive path. Rejoining it with a leading slash would yield '/D:/work/...' // '<sid>//server/share/...' for UNC paths); the encoder filters empty
// which node's path.resolve() mangles into 'D:\D:\work\...' on Windows — // segments everywhere else, so an empty segment can only be the marker or
// the html route's isWithin(cwd) fence would then reject every drive path. // a malformed URL — both handled here.
// Keep the drive form slash-free so requireAbsolute() resolves it verbatim. const unc = pathSegments[0] === ''
const first = pathSegments[0] ?? '' const tail = unc ? pathSegments.slice(1) : pathSegments
const path = /^[A-Za-z]:$/.test(first) if (tail.length === 0 || tail.some(segment => segment === '')) {
? pathSegments.join('/') return { ok: false, status: 400, message: 'sessionId and file path are required' }
: `/${pathSegments.join('/')}` }
let path: string
if (unc) {
// Rebuild the platform-neutral forward-slash form `//server/share/...`;
// requireAbsolute() resolves it to the platform's own UNC/POSIX spelling.
path = `//${tail.join('/')}`
} else if (/^[A-Za-z]:$/.test(tail[0] ?? '')) {
// A Windows drive segment ('D:') is the FIRST path segment of an encoded
// drive path. Rejoining it with a leading slash would yield '/D:/work/...'
// which node's path.resolve() mangles into 'D:\D:\work\...' on Windows —
// the html route's isWithin(cwd) fence would then reject every drive path.
// Keep the drive form slash-free so requireAbsolute() resolves it verbatim.
path = tail.join('/')
} else {
path = `/${tail.join('/')}`
}
return { ok: true, ref: { sessionId, path } } return { ok: true, ref: { sessionId, path } }
} }
+96 -17
View File
@@ -29,6 +29,7 @@ import {
type SidebarPrefs, type SidebarPrefs,
} from './config.ts' } from './config.ts'
import { isWithin, parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts' import { isWithin, parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
import { searchFiles } from './fs-search.ts'
import { decodeHtmlUrl } from './html-route.ts' import { decodeHtmlUrl } from './html-route.ts'
import { extractFrameAncestors } from './browser-probe.ts' import { extractFrameAncestors } from './browser-probe.ts'
import { isTrustedApiRequest, isLoopbackHostname } from './trust-fence.ts' import { isTrustedApiRequest, isLoopbackHostname } from './trust-fence.ts'
@@ -37,6 +38,12 @@ import * as git from './git.ts'
import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings' import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts' import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts'
import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts' import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts'
import {
DSH_NODE_PTY_RANGE,
depsStatus,
loadNodePty,
PTY_DEPS_MISSING,
} from './pty-deps.ts'
import { registerTools } from './tools.ts' import { registerTools } from './tools.ts'
import { buildJobsApi, type SidebarJobsRoutes } from './jobs-routes.ts' import { buildJobsApi, type SidebarJobsRoutes } from './jobs-routes.ts'
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts' import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
@@ -172,6 +179,14 @@ type ApiMethod = (payload: unknown) => Promise<unknown> | unknown
export interface SidebarSettingsFace { export interface SidebarSettingsFace {
/** The current resolved value + revision (undefined while the settings service is absent). */ /** The current resolved value + revision (undefined while the settings service is absent). */
get(): { value?: unknown; revision?: number } get(): { value?: unknown; revision?: number }
/**
* Whether the dsh-web-ui family's aionui-panel has been selected as the
* right-panel provider (the `aionui-panel` settings namespace resolves
* `rightPanel: 'aionui-panel'`). While true the sidebar must not mount —
* the two right panels are mutually exclusive. False when the namespace is
* absent (no aionui installed) or the provider is anything else.
*/
externalDisable(): boolean
/** Merge a patch (revision-guarded) and return the fresh resolved view. */ /** Merge a patch (revision-guarded) and return the fresh resolved view. */
update(patch: Record<string, unknown>, expectedRevision?: number): Promise<{ value?: unknown; revision?: number }> update(patch: Record<string, unknown>, expectedRevision?: number): Promise<{ value?: unknown; revision?: number }>
} }
@@ -179,8 +194,8 @@ export interface SidebarSettingsFace {
/** Build the API method table bound to the plugin context, pty manager, agent pty registry, and resolved config. */ /** Build the API method table bound to the plugin context, pty manager, agent pty registry, and resolved config. */
function buildApi( function buildApi(
ctx: Context, ctx: Context,
ptyManager: PtyManager, ptyManager: PtyManager | null,
agentPtyRegistry: AgentPtyRegistry, agentPtyRegistry: AgentPtyRegistry | null,
resolved: ResolvedSidebarConfig, resolved: ResolvedSidebarConfig,
getSettings: () => SidebarSettingsFace | undefined, getSettings: () => SidebarSettingsFace | undefined,
): Record<string, ApiMethod> { ): Record<string, ApiMethod> {
@@ -207,6 +222,14 @@ function buildApi(
const target = record.path === undefined ? cwd : requireAbsolute(requireString(payload, 'path')) const target = record.path === undefined ? cwd : requireAbsolute(requireString(payload, 'path'))
return listDirectory(target, resolved.listLimit) return listDirectory(target, resolved.listLimit)
}, },
'fs.search': async (payload) => {
// The editor side panel's global name search: rooted at the session
// cwd (not caller-targetable — the walk is unbounded by design and
// must never escape the workspace), budgeted inside searchFiles.
const { cwd } = cwdOf(payload)
const query = requireString(payload, 'query')
return searchFiles(cwd, query)
},
'fs.read': async (payload) => { 'fs.read': async (payload) => {
const { cwd } = cwdOf(payload) const { cwd } = cwdOf(payload)
// Relative paths are git-derived (status/diff report repo-root-relative // Relative paths are git-derived (status/diff report repo-root-relative
@@ -313,7 +336,9 @@ function buildApi(
'pty.close': (payload) => { 'pty.close': (payload) => {
const sessionId = requireString(payload, 'sessionId') const sessionId = requireString(payload, 'sessionId')
const tab = requireString(payload, 'tab') const tab = requireString(payload, 'tab')
ptyManager.close(`${sessionId}:${tab}`) // Degraded mode (node-pty unavailable): no live pty can exist, so a
// no-op ok is the honest answer — never an error the client must show.
ptyManager?.close(`${sessionId}:${tab}`)
return { ok: true } return { ok: true }
}, },
// Release an agent terminal by uuid. The WS close frame already does // Release an agent terminal by uuid. The WS close frame already does
@@ -322,9 +347,14 @@ function buildApi(
// tab never leaves a zombie pty behind. Idempotent. // tab never leaves a zombie pty behind. Idempotent.
'agent-pty.close': (payload) => { 'agent-pty.close': (payload) => {
const uuid = requireString(payload, 'uuid') const uuid = requireString(payload, 'uuid')
agentPtyRegistry.close(uuid) agentPtyRegistry?.close(uuid)
return { ok: true } return { ok: true }
}, },
// Terminal dependency status (issue #140): after a WS close 1011 with
// reason `pty-deps-missing` the client fetches the full repair details
// here — the close reason itself is capped at 123 bytes, too small for
// the pasteable command.
'terminal.deps': () => depsStatus(),
// Background jobs: read one job's output (a REPLAY of what the model // Background jobs: read one job's output (a REPLAY of what the model
// has read so far, from the owner session's event log — the model's // has read so far, from the owner session's event log — the model's
// job_output cursor is never touched, so the human pane can never steal // job_output cursor is never touched, so the human pane can never steal
@@ -340,7 +370,9 @@ function buildApi(
// silently overwritten (mirror of the settings seam's own guard). // silently overwritten (mirror of the settings seam's own guard).
'settings.get': () => { 'settings.get': () => {
const settings = getSettings() const settings = getSettings()
return settings?.get() ?? { value: undefined, revision: undefined } return settings === undefined
? { value: undefined, revision: undefined, externalDisable: false }
: { ...settings.get(), externalDisable: settings.externalDisable() }
}, },
'settings.update': async (payload) => { 'settings.update': async (payload) => {
const settings = getSettings() const settings = getSettings()
@@ -426,18 +458,35 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
// restore it before any terminal can spawn (idempotent). // restore it before any terminal can spawn (idempotent).
ensureSpawnHelper() ensureSpawnHelper()
const resolved = resolveSidebarConfig(config) const resolved = resolveSidebarConfig(config)
// One shell resolution feeds BOTH terminal surfaces: the UI tabs and the
// model-facing terminal_* tools. They must stay in lockstep, otherwise a
// configured shell fixes one surface and silently leaves the other on the
// platform default.
const terminalShell = defaultShell({ explicit: resolved.shell })
// The web runtime's bind-derived trust list (boot-sampled LAN literals // The web runtime's bind-derived trust list (boot-sampled LAN literals
// plus --trusted-host authorities) — the authoritative source the /api // plus --trusted-host authorities) — the authoritative source the /api
// gateway fence derives its list from. Read per request from the live // gateway fence derives its list from. Read per request from the live
// service value; a replaced list takes effect without a plugin restart. // service value; a replaced list takes effect without a plugin restart.
const fence = (req: SidebarHttpRequest): boolean => isTrustedApiRequest(req, ctx.webRuntime.trustedHosts) const fence = (req: SidebarHttpRequest): boolean => isTrustedApiRequest(req, ctx.webRuntime.trustedHosts)
const ptyManager = new PtyManager(defaultShell(), resolved.terminalsPerSession) // node-pty is loaded lazily, never at module top level (issue #140): a
// missing or broken install must degrade THIS plugin — terminal tab shows
// a repair command, agent terminal tools stay unregistered — instead of
// failing the plugin load and taking the whole `dsh web` server down.
const nodePty = loadNodePty()
if (nodePty === null) {
const status = depsStatus()
const detail = status.ok
? 'unknown cause'
: `${status.cause}. Repair: ${status.command}`
ctx.logger?.warn(`[dsh-better-sidebar] node-pty (${DSH_NODE_PTY_RANGE}) failed to load: ${detail}`)
}
const ptyManager = nodePty !== null ? new PtyManager(terminalShell, resolved.terminalsPerSession, nodePty) : null
// The agent-owned terminal registry: parallel to the UI-tab ptyManager, // The agent-owned terminal registry: parallel to the UI-tab ptyManager,
// keyed by uuid (the model's opaque handle) instead of `${sessionId}:${tabId}`, // keyed by uuid (the model's opaque handle) instead of `${sessionId}:${tabId}`,
// uncapped, and torn down with the plugin. The model creates terminals here // uncapped, and torn down with the plugin. The model creates terminals here
// through the terminal_create tool; the sidebar view attaches through the // through the terminal_create tool; the sidebar view attaches through the
// same /sidebar/ws/terminal upgrade with ?uuid=... instead of ?tab=... // same /sidebar/ws/terminal upgrade with ?uuid=... instead of ?tab=...
const agentPtyRegistry = new AgentPtyRegistry(defaultShell()) const agentPtyRegistry = nodePty !== null ? new AgentPtyRegistry(terminalShell, nodePty) : null
// ── User-facing "Side card" preferences ────────────────────────────────── // ── User-facing "Side card" preferences ──────────────────────────────────
// Register the namespace with the settings provider so the Settings page // Register the namespace with the settings provider so the Settings page
@@ -456,6 +505,9 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
const syncToolsGate = (scope: { get(): SidebarPrefs }): void => { const syncToolsGate = (scope: { get(): SidebarPrefs }): void => {
if (scope.get().agentTerminalTools) { if (scope.get().agentTerminalTools) {
if (toolsDisposers === null) { if (toolsDisposers === null) {
// 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))
} }
} else if (toolsDisposers !== null) { } else if (toolsDisposers !== null) {
@@ -464,7 +516,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
// The feature is off: release every agent terminal the model created // The feature is off: release every agent terminal the model created
// while it was on (they are only reachable through the tools). The // while it was on (they are only reachable through the tools). The
// registry change fires the push, so the sidebar reconciles them away. // registry change fires the push, so the sidebar reconciles them away.
agentPtyRegistry.disposeAll() agentPtyRegistry?.disposeAll()
} }
} }
ctx.inject(['settings'], (sctx) => { ctx.inject(['settings'], (sctx) => {
@@ -482,8 +534,20 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
? { value: undefined, revision: undefined } ? { value: undefined, revision: undefined }
: { value: descriptor.value, revision: descriptor.revision } : { value: descriptor.value, revision: descriptor.revision }
} }
// Mutual exclusion with the dsh-web-ui family right panel: the aionui
// panel's provider choice (`aionui-panel.rightPanel`) is the authority.
// While it resolves to 'aionui-panel', this sidebar must not mount. The
// namespace is read through the settings seam like any other registered
// section; absent namespace (no aionui installed) = not disabled.
const externalDisable = (): boolean => {
const descriptor = sctx.settings.describe({ redactSecrets: true })
.find(candidate => candidate.ns === 'aionui-panel')
const value = descriptor?.value as { rightPanel?: unknown } | undefined
return value?.rightPanel === 'aionui-panel'
}
settingsFace = { settingsFace = {
get: viewOf, get: viewOf,
externalDisable,
update: async (patch, expectedRevision) => { update: async (patch, expectedRevision) => {
await sctx.settings.update(ns, patch, expectedRevision) await sctx.settings.update(ns, patch, expectedRevision)
return viewOf() return viewOf()
@@ -695,8 +759,8 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
ctx.effect(() => () => { ctx.effect(() => () => {
toolsDisposers?.() toolsDisposers?.()
ptyManager.disposeAll() ptyManager?.disposeAll()
agentPtyRegistry.disposeAll() agentPtyRegistry?.disposeAll()
wss.close() wss.close()
agentListWss.close() agentListWss.close()
}, 'dsh-better-sidebar: teardown') }, 'dsh-better-sidebar: teardown')
@@ -704,7 +768,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
/** Push the live agent-terminal list for one session to a connected sidebar view. */ /** Push the live agent-terminal list for one session to a connected sidebar view. */
async function attachAgentList( async function attachAgentList(
registry: AgentPtyRegistry, registry: AgentPtyRegistry | null,
ws: WebSocket, ws: WebSocket,
req: SidebarHttpRequest, req: SidebarHttpRequest,
): Promise<void> { ): Promise<void> {
@@ -717,13 +781,15 @@ async function attachAgentList(
} }
const send = (): void => { const send = (): void => {
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(registry.list(sessionId))) // Degraded mode (node-pty unavailable): no agent terminal can exist,
// so the honest push is the empty list.
ws.send(JSON.stringify(registry?.list(sessionId) ?? []))
} }
} }
send() send()
const unsubscribe = registry.subscribe(send) const unsubscribe = registry?.subscribe(send)
ws.on('close', () => { unsubscribe() }) ws.on('close', () => { unsubscribe?.() })
ws.on('error', () => { unsubscribe() }) ws.on('error', () => { unsubscribe?.() })
} catch (error) { } catch (error) {
ws.close(1011, error instanceof Error ? error.message : String(error)) ws.close(1011, error instanceof Error ? error.message : String(error))
} }
@@ -743,8 +809,8 @@ async function attachAgentList(
*/ */
async function attachTerminal( async function attachTerminal(
ctx: Context, ctx: Context,
ptyManager: PtyManager, ptyManager: PtyManager | null,
agentPtyRegistry: AgentPtyRegistry, agentPtyRegistry: AgentPtyRegistry | null,
ws: WebSocket, ws: WebSocket,
req: SidebarHttpRequest, req: SidebarHttpRequest,
resolved: ResolvedSidebarConfig, resolved: ResolvedSidebarConfig,
@@ -753,6 +819,12 @@ async function attachTerminal(
const url = new URL(req.url ?? '/', 'http://dsh.internal') const url = new URL(req.url ?? '/', 'http://dsh.internal')
const uuid = url.searchParams.get('uuid') const uuid = url.searchParams.get('uuid')
if (uuid !== null) { if (uuid !== null) {
// Degraded mode (node-pty unavailable): no agent terminal can exist,
// so the lookup behaves exactly like a missing uuid.
if (agentPtyRegistry === null) {
ws.close(1011, `agent terminal "${uuid}" not found`)
return
}
const handle = agentPtyRegistry.get(uuid) const handle = agentPtyRegistry.get(uuid)
if (handle === undefined) { if (handle === undefined) {
ws.close(1011, `agent terminal "${uuid}" not found`) ws.close(1011, `agent terminal "${uuid}" not found`)
@@ -767,6 +839,13 @@ async function attachTerminal(
ws.close(1008, 'either ?uuid or ?sessionId+?tab are required') ws.close(1008, 'either ?uuid or ?sessionId+?tab are required')
return return
} }
if (ptyManager === null) {
// Degraded mode (issue #140): node-pty unavailable. The close reason
// is a SHORT marker — a WS close reason is capped at 123 bytes, so the
// client fetches the full repair command from /sidebar/api/terminal.deps.
ws.close(1011, PTY_DEPS_MISSING)
return
}
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined) const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24) const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24)
// Replay the transcript, then follow live output. // Replay the transcript, then follow live output.
+12 -2
View File
@@ -60,6 +60,15 @@ export interface SidebarPrefs {
* own enable switch gates it too (both must be on for the takeover). * own enable switch gates it too (both must be on for the takeover).
*/ */
interceptOpenPath: boolean interceptOpenPath: boolean
/**
* Whether the editor tab runs in merged mode: a path input replaces the
* plain header and a toggleable file-tree panel (with a global name
* search) docks at the tab's right edge. On by default; also makes brand
* new sessions seed an empty editor tab (tree panel open) instead of the
* explorer tab. The switch lives under the editor card's gear in the
* Side card settings; off restores the pre-merge editor exactly.
*/
editorExplorer: boolean
/** /**
* Position compatibility mode: reserves space at the top for the native * 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 * Windows title bar (drawn at the window's top-right corner over the web
@@ -156,7 +165,7 @@ export interface SidebarPrefs {
/** Range contract of {@link SidebarPrefs.defaultWidthPercent}. */ /** Range contract of {@link SidebarPrefs.defaultWidthPercent}. */
export const WIDTH_PERCENT_MIN = 20 export const WIDTH_PERCENT_MIN = 20
export const WIDTH_PERCENT_MAX = 60 export const WIDTH_PERCENT_MAX = 60
export const WIDTH_PERCENT_DEFAULT = 30 export const WIDTH_PERCENT_DEFAULT = 35
/** Range contract of {@link SidebarPrefs.terminalFontSize}. */ /** Range contract of {@link SidebarPrefs.terminalFontSize}. */
export const TERMINAL_FONT_SIZE_MIN = 9 export const TERMINAL_FONT_SIZE_MIN = 9
@@ -170,7 +179,7 @@ export const TITLE_BAR_STRIP_DEFAULT = 40
/** Fallback prefs used whenever the settings document is unreachable or malformed. */ /** Fallback prefs used whenever the settings document is unreachable or malformed. */
export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = { export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
openByDefault: true, openByDefault: false,
defaultWidthPercent: WIDTH_PERCENT_DEFAULT, defaultWidthPercent: WIDTH_PERCENT_DEFAULT,
autoOpenSubagent: true, autoOpenSubagent: true,
autoOpenJobs: true, autoOpenJobs: true,
@@ -179,6 +188,7 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
terminalFontFamily: '', terminalFontFamily: '',
terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT, terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT,
interceptOpenPath: true, interceptOpenPath: true,
editorExplorer: true,
titleBarCompat: false, titleBarCompat: false,
titleBarStripPx: TITLE_BAR_STRIP_DEFAULT, titleBarStripPx: TITLE_BAR_STRIP_DEFAULT,
htmlViewerNoSandbox: false, htmlViewerNoSandbox: false,
+240
View File
@@ -0,0 +1,240 @@
/**
* node-pty dependency loading for the host half (issue #140, plugin side).
*
* The terminal surfaces (UI tabs + model-facing terminal_* tools) need
* node-pty, but the package must NEVER be imported statically at module
* top level: a missing or broken install (pnpm 11's strict-dep-builds
* skipping node-pty's install script, a pruned store entry, a failed
* prebuilt-binary download…) would then fail the plugin module load and —
* because a loader entry apply failure aborts the boot — take the whole
* `dsh web` server down with it.
*
* Instead the host half loads node-pty lazily (synchronously, via
* createRequire — the same resolution `ensureSpawnHelper` already uses in
* production). When the load fails the plugin stays mounted in a degraded
* state: the terminal tab shows a friendly error carrying a pasteable
* repair command (see scripts/install.sh / install.ps1 `--repair`), and the
* agent terminal tools are simply not registered.
*
* Version contract: the plugin must stay in sync with DSH core —
* `@deepseek-ai/dsh-subprocess-local` declares `"node-pty": "^1.1.0"` in
* its `dependencies`. Both sides then resolve the SAME pnpm store entry
* (same range, same integrity → one native binding, no drift). Do NOT
* switch to a fork (e.g. @lydell/node-pty) or a different range without
* re-checking the core declaration.
*/
import { existsSync, readFileSync, realpathSync } from 'node:fs'
import { createRequire } from 'node:module'
import { homedir } from 'node:os'
import { basename, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type * as nodePtyNs from 'node-pty'
import { SidebarError } from './wire.ts'
/** The node-pty module surface the registries consume (spawn/kill/resize/…). */
export type NodePtyModule = typeof nodePtyNs
/**
* The node-pty version range this plugin ships. MUST stay identical to the
* range DSH core declares (`@deepseek-ai/dsh-subprocess-local`): the same
* range keeps pnpm resolving both to one physical package.
*/
export const DSH_NODE_PTY_RANGE = '^1.1.0'
/**
* The WebSocket close-code-1011 reason the host sends when node-pty is
* unavailable. The client recognizes this exact marker and fetches the full
* repair details from `/sidebar/api/terminal.deps` (a WS close reason is
* capped at 123 bytes, so the command itself cannot ride the close frame).
*/
export const PTY_DEPS_MISSING = 'pty-deps-missing'
/** A require-compatible loader, injectable for tests. */
export type NodePtyRequire = (id: string) => unknown
const defaultRequire: NodePtyRequire = createRequire(import.meta.url)
type LoadResult = { ok: true; module: NodePtyModule } | { ok: false; cause: unknown }
let cached: LoadResult | undefined
/**
* Load node-pty once (synchronously) and cache the outcome. Returns null
* when the package or its native binding cannot be loaded; the cause stays
* queryable through {@link nodePtyLoadCause}. Never throws.
*/
export function loadNodePty(requireImpl: NodePtyRequire = defaultRequire): NodePtyModule | null {
if (cached === undefined) {
try {
cached = { ok: true, module: requireImpl('node-pty') as NodePtyModule }
} catch (cause) {
cached = { ok: false, cause }
}
}
return cached.ok ? cached.module : null
}
/** The recorded load failure (undefined when the load succeeded or never ran). */
export function nodePtyLoadCause(): unknown {
return cached !== undefined && !cached.ok ? cached.cause : undefined
}
/** Forget the cached outcome (tests only — a real reload is otherwise one-shot). */
export function resetNodePtyCache(): void {
cached = undefined
}
/** Load node-pty or throw the canonical degraded-mode error (class-constructor default). */
export function loadRequiredNodePty(): NodePtyModule {
const module = loadNodePty()
if (module === null) {
const cause = describeCause(nodePtyLoadCause())
throw new SidebarError(
'pty-deps-missing',
`node-pty (${DSH_NODE_PTY_RANGE}) failed to load: ${cause} — run the repair command shown in the terminal tab`,
503,
)
}
return module
}
/** Resolve a directory to its physical location (symlinked/link: installs). */
function realDir(file: string): string {
try {
return dirname(realpathSync(file))
} catch {
return dirname(file)
}
}
/** Walk up from `dir` looking for a DSH profile root (package.json + pnpm-workspace.yaml). */
function walkUp(dir: string, isRoot: (dir: string) => boolean): string | null {
let current = dir
for (let depth = 0; depth < 16; depth += 1) {
if (isRoot(current)) return current
const parent = dirname(current)
if (parent === current) break
current = parent
}
return null
}
/** Whether `dir` looks like a DSH profile root (the plugin lives under its node_modules). */
function isProfileRoot(dir: string): boolean {
return existsSync(join(dir, 'package.json')) && existsSync(join(dir, 'pnpm-workspace.yaml'))
}
/**
* Detect the DSH profile directory this plugin is installed into: the
* nearest ancestor of the plugin module that carries both `package.json`
* and `pnpm-workspace.yaml` (the profile root; the plugin resolves from the
* profile's node_modules). Falls back to `$DSH_HOME/profiles/web` (the
* standard web profile), then null.
*/
export function findProfileDir(fromFile: string = fileURLToPath(import.meta.url)): string | null {
const detected = walkUp(realDir(fromFile), isProfileRoot)
if (detected !== null) return detected
const home = process.env.DSH_HOME !== undefined && process.env.DSH_HOME.trim() !== ''
? process.env.DSH_HOME
: join(homedir(), '.dsh')
const web = join(home, 'profiles', 'web')
return isProfileRoot(web) ? realpathSync(web) : null
}
/** Whether `dir`'s package.json declares this plugin's name. */
function isPluginRoot(dir: string): boolean {
const file = join(dir, 'package.json')
if (!existsSync(file)) return false
try {
const parsed = JSON.parse(readFileSync(file, 'utf8')) as { name?: unknown }
return parsed.name === 'dsh-better-sidebar'
} catch {
return false
}
}
/** The plugin package root (walk-up from the module; works for lib/ and src/ layouts). */
export function findPluginRoot(fromFile: string = fileURLToPath(import.meta.url)): string | null {
return walkUp(realDir(fromFile), isPluginRoot)
}
/** Options for {@link buildRepairCommand}. */
export interface RepairCommandOptions {
/** The plugin package root (where scripts/install.sh / install.ps1 live). */
pluginRoot: string | null
/** The detected profile directory (null → the standard `web` profile). */
profileDir: string | null
/** Platform override for tests; defaults to the live process. */
platform?: NodeJS.Platform
}
/**
* The pasteable repair command for a broken node-pty install: rerun the
* plugin's own installer in `--repair` mode (idempotent: it re-writes the
* profile's `allowBuilds: node-pty: true` and re-installs/rebuilds the
* dependency). Falls back to DSH's plugin command when the scripts are not
* shipped (exotic layouts).
*/
export function buildRepairCommand(options: RepairCommandOptions): { command: string; note?: string } {
const { pluginRoot, profileDir } = options
const platform = options.platform ?? process.platform
const profileName = profileDir !== null ? basename(profileDir) : null
const profileArg = profileName !== null
? (platform === 'win32' ? ` -Profile "${profileName}"` : ` --profile "${profileName}"`)
: ''
if (pluginRoot !== null) {
if (platform === 'win32') {
const script = join(pluginRoot, 'scripts', 'install.ps1')
if (existsSync(script)) {
return { command: `powershell -ExecutionPolicy Bypass -File "${script}" -Repair${profileArg}` }
}
} else {
const script = join(pluginRoot, 'scripts', 'install.sh')
if (existsSync(script)) {
return { command: `bash "${script}" --repair${profileArg}` }
}
}
}
const name = profileName ?? 'web'
return {
command: `dsh plugin --profile "${name}" install`,
note: 'If pnpm 11 blocked node-pty\'s build script, ensure `allowBuilds: node-pty: true` in the profile\'s pnpm-workspace.yaml (the plugin\'s scripts/install.sh / install.ps1 --repair does this automatically).',
}
}
/** One-line human description of the recorded load cause. */
function describeCause(cause: unknown): string {
if (cause instanceof Error) return cause.message
return String(cause)
}
/** Structured status served by the `/sidebar/api/terminal.deps` endpoint. */
export type NodePtyDepsStatus =
| { ok: true }
| {
ok: false
/** The require-time error message (module missing, native binding broken…). */
cause: string
/** The pasteable repair command (terminal/cmd). */
command: string
/** The detected profile name (null when undetected → the command defaults to web). */
profile: string | null
/** Optional supplementary hint (fallback command only). */
note?: string
}
/** Current node-pty dependency status (loaded vs degraded + repair info). */
export function depsStatus(options: { fromFile?: string } = {}): NodePtyDepsStatus {
const module = loadNodePty()
if (module !== null) return { ok: true }
const pluginRoot = findPluginRoot(options.fromFile)
const profileDir = findProfileDir(options.fromFile)
const { command, note } = buildRepairCommand({ pluginRoot, profileDir })
return {
ok: false,
cause: describeCause(nodePtyLoadCause()),
command,
profile: profileDir !== null ? basename(profileDir) : null,
...(note !== undefined ? { note } : {}),
}
}
+92 -14
View File
@@ -10,7 +10,8 @@ import { chmodSync, existsSync } from 'node:fs'
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path'
import { createRequire } from 'node:module' import { createRequire } from 'node:module'
import { userInfo } from 'node:os' import { userInfo } from 'node:os'
import * as nodePty from 'node-pty' import type { IPty } from 'node-pty'
import { loadRequiredNodePty, type NodePtyModule } from './pty-deps.ts'
import { SidebarError } from './wire.ts' import { SidebarError } from './wire.ts'
/** Per-terminal transcript bound (bytes kept for replay). */ /** Per-terminal transcript bound (bytes kept for replay). */
@@ -52,7 +53,7 @@ export interface SidebarPty {
* the page-load hydrate race can attach the real cwd after the first * the page-load hydrate race can attach the real cwd after the first
* connect, and a shell in the wrong directory must not linger). */ * connect, and a shell in the wrong directory must not linger). */
cwd: string cwd: string
pty: nodePty.IPty pty: IPty
/** Output accumulated since spawn (bounded; head dropped when over the limit). */ /** Output accumulated since spawn (bounded; head dropped when over the limit). */
transcript: string transcript: string
/** Whether the top-level process exited (transcript stays replayable). */ /** Whether the top-level process exited (transcript stays replayable). */
@@ -71,6 +72,8 @@ export class PtyManager {
constructor( constructor(
private readonly shell: string, private readonly shell: string,
private readonly maxPerSession: number, private readonly maxPerSession: number,
/** The loaded node-pty module (injected so a broken install degrades instead of crashing the plugin). */
private readonly nodePty: NodePtyModule = loadRequiredNodePty(),
) {} ) {}
/** All live terminal keys of one session. */ /** All live terminal keys of one session. */
@@ -119,7 +122,7 @@ export class PtyManager {
sessionId, sessionId,
tabId, tabId,
cwd, cwd,
pty: nodePty.spawn(this.shell, shellSpawnArgs(), { pty: this.nodePty.spawn(this.shell, shellSpawnArgs(), {
name: 'xterm-256color', name: 'xterm-256color',
cols: Math.max(2, Math.floor(cols)), cols: Math.max(2, Math.floor(cols)),
rows: Math.max(2, Math.floor(rows)), rows: Math.max(2, Math.floor(rows)),
@@ -193,18 +196,93 @@ export class PtyManager {
} }
/** /**
* The interactive shell for this platform, resolved like a terminal * Inputs for {@link defaultShell} resolution. Every field is optional and
* emulator: an explicit `$SHELL` on the dsh process wins (deployment * defaults to the live process, which keeps the no-argument call sites
* override), then the account's login shell from passwd, then `/bin/bash`. * working while tests (and exotic embedders) can pin the platform, the
* The passwd step matters because service managers and container inits * environment, and the existence probe independently — the Windows chain
* often start dsh without `SHELL`, and the tab should still open the * never executes on the ubuntu CI runners, so it is only testable through
* user's login shell (e.g. zsh) instead of silently degrading to bash. * these injection points.
* Windows short-circuits to `powershell.exe` before any resolution.
*/ */
export function defaultShell(): string { export interface ShellResolutionOptions {
if (process.platform === 'win32') return 'powershell.exe' /** Platform override (defaults to `process.platform`). */
const envShell = process.env.SHELL platform?: NodeJS.Platform
if (envShell !== undefined && envShell.trim() !== '') return envShell /** Environment override; the resolver only reads SHELL, DSH_SIDEBAR_SHELL, PATH, ProgramW6432, ProgramFiles, LOCALAPPDATA. */
env?: NodeJS.ProcessEnv
/** Explicitly configured shell (the `shell` config field); wins over every automatic source. Empty means unset. */
explicit?: string
/** File-existence probe override (defaults to `existsSync`). */
exists?: (path: string) => boolean
}
/**
* Candidate directories that may contain a `pwsh.exe` on Windows: PATH
* entries first, then the well-known machine/user install locations
* (including preview channels and per-user MSI/portable layouts). The
* machine-scope search reads both `ProgramW6432` and `ProgramFiles` so a
* 32-bit Node process — whose `ProgramFiles` points at `(x86)` — still
* finds a 64-bit PowerShell 7 install. De-duped while preserving priority
* order.
*/
function windowsPwshCandidateDirs(env: NodeJS.ProcessEnv): string[] {
const dirs: string[] = []
const pathEntries = env.PATH
if (pathEntries !== undefined) {
// The win32 branch always uses the Windows PATH separator; hardcoding it
// keeps the function testable from POSIX runners without a delimiter
// injection point.
for (const entry of pathEntries.split(';')) {
const trimmed = entry.trim()
if (trimmed !== '') dirs.push(trimmed)
}
}
for (const programFiles of [env.ProgramW6432, env.ProgramFiles]) {
if (programFiles === undefined || programFiles.trim() === '') continue
dirs.push(join(programFiles, 'PowerShell', '7'))
dirs.push(join(programFiles, 'PowerShell', '7-preview'))
}
const localAppData = env.LOCALAPPDATA
if (localAppData !== undefined && localAppData.trim() !== '') {
dirs.push(join(localAppData, 'Microsoft', 'PowerShell', '7'))
dirs.push(join(localAppData, 'Microsoft', 'PowerShell', '7-preview'))
dirs.push(join(localAppData, 'Programs', 'PowerShell', '7'))
dirs.push(join(localAppData, 'Programs', 'PowerShell', '7-preview'))
}
return [...new Set(dirs)]
}
/**
* The interactive shell for this platform, resolved like a terminal
* emulator: an explicitly configured shell (the `shell` config field) wins,
* then `$SHELL` on POSIX (deployment override), then the account's login
* shell from passwd, then `/bin/bash`. The passwd step matters because
* service managers and container inits often start dsh without `SHELL`, and
* the tab should still open the user's login shell (e.g. zsh) instead of
* silently degrading to bash.
*
* Windows previously short-circuited to `powershell.exe` (the inbox 5.1)
* before any resolution, so PowerShell 7 users always got a legacy shell
* without `??`/`?.`/ternary and with poor ANSI/UTF-8 defaults. The Windows
* chain is now: explicit shell → `DSH_SIDEBAR_SHELL` env override → first
* `pwsh.exe` found on PATH or in a known install directory → the 5.1
* fallback (machines without PowerShell 7 keep working).
*/
export function defaultShell(options: ShellResolutionOptions = {}): string {
const platform = options.platform ?? process.platform
const env = options.env ?? process.env
const exists = options.exists ?? existsSync
const explicit = options.explicit
if (explicit !== undefined && explicit.trim() !== '') return explicit.trim()
if (platform === 'win32') {
const envShell = env.DSH_SIDEBAR_SHELL
if (envShell !== undefined && envShell.trim() !== '') return envShell.trim()
for (const dir of windowsPwshCandidateDirs(env)) {
const candidate = join(dir, 'pwsh.exe')
if (exists(candidate)) return candidate
}
return 'powershell.exe'
}
const envShell = env.SHELL
if (envShell !== undefined && envShell.trim() !== '') return envShell.trim()
// userInfo() throws when the uid has no passwd entry (rare chroots); // userInfo() throws when the uid has no passwd entry (rare chroots);
// without a login shell there is nothing better than the bash default. // without a login shell there is nothing better than the bash default.
try { try {
+1
View File
@@ -15,6 +15,7 @@ export type SidebarErrorCode =
| 'fs-error' | 'fs-error'
| 'git-error' | 'git-error'
| 'pty-error' | 'pty-error'
| 'pty-deps-missing'
| 'job-error' | 'job-error'
| 'settings-rejected' | 'settings-rejected'
| 'settings-conflict' | 'settings-conflict'
+15 -8
View File
@@ -155,7 +155,7 @@ describe('PluginListBody copy click (interactive)', () => {
vi.restoreAllMocks() vi.restoreAllMocks()
}) })
it('both the name button and the jump button open the repo in a NEW browser tab (window.open, not a link)', () => { it('the name and jump buttons open each catalog entry in a NEW browser tab (window.open, not a link)', () => {
const store = createSidebarStore() const store = createSidebarStore()
const service = createBetterSidebarService(store) const service = createBetterSidebarService(store)
const opened: Array<[string, string, string]> = [] const opened: Array<[string, string, string]> = []
@@ -171,14 +171,21 @@ describe('PluginListBody copy click (interactive)', () => {
act(() => { act(() => {
root.render(createElement(PluginListBody, { service, kind: 'viewer' })) root.render(createElement(PluginListBody, { service, kind: 'viewer' }))
}) })
// Two window.open buttons per catalog entry — the name button and the
// jump button. The expected count is catalog-driven so adding or
// removing recommended plugins keeps the test honest instead of
// pinning a hardcoded total.
const jumpButtons = [...container.querySelectorAll('button[aria-label^="Open:"]')] const jumpButtons = [...container.querySelectorAll('button[aria-label^="Open:"]')]
expect(jumpButtons).toHaveLength(2) // the name + the jump button expect(jumpButtons).toHaveLength(builtinViewerPlugins.length * 2)
act(() => { (jumpButtons[0] as HTMLButtonElement).click() }) for (const button of jumpButtons) {
act(() => { (jumpButtons[1] as HTMLButtonElement).click() }) act(() => { (button as HTMLButtonElement).click() })
expect(opened).toEqual([ }
[officeEntry.url, '_blank', 'noopener'], expect(opened).toEqual(
[officeEntry.url, '_blank', 'noopener'], builtinViewerPlugins.flatMap((entry) => [
]) [entry.url, '_blank', 'noopener'],
[entry.url, '_blank', 'noopener'],
]),
)
act(() => { root.unmount() }) act(() => { root.unmount() })
container.remove() container.remove()
} finally { } finally {
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import {
allLeaves, makeDefaultState, openTabInActivePane, reconcileAgentTerminals,
tabOpenIn, toggleBottomPanel,
} from '../src/client/state.ts'
describe('agent terminal reconciliation', () => {
it('adds tabs for new agent terminals', () => {
let s = makeDefaultState(400, true)
s = reconcileAgentTerminals(s, [
{ uuid: 'aaa-111', title: 'dev server' },
{ uuid: 'bbb-222', title: 'python repl' },
])
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(true)
// The titles are preserved.
const tabs = allLeaves(s.splits).flatMap(leaf => leaf.tabs)
const agentTab = tabs.find(t => t.id === 'agent:aaa-111')
expect(agentTab?.title).toBe('dev server')
expect(agentTab?.type).toBe('terminal')
})
it('removes tabs for agent terminals that vanished from the server list', () => {
let s = makeDefaultState(400, true)
s = reconcileAgentTerminals(s, [
{ uuid: 'aaa-111', title: 'keep' },
{ uuid: 'bbb-222', title: 'remove' },
])
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(true)
// Next push drops bbb-222.
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'keep' }])
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(false)
})
it('is a no-op (same reference) when the lists already match', () => {
let s = makeDefaultState(400, true)
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable' }])
const next = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable' }])
expect(next).toBe(s)
})
it('does not touch non-agent terminal tabs', () => {
let s = makeDefaultState(400, true)
s = openTabInActivePane(s, { id: 'terminal:1', type: 'terminal', title: 'UI Tab' })
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'agent' }])
expect(tabOpenIn(s, 'terminal:1')).toBe(true)
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
})
it('handles an empty server list (removes all agent tabs)', () => {
let s = makeDefaultState(400, true)
s = reconcileAgentTerminals(s, [
{ uuid: 'aaa-111', title: 'a' },
{ uuid: 'bbb-222', title: 'b' },
])
s = reconcileAgentTerminals(s, [])
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(false)
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(false)
})
it('lands new agent terminals in the active tree (bottom panel pane)', () => {
let s = makeDefaultState(400, true)
s = toggleBottomPanel(s)
const bottomPane = (s.bottomSplits as { id: string }).id
s = { ...s, activePane: bottomPane }
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'dev server' }])
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
expect(allLeaves(s.bottomSplits).flatMap(l => l.tabs).some(t => t.id === 'agent:aaa-111')).toBe(true)
expect(allLeaves(s.splits).flatMap(l => l.tabs).some(t => t.id === 'agent:aaa-111')).toBe(false)
})
})
+22 -6
View File
@@ -1,5 +1,5 @@
/** /**
* Built-in registration tests: the plugin registers 7 tabs and 6 file * Built-in registration tests: the plugin registers 6 tabs and 6 file
* viewers through the same service external plugins use (dogfooding); * viewers through the same service external plugins use (dogfooding);
* the catch-all `code` viewer, the NUL-sniffing `binary-download` viewer, * the catch-all `code` viewer, the NUL-sniffing `binary-download` viewer,
* and the html sandbox settings pin the registry's behavior. (Office * and the html sandbox settings pin the registry's behavior. (Office
@@ -24,21 +24,24 @@ function setup(): { service: ReturnType<typeof createBetterSidebarService>; stor
} }
describe('built-in tab registrations', () => { describe('built-in tab registrations', () => {
it('registers the 7 built-in tabs', () => { it('registers the 6 built-in tabs', () => {
const { service } = setup() const { service } = setup()
expect(service.getTabs().map(t => t.id).sort()).toEqual( expect(service.getTabs().map(t => t.id).sort()).toEqual(
['browser', 'diff', 'editor', 'explorer', 'git', 'subagent', 'terminal'], ['browser', 'diff', 'editor', 'git', 'subagent', 'terminal'],
) )
}) })
it('editor and diff are hidden from the + menu (opened by file-open / git view)', () => { it('only diff is hidden from the + menu; editor is the visible files window (order 10)', () => {
const { service } = setup() const { service } = setup()
expect(service.getTabs().filter(t => t.hidden).map(t => t.id).sort()).toEqual(['diff', 'editor']) expect(service.getTabs().filter(t => t.hidden).map(t => t.id)).toEqual(['diff'])
const editor = service.getTab('editor')
expect(editor?.hidden).toBe(false)
expect(editor?.order).toBe(10)
}) })
it('single-instance tabs use the single sugar', () => { it('single-instance tabs use the single sugar', () => {
const { service } = setup() const { service } = setup()
for (const id of ['explorer', 'git', 'subagent']) { for (const id of ['git', 'subagent']) {
expect(service.getTab(id)?.single).toBe(true) expect(service.getTab(id)?.single).toBe(true)
} }
}) })
@@ -49,6 +52,19 @@ describe('built-in tab registrations', () => {
expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent', 'autoOpenJobs']) expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent', 'autoOpenJobs'])
}) })
it('the editor tab declares its merged-mode (embedded file tree) setting', () => {
const { service } = setup()
const toggles = service.getTab('editor')?.settings?.toggles ?? []
expect(toggles.map(t => t.key)).toEqual(['editorExplorer'])
expect(toggles[0]?.title).toBeDefined()
expect(toggles[0]?.desc).toBeDefined()
// The merged mode is an iconed select (merged vs separate), not a switch.
expect(toggles[0]?.type).toBe('select')
const options = toggles[0]?.options ?? []
expect(options.map(o => o.value)).toEqual([true, false])
expect(options.every(o => o.icon !== undefined && o.title !== undefined)).toBe(true)
})
it('the terminal tab declares the model terminal-tools, auto-terminal and custom-font settings', () => { it('the terminal tab declares the model terminal-tools, auto-terminal and custom-font settings', () => {
const { service } = setup() const { service } = setup()
const toggles = service.getTab('terminal')?.settings?.toggles ?? [] const toggles = service.getTab('terminal')?.settings?.toggles ?? []
+258
View File
@@ -0,0 +1,258 @@
/**
* Drag-layout lane: the width drag must track the app shell 1:1 — the
* regression test for issue #92 ("主会话框左右抖动").
*
* The layout push squeezes `#root` via `margin-right: var(--dsh-sidebar-width)`
* (layout.css), and layout.css disables that margin's transition while a drag
* is live via `body[data-dsh-sidebar-dragging]`. If the transition stays
* active during the drag (or the conversation lags the panel edge), the
* conversation visibly shakes at pointer cadence. This spec drives a real
* pointer drag on the width strip while a requestAnimationFrame sampler
* records, per frame:
*
* - the strip's x (the panel edge),
* - the conversation column's right edge (`#root`'s margin push lands
* exactly there),
* - whether `body[data-dsh-sidebar-dragging]` is set,
* - `#root`'s computed transition property/duration.
*
* Then it asserts the drag contract:
* 1. the dragging attribute is present during the drag;
* 2. `#root`'s transition is disabled (none) during the drag;
* 3. the conversation edge follows the panel edge monotonically (no
* oscillation) and 1:1 (total travel within a rounding epsilon).
*
* The server is booted by scripts/e2e-mount.sh; this spec only loads the
* page (same contract as mount.e2e.ts, using its own workspace so the two
* lanes never race on seeding).
*/
import { mkdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test, expect, request, type APIRequestContext } from '@playwright/test'
const BASE_URL = process.env.DSH_E2E_URL
if (!BASE_URL) {
throw new Error('DSH_E2E_URL is not set — boot a DSH web instance with the plugin mounted and point this lane at it (see scripts/e2e-mount.sh)')
}
/** This lane's own workspace (distinct from mount.e2e.ts's, lanes run serially
* but against the same server — never share seed paths). */
const WORKSPACE_PATH = process.env.DSH_E2E_DRAG_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-drag-workspace')
let api: APIRequestContext
/** Seed one workspace + one session through the host's unary RPC surface. */
async function seedSession(): Promise<void> {
mkdirSync(WORKSPACE_PATH, { recursive: true })
writeFileSync(join(WORKSPACE_PATH, 'seed.txt'), 'drag lane\n')
const workspace = await api.post(`${BASE_URL}/api/workspace.create`, {
data: { type: 'client-request', rpcId: 'e2e-drag-workspace', method: 'workspace.create', payload: { path: WORKSPACE_PATH } },
})
expect(workspace.ok(), `workspace.create: ${workspace.status()} ${await workspace.text()}`).toBe(true)
const workspaceBody = (await workspace.json()) as {
result: { ok: true; value: { workspace: { workspaceId: string } } } | { ok: false; error: unknown }
}
expect(workspaceBody.result.ok).toBe(true)
const workspaceId = (workspaceBody.result as { value: { workspace: { workspaceId: string } } }).value.workspace.workspaceId
const session = await api.post(`${BASE_URL}/api/session.create`, {
data: { type: 'client-request', rpcId: 'e2e-drag-session', method: 'session.create', payload: { workspaceId } },
})
expect(session.ok(), `session.create: ${session.status()} ${await session.text()}`).toBe(true)
}
test.beforeAll(async () => {
api = await request.newContext({ baseURL: BASE_URL })
await seedSession()
})
test.afterAll(async () => {
await api?.dispose()
})
interface FrameSample {
t: number
stripX: number
convoRight: number
dragging: boolean
transitionProperty: string
transitionDuration: string
}
test('width drag tracks the shell 1:1 with transitions disabled (issue #92)', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
const sidebar = page.locator('[data-dsh-better-sidebar]')
await expect(sidebar).toBeAttached({ timeout: 90_000 })
// Dismiss whatever onboarding takeover is present (same dance as the mount
// lane), so the pointer can reach the strip without a masking overlay.
try {
await expect
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
.toBeGreaterThan(0)
} catch {
console.warn('[e2e-drag] no onboarding takeover appeared; proceeding')
}
for (let round = 0; round < 8; round++) {
let dismissed = false
for (const name of ['Continue', 'Configure later']) {
const button = page.getByRole('button', { name, exact: true }).first()
if ((await button.count()) === 0) continue
try {
await button.click({ timeout: 4_000 })
dismissed = true
await page.waitForTimeout(1_000)
} catch {
// Masked by a takeover stacked above; retry in the next round.
}
}
if (!dismissed) break
}
// openByDefault defaults OFF: a fresh session's panel starts collapsed, and
// the collapsed layout push still writes `--dsh-sidebar-width: 0px` — so the
// geometry checks below are meaningless until the panel is expanded through
// the toggle cluster.
const expandButton = sidebar.getByRole('button', { name: 'Expand sidebar' })
await expect(expandButton, 'the collapsed toggle cluster must offer the expand button').toHaveCount(1)
await expandButton.click()
// The layout push variable becomes live (non-zero) once the panel opens
// (session activation lags the shell render).
await expect
.poll(async () => {
const value = await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
return value !== '' && value !== '0px'
}, { timeout: 90_000 })
.toBe(true)
// The width drag strip is the panel's left-edge hit strip. There is no
// dedicated hook (the skinning contract is token-driven), so locate it
// semantically among the sidebar's `cursor: col-resize` elements — the files
// window's tree-dock handle matches too, but the panel strip is always the
// LEFTMOST one (the dock sits at the panel's right edge).
const locateStrip = `(() => {
const host = document.querySelector('[data-dsh-better-sidebar]')
if (host === null) return null
const boxes = [...host.querySelectorAll('*')]
.filter(el => getComputedStyle(el).cursor === 'col-resize')
.map(el => el.getBoundingClientRect())
.filter(r => r.width > 0 && r.height > 0)
.sort((a, b) => a.x - b.x)
const r = boxes[0]
if (r === undefined) return null
const varWidth = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
return {
x: r.x, y: r.y, width: r.width, height: r.height,
varWidth: Number.isNaN(varWidth) ? 0 : varWidth,
innerWidth: window.innerWidth,
}
})()`
type StripBox = { x: number; y: number; width: number; height: number; varWidth: number; innerWidth: number }
// The panel SLIDES IN from the right on expand: locating the strip before
// the open transition settles captures a mid-animation box at the viewport
// edge, and the drag lands off-panel. Wait until the strip sits at the
// pushed layout edge (its right edge ≈ innerWidth - the push variable).
await expect
.poll(async () => {
const box = await page.evaluate<StripBox | null>(locateStrip)
if (box === null) return false
return Math.abs((box.x + box.width) - (box.innerWidth - box.varWidth)) <= 8
}, { timeout: 30_000 })
.toBe(true)
const stripBox = await page.evaluate<StripBox | null>(locateStrip)
expect(stripBox, 'the width drag strip must be present (cursor: col-resize)').not.toBeNull()
// Instrument a per-frame sampler BEFORE the drag begins.
await page.evaluate(() => {
type Sample = FrameSample
const samples: Sample[] = []
// Same rule as the strip locator above: the LEFTMOST col-resize element
// inside the sidebar host (the tree-dock handle is further right).
const host = document.querySelector('[data-dsh-better-sidebar]')
const strip = host === null
? null
: [...host.querySelectorAll<HTMLElement>('*')]
.filter(el => getComputedStyle(el).cursor === 'col-resize')
.sort((a, b) => a.getBoundingClientRect().x - b.getBoundingClientRect().x)[0]
// The conversation column: the grid item the layout push squeezes (the
// same nth-child(2) layout.css targets for the vertical push; its right
// edge is where the width push lands).
const center = document.querySelector('#root > div[data-slot="root"] > div > div:nth-child(2)')
const root = document.querySelector('#root') as HTMLElement
const loop = (): void => {
const s = strip?.getBoundingClientRect() ?? { left: 0 }
const c = center?.getBoundingClientRect() ?? { left: 0, right: 0 }
const cs = getComputedStyle(root)
samples.push({
t: performance.now(),
stripX: s.left,
convoRight: c.right,
dragging: document.body.hasAttribute('data-dsh-sidebar-dragging'),
transitionProperty: cs.transitionProperty,
transitionDuration: cs.transitionDuration,
})
requestAnimationFrame(loop)
}
requestAnimationFrame(loop)
;(window as unknown as { __dragSamples: Sample[] }).__dragSamples = samples
})
const startX = stripBox!.x + stripBox!.width / 2
const startY = stripBox!.y + Math.min(120, stripBox!.height / 2 + 60)
await page.mouse.move(startX, startY)
await page.mouse.down()
// Drag LEFT in steps (widens the panel): the conversation edge must move
// LEFT in lockstep with the panel edge, monotonically, no oscillation.
for (let i = 1; i <= 14; i++) {
await page.mouse.move(startX - i * 10, startY, { steps: 2 })
await page.waitForTimeout(40)
}
await page.mouse.up()
await page.waitForTimeout(400)
const samples = await page.evaluate(
() => (window as unknown as { __dragSamples: FrameSample[] }).__dragSamples,
)
expect(samples.length, 'the frame sampler must have collected frames').toBeGreaterThan(20)
// The drag must actually have moved the panel (sanity: the store committed).
const first = samples.find(s => s.dragging)
const last = [...samples].reverse().find(s => s.dragging)
expect(first, 'the dragging attribute must appear during the drag').toBeDefined()
expect(last!.stripX, 'the panel edge must have moved during the drag').toBeLessThan(first!.stripX - 40)
// The conversation-column selector must have matched (the push lands on it).
expect(last!.convoRight, 'the conversation edge must have moved with the drag').toBeLessThan(first!.convoRight - 40)
// Contract 1 + 2: while dragging, the body attribute is set and #root's
// margin transition is disabled (computed `transition: none` reads as
// transition-property "none" with 0s duration; the non-dragging rule would
// compute to "margin-right" with the theme duration).
const draggingSamples = samples.filter(s => s.dragging)
expect(draggingSamples.length).toBeGreaterThan(5)
for (const sample of draggingSamples) {
expect(sample.transitionProperty, 'the margin transition must be off while dragging').toBe('none')
expect(sample.transitionDuration, 'the margin transition must be off while dragging').toBe('0s')
}
// Contract 3: monotonic, 1:1 tracking. During a leftward drag both the
// strip x and the conversation right edge decrease; allow one frame of
// rAF-batching staleness (0 delta), never a reversal.
const tracked = draggingSamples.filter(s => s.t > first!.t)
for (let i = 1; i < tracked.length; i++) {
const stripDelta = tracked[i]!.stripX - tracked[i - 1]!.stripX
const convoDelta = tracked[i]!.convoRight - tracked[i - 1]!.convoRight
expect(stripDelta, 'the strip must move left during the drag').toBeLessThanOrEqual(2)
expect(
convoDelta,
`conversation edge reversed while dragging (jitter): strip ${stripDelta}px, conversation ${convoDelta}px`,
).toBeLessThanOrEqual(2)
}
// Total travel in lockstep (rounding + one-frame staleness tolerance).
const stripTravel = first!.stripX - last!.stripX
const convoTravel = first!.convoRight - last!.convoRight
expect(Math.abs(convoTravel - stripTravel), 'conversation must track the panel edge 1:1').toBeLessThanOrEqual(8)
})
+64 -22
View File
@@ -13,10 +13,13 @@
* plugin's `[data-dsh-better-sidebar]` host mount; * plugin's `[data-dsh-better-sidebar]` host mount;
* 3. asserts the plugin's crash markers never appear (no RenderBoundary / * 3. asserts the plugin's crash markers never appear (no RenderBoundary /
* fail() strips, no `pageerror`, no plugin-prefixed console errors); * fail() strips, no `pageerror`, no plugin-prefixed console errors);
* 4. sweeps every built-in tab (Explorer / Source Control / Tasks / * 4. expands the collapsed panel (openByDefault defaults off), sweeps every
* Terminal / Browser) — including the lazily-fetched terminal chunk — * built-in tab (Files / Source Control / Tasks / Terminal / Browser) —
* and then opens a seeded file through the Explorer to force the * including the lazily-fetched terminal chunk — and then opens a seeded
* lazily-fetched editor chunk (client-editor.js) to load as well. * file through the Files window's tree (in-place mode: the seeded home
* tab itself switches to the file, no new tab), while a response wait
* armed before goto proves the lazily-fetched editor chunk
* (client-editor.js) loaded.
* *
* Deterministic by construction: every wait is on a DOM/network marker, the * Deterministic by construction: every wait is on a DOM/network marker, the
* suite is serial (one server instance), and any crash trips the very next * suite is serial (one server instance), and any crash trips the very next
@@ -35,8 +38,8 @@ if (!BASE_URL) {
/** Workspace the sidebar renders against (created by the lane's seeding). */ /** Workspace the sidebar renders against (created by the lane's seeding). */
const WORKSPACE_PATH = process.env.DSH_E2E_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-workspace') const WORKSPACE_PATH = process.env.DSH_E2E_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-workspace')
/** A file seeded into the workspace, opened through the Explorer to force the /** A file seeded into the workspace, opened through the Files window's tree to
* lazily-packed editor chunk (client-editor.js) to load. */ * exercise the file-open path (editor chunk = client-editor.js). */
const SEEDED_FILE = 'hello.txt' const SEEDED_FILE = 'hello.txt'
/** /**
@@ -47,7 +50,7 @@ const SEEDED_FILE = 'hello.txt'
const CRASH_STRIP_PATTERNS = [/^dsh-better-sidebar:/, /^\[dsh-better-sidebar\]/] const CRASH_STRIP_PATTERNS = [/^dsh-better-sidebar:/, /^\[dsh-better-sidebar\]/]
/** Built-in tab titles the sweep drives (en-US copy; follows DSH locale). */ /** Built-in tab titles the sweep drives (en-US copy; follows DSH locale). */
const BUILTIN_TABS = ['Explorer', 'Source Control', 'Tasks', 'Terminal', 'Browser'] const BUILTIN_TABS = ['Files', 'Source Control', 'Tasks', 'Terminal', 'Browser']
let api: APIRequestContext let api: APIRequestContext
@@ -91,6 +94,15 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
// Load the shell. The app renders into #root; the plugin appends its own // Load the shell. The app renders into #root; the plugin appends its own
// [data-dsh-better-sidebar] host once its client half activates. // [data-dsh-better-sidebar] host once its client half activates.
//
// The editor chunk (client-editor.js) loads as soon as ANY files-window tab
// renders — the seeded home tab mounts the moment the panel expands, long
// before the tree click below — so the response wait must be armed BEFORE
// goto, or it misses the fetch and times out.
const editorChunk = page.waitForResponse(
(response) => response.url().includes('/sidebar/bundle/editor.js'),
{ timeout: 120_000 },
)
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }) await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 }) await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
const sidebar = page.locator('[data-dsh-better-sidebar]') const sidebar = page.locator('[data-dsh-better-sidebar]')
@@ -134,6 +146,22 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
const tabBar = sidebar.locator('[title]') const tabBar = sidebar.locator('[title]')
await expect(tabBar.first()).toBeAttached({ timeout: 90_000 }) await expect(tabBar.first()).toBeAttached({ timeout: 90_000 })
// openByDefault defaults OFF: a fresh session's panel starts collapsed.
// Expand it through the toggle cluster before the layout push can apply.
const expandButton = sidebar.getByRole('button', { name: 'Expand sidebar' })
await expect(expandButton, 'the collapsed toggle cluster must offer the expand button').toHaveCount(1)
await expandButton.click()
// The skinning contract is token-driven (AGENTS.md §8): the panels consume
// `--dsw-alias-bg-layer-1`, so switching a skin re-skins the sidebar with
// no per-skin code. The layout push variable must be live once the panel
// mounts (its absence would mean the panel never opened with the session).
await expect
.poll(async () => (
await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
), { timeout: 90_000 })
.not.toBe('')
// Crash-marker assertions shared by every step. // Crash-marker assertions shared by every step.
const assertNoCrash = async (): Promise<void> => { const assertNoCrash = async (): Promise<void> => {
await expect await expect
@@ -169,22 +197,36 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
await assertNoCrash() await assertNoCrash()
} }
// The editor tab is hidden from the + menu — its CodeMirror chunk // The editor chunk (client-editor.js) only loads when a files-window tab
// (client-editor.js) only loads when a file is opened. Exercise that path // renders. Exercise the file-open path explicitly through the Files window's
// explicitly: reopen Explorer, open the seeded file, and require the chunk // own tree: the seeded home tab ("Files") is already open with its tree
// round-trip, so a missing/corrupt editor chunk fails the lane. // panel pinned — activate it from the tab strip, open the seeded file, and
await newTabButton.click() // require the chunk round-trip (armed before goto), so a missing/corrupt
const explorerItem = page.getByRole('menuitem', { name: 'Explorer' }).first() // editor chunk fails the lane.
await expect(explorerItem, 'Explorer must be re-openable for the editor-chunk probe').toHaveCount(1) // Tab-strip tabs carry `draggable="true"`; the always-mounted (hidden)
await explorerItem.click() // bottom panel's empty-pane welcome cards repeat the + menu labels with
const editorChunk = page.waitForResponse( // `title="Files"`, so a bare `[title="Files"]` match is ambiguous.
(response) => response.url().includes('/sidebar/bundle/editor.js'), const filesTab = sidebar.locator('[title="Files"][draggable="true"]').first()
{ timeout: 30_000 }, await expect(filesTab, 'the seeded files-window home tab must be in the tab strip').toHaveCount(1)
) await filesTab.click()
const fileRow = sidebar.locator(`[role="button"][title$="${SEEDED_FILE}"]`) // Inactive tabs stay mounted (display:none); only the ACTIVE files
await expect(fileRow, `the seeded "${SEEDED_FILE}" file must appear in the Explorer tree`).toHaveCount(1, { timeout: 30_000 }) // window's embedded tree is visible — match the visible row.
await fileRow.click() const fileRow = sidebar.locator(`[role="button"][title$="${SEEDED_FILE}"]:visible`)
await expect(fileRow, `the seeded "${SEEDED_FILE}" file must appear in the files window's tree`).toHaveCount(1, { timeout: 30_000 })
// Click near the row's LEFT edge: hovering reveals an @-reference button at
// the row's right end, and a center click on a narrow dock lands on it
// (referencing the file into the composer instead of opening it).
await fileRow.click({ position: { x: 8, y: 8 } })
await editorChunk await editorChunk
// In-place mode (editorExplorer default): the SAME tab switches to the
// file — its title is rewritten, no new tab appears. One "Files" tab
// remains: the second files window the sweep opened via the + menu.
await expect(
sidebar.locator('[title="Files"][draggable="true"]'),
'in-place mode rewrites the activated home tab instead of opening a new one',
).toHaveCount(1)
const pathInput = sidebar.locator('input[placeholder^="File path"]:visible')
await expect(pathInput, 'the files window header path input shows the opened file').toHaveValue(new RegExp(`${SEEDED_FILE}$`))
await page.waitForTimeout(1_500) await page.waitForTimeout(1_500)
await assertNoCrash() await assertNoCrash()
+286
View File
@@ -0,0 +1,286 @@
/**
* EditorHost (the files window): in merged (in-place) mode a path-less tab
* renders the empty-state hint with the tree dock open, and the header's
* tree toggle persists its flag through ctx.betterSidebar.updateTab
* (meta.treeOpen rides the tab's persisted layout). The editorExplorer pref
* controls FILE-OPEN behavior — in-place rewrites the current tab via
* updateTab, split opens a per-path dedupe tab via openSidebarFile — and in
* split mode a PATH-LESS window becomes the standalone explorer (tree panel
* only, no editor chrome); file tabs keep the full chrome in both modes.
*/
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { createElement, useEffect } from 'react'
import { createRoot } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import type { Context } from '../src/context-types.ts'
import { EditorHost } from '../src/client/EditorHost.tsx'
import { createBetterSidebarService } from '../src/client/service.ts'
import { allLeaves, createSidebarStore, type SidebarTab } from '../src/client/state.ts'
// The act() environment flag (React 18.2 reads it before flushing effects).
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
/** A store with the seeded editor-home tab (default prefs: in-place mode). */
function setup(): {
store: ReturnType<typeof createSidebarStore>
ctx: Context
homeTab: () => SidebarTab
} {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
// The openTab path needs a registered editor descriptor (dedupe by path).
service.registerTab({ id: 'editor', title: 'Editor', dedupeKey: (tab) => tab.path, component: () => null })
store.setSession('editor-home-session')
const homeTab = (): SidebarTab =>
allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
.find(tab => tab.type === 'editor' && tab.path === undefined)!
// openSidebarFile reads the session cwd from ctx.sessions.
const sessionsSnapshot = { byId: { 'editor-home-session': { cwd: '/tmp' } }, current: 'editor-home-session' }
const ctx = {
betterSidebar: service,
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } },
} as unknown as Context
return { store, ctx, homeTab }
}
/** Mount the host for one tab; returns the container and an unmount helper. */
function mountHost(ctx: Context, store: ReturnType<typeof createSidebarStore>, tab: () => SidebarTab): {
container: HTMLDivElement
rerender: () => void
unmount: () => void
} {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
const render = (): void => {
root.render(createElement(EditorHost, {
ctx,
store,
scope: { sessionId: 'editor-home-session' },
tab: tab(),
expanded: [],
onToggleDir: () => {},
onReferenceFile: () => {},
}))
}
act(render)
return {
container,
// The real app re-renders the host with the fresh tab on every store
// change (Sidebar subscribes); mirror that after mutating the store.
rerender: () => { act(render) },
unmount: () => {
act(() => { root.unmount() })
container.remove()
},
}
}
/** Type into the controlled path input (native setter) and press Enter. */
function typeAndCommit(input: HTMLInputElement, value: string): void {
act(() => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
})
act(() => {
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
})
}
describe('EditorHost (files window)', () => {
it('a path-less tab renders the empty-state hint with the tree panel open', () => {
const { store, ctx, homeTab } = setup()
const { container, unmount } = mountHost(ctx, store, homeTab)
try {
const html = container.innerHTML
// The empty-state hint renders instead of the viewer loading flow.
expect(html).toContain('Pick a file from the tree panel')
expect(html).not.toContain('Loading…')
// The header carries the path input and the pressed tree toggle; the
// docked panel (search box) is open by default for path-less tabs.
expect(container.querySelector('input')).not.toBeNull()
const toggle = container.querySelector('button[aria-pressed]')
expect(toggle?.getAttribute('aria-pressed')).toBe('true')
// No cwd: the embedded tree renders its no-session placeholder
// instead of touching the network.
expect(html).toContain('Select a conversation')
} finally {
unmount()
}
})
it('the tree toggle persists meta.treeOpen through updateTab', () => {
const { store, ctx, homeTab } = setup()
expect(homeTab().meta).toEqual({ treeOpen: true })
const { container, rerender, unmount } = mountHost(ctx, store, homeTab)
try {
act(() => {
container.querySelector('button[aria-pressed]')!
.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(homeTab().meta).toEqual({ treeOpen: false })
// The store change re-renders the host with the fresh tab (Sidebar's
// subscription in the real app); the second click flips it back.
rerender()
expect(container.querySelector('button[aria-pressed]')?.getAttribute('aria-pressed')).toBe('false')
act(() => {
container.querySelector('button[aria-pressed]')!
.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(homeTab().meta).toEqual({ treeOpen: true })
} finally {
unmount()
}
})
it('in-place mode: the path input Enter switches the CURRENT tab (stable id, meta kept)', () => {
const { store, ctx, homeTab } = setup()
const { container, unmount } = mountHost(ctx, store, homeTab)
try {
const before = homeTab()
typeAndCommit(container.querySelector('input')!, '/tmp/a.ts')
// The same tab id now carries the file (homeTab's path-less finder no
// longer matches — look the tab up by id).
const after = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
.find(tab => tab.id === before.id)!
expect(after.id).toBe(before.id)
expect(after.path).toBe('/tmp/a.ts')
expect(after.title).toBe('a.ts')
expect(after.meta).toEqual({ treeOpen: true })
// No new tab landed.
expect(allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)).toHaveLength(1)
} finally {
unmount()
}
})
it('split mode: a file tab\'s path input Enter opens a NEW per-path tab; the source tab keeps its path', () => {
const { store, ctx } = setup()
store.setPrefs({ ...store.getPrefs(), editorExplorer: false })
ctx.betterSidebar.openTab({ type: 'editor', title: 'a.ts', path: '/tmp/a.ts', id: 'editor:/tmp/a.ts' })
const fileTab = (): SidebarTab =>
allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
.find(tab => tab.path === '/tmp/a.ts')!
const { container, unmount } = mountHost(ctx, store, fileTab)
try {
typeAndCommit(container.querySelector('input[placeholder^="File path"]')!, '/tmp/b.ts')
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
// home + a.ts + b.ts
expect(tabs).toHaveLength(3)
expect(fileTab().path).toBe('/tmp/a.ts')
const opened = tabs.find(tab => tab.path === '/tmp/b.ts')!
expect(opened.type).toBe('editor')
expect(opened.title).toBe('b.ts')
expect(opened.id).toBe('editor:/tmp/b.ts')
} finally {
unmount()
}
})
it('split mode: the path-less window is the standalone explorer (tree only, no chrome)', () => {
const { store, ctx, homeTab } = setup()
store.setPrefs({ ...store.getPrefs(), editorExplorer: false })
const { container, unmount } = mountHost(ctx, store, homeTab)
try {
// No editor chrome: no path input, no tree toggle, no resize handle.
expect(container.querySelector('input[placeholder^="File path"]')).toBeNull()
expect(container.querySelector('button[aria-pressed]')).toBeNull()
expect(container.querySelector('[role="separator"]')).toBeNull()
// The tree panel fills the whole window — its search box is the only
// input, and (no cwd) the tree shows its no-session placeholder.
expect(container.querySelector('input[placeholder^="Search files"]')).not.toBeNull()
expect(container.innerHTML).toContain('Select a conversation')
} finally {
unmount()
}
})
it('split mode: a file tab keeps the full chrome (path input + tree toggle + dock)', () => {
const { store, ctx } = setup()
store.setPrefs({ ...store.getPrefs(), editorExplorer: false })
ctx.betterSidebar.openTab({
type: 'editor', title: 'a.ts', path: '/tmp/a.ts', id: 'editor:/tmp/a.ts', meta: { treeOpen: true },
})
const fileTab = (): SidebarTab =>
allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
.find(tab => tab.path === '/tmp/a.ts')!
const { container, unmount } = mountHost(ctx, store, fileTab)
try {
expect(container.querySelector('input[placeholder^="File path"]')).not.toBeNull()
expect(container.querySelector('button[aria-pressed]')?.getAttribute('aria-pressed')).toBe('true')
expect(container.querySelector('[role="separator"]')).not.toBeNull()
} finally {
unmount()
}
})
it('dragging the panel edge resizes the dock and persists meta.treeWidth on release', () => {
const { store, ctx, homeTab } = setup()
const { container, unmount } = mountHost(ctx, store, homeTab)
try {
const handle = container.querySelector('[role="separator"]')!
expect(handle).not.toBeNull()
// The dock starts at the default width.
const dock = handle.parentElement!
expect(dock.style.width).toBe('240px')
// Drag the left edge LEFT by 100px → the right-docked panel widens.
// Pointer capture keeps move/up on the handle (jsdom: MouseEvent with
// pointer* type names; setPointerCapture is absent and skipped).
act(() => {
handle.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 300 }))
handle.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 200 }))
})
expect(dock.style.width).toBe('340px')
// Release: the drag state clears and the width persists on the tab.
act(() => { handle.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 200 })) })
expect(homeTab().meta).toEqual({ treeOpen: true, treeWidth: 340 })
} finally {
unmount()
}
})
it('the header hosts the viewer toolbar (mode toggle / dirty dot / save)', () => {
const { store, ctx } = setup()
const service = ctx.betterSidebar
const calls: string[] = []
// A viewer with a hoisted toolbar (the TextEditor contract): register
// commands and report the state once on mount.
service.registerFileViewer({
id: 'test:fake',
exts: ['fake'],
fetchStrategy: 'none',
component: (viewerProps) => {
useEffect(() => {
viewerProps.onToolbarControls?.({
setMode: (next) => { calls.push(`mode:${next}`) },
save: () => { calls.push('save') },
})
viewerProps.onToolbarState?.({ modes: true, mode: 'preview', dirty: true, editable: true, saveState: 'idle' })
return () => { viewerProps.onToolbarControls?.(null) }
}, [])
return null
},
})
service.openTab({ type: 'editor', title: 'x.fake', path: '/tmp/x.fake', id: 'editor:/tmp/x.fake' })
const fileTab = (): SidebarTab =>
allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
.find(tab => tab.path === '/tmp/x.fake')!
const { container, unmount } = mountHost(ctx, store, fileTab)
try {
// Mode toggle + dirty dot + save button sit in the header row.
const header = container.querySelector('input')!.parentElement!
const buttons = [...header.querySelectorAll('button')]
expect(buttons.map(b => b.textContent)).toContain('Preview')
expect(buttons.map(b => b.textContent)).toContain('Edit')
expect(header.querySelector('button[aria-label="Save"]')).not.toBeNull()
expect(header.querySelector('[title="Unsaved"]')).not.toBeNull()
// The header commands reach the viewer's registered controls.
act(() => { buttons.find(b => b.textContent === 'Edit')!.click() })
act(() => { header.querySelector<HTMLButtonElement>('button[aria-label="Save"]')!.click() })
expect(calls).toEqual(['mode:edit', 'save'])
} finally {
unmount()
}
})
})
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { isImageExt } from '../src/client/image-types.ts'
import { extOf, languageKeyForExt } from '../src/client/lang.ts'
import { isPdfExt } from '../src/client/pdf-types.ts'
describe('editor language mapping', () => {
it('derives extensions from paths', () => {
expect(extOf('/a/b/main.tsx')).toBe('tsx')
expect(extOf('README.MD')).toBe('md')
expect(extOf('/a/b/.gitignore')).toBe('gitignore')
expect(extOf('noext')).toBe('')
})
it('maps common extensions to languages and falls back to plain text', () => {
expect(languageKeyForExt('tsx')).toBe('tsx')
expect(languageKeyForExt('js')).toBe('js')
expect(languageKeyForExt('py')).toBe('python')
expect(languageKeyForExt('yaml')).toBe('yaml')
expect(languageKeyForExt('sh')).toBe('shell')
expect(languageKeyForExt('md')).toBe('md')
expect(languageKeyForExt('cs')).toBe('csharp')
expect(languageKeyForExt('kt')).toBe('kotlin')
expect(languageKeyForExt('swift')).toBe('swift')
expect(languageKeyForExt('txt')).toBeNull()
expect(languageKeyForExt('log')).toBeNull()
expect(languageKeyForExt('')).toBeNull()
})
})
describe('pdf preview kind', () => {
it('routes only .pdf to the browser-native preview', () => {
expect(isPdfExt('.pdf')).toBe(true)
expect(isPdfExt('.PDF')).toBe(false)
expect(isPdfExt('.docx')).toBe(false)
expect(isPdfExt('')).toBe(false)
})
})
describe('image preview kind', () => {
it('routes supported image extensions before binary probing', () => {
expect(isImageExt('.png')).toBe(true)
expect(isImageExt('.jpg')).toBe(true)
expect(isImageExt('.svg')).toBe(true)
expect(isImageExt('.avif')).toBe(true)
expect(isImageExt('.pdf')).toBe(false)
expect(isImageExt('')).toBe(false)
})
})
+151
View File
@@ -0,0 +1,151 @@
/**
* fs-search: the host's recursive file-name search behind the editor side
* panel's search box. Matches are case-insensitive name substrings, reported
* RELATIVE to the root ('/'-separated); `.git` directories are skipped,
* symlinked directories are never descended (cycle safety), and the
* maxMatches/maxVisited budgets stop a runaway walk with `truncated: true`.
*/
import { describe, expect, it } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { searchFiles } from '../src/fs-search.ts'
/**
* Symlink creation needs extra privileges on Windows; the symlink case skips
* there rather than fails (mirror of the fs-tree symlink spec).
*/
const canSymlink = (() => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-search-probe-'))
try {
symlinkSync('target', join(dir, 'link'))
return true
} catch {
return false
} finally {
rmSync(dir, { recursive: true, force: true })
}
})()
/** A scratch tree: nested matches, a .git dir, and unrelated noise. */
function makeFixture(): string {
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-search-'))
mkdirSync(join(dir, 'src'))
mkdirSync(join(dir, 'docs'))
mkdirSync(join(dir, '.git'))
mkdirSync(join(dir, '.git', 'objects'))
writeFileSync(join(dir, 'README.md'), 'readme')
writeFileSync(join(dir, 'src', 'Index.TS'), 'code')
writeFileSync(join(dir, 'src', 'util.ts'), 'code')
writeFileSync(join(dir, 'docs', 'guide.md'), 'doc')
writeFileSync(join(dir, '.git', 'config'), 'git-internal')
writeFileSync(join(dir, '.git', 'objects', 'readme-pack'), 'git-internal')
return dir
}
describe('fs-search', () => {
it('matches name substrings and reports root-relative /-separated paths', async () => {
const dir = makeFixture()
try {
const result = await searchFiles(dir, 'util')
expect(result).toEqual({ matches: ['src/util.ts'], truncated: false })
// A multi-level match list is sorted and relative (never absolute).
const md = await searchFiles(dir, '.md')
expect(md.truncated).toBe(false)
expect(md.matches).toEqual(['README.md', 'docs/guide.md'])
for (const match of md.matches) {
expect(match.startsWith(dir)).toBe(false)
expect(match).not.toContain('\\')
}
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('matches case-insensitively on the entry name', async () => {
const dir = makeFixture()
try {
expect((await searchFiles(dir, 'index.ts')).matches).toEqual(['src/Index.TS'])
expect((await searchFiles(dir, 'INDEX.TS')).matches).toEqual(['src/Index.TS'])
// Directory names match too (the client can hint where matches live).
expect((await searchFiles(dir, 'SRC')).matches).toEqual(['src'])
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('never descends into .git directories', async () => {
const dir = makeFixture()
try {
// 'readme' would hit .git/objects/readme-pack if the walk entered .git.
expect((await searchFiles(dir, 'readme')).matches).toEqual(['README.md'])
expect((await searchFiles(dir, 'config')).matches).toEqual([])
expect((await searchFiles(dir, '.git')).matches).toEqual([])
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('an empty (or whitespace) query matches nothing without walking', async () => {
const dir = makeFixture()
try {
expect(await searchFiles(dir, '')).toEqual({ matches: [], truncated: false })
expect(await searchFiles(dir, ' ')).toEqual({ matches: [], truncated: false })
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it.skipIf(!canSymlink)('does not descend into symlinked directories (cycle safety)', async () => {
const dir = makeFixture()
try {
// A link back to the root would loop forever if descended; a link to
// src would duplicate its matches. Neither must be entered.
symlinkSync(dir, join(dir, 'loop'))
symlinkSync(join(dir, 'src'), join(dir, 'src-link'))
const result = await searchFiles(dir, 'util')
expect(result).toEqual({ matches: ['src/util.ts'], truncated: false })
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('stops with truncated: true when the match budget is exceeded', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-search-cap-'))
try {
for (let index = 0; index < 5; index += 1) {
writeFileSync(join(dir, `match-${index}.txt`), 'x')
}
const result = await searchFiles(dir, 'match', { maxMatches: 2 })
expect(result.truncated).toBe(true)
expect(result.matches.length).toBe(2)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('stops with truncated: true when the visited budget is exceeded', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-search-visited-'))
try {
for (let index = 0; index < 5; index += 1) {
writeFileSync(join(dir, `file-${index}.txt`), 'x')
}
// The walk visits more entries than the budget allows and gives up.
const result = await searchFiles(dir, 'nomatch', { maxVisited: 3 })
expect(result.truncated).toBe(true)
expect(result.matches).toEqual([])
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('an unreadable root yields no matches instead of throwing', async () => {
const dir = makeFixture()
try {
const missing = join(dir, 'does-not-exist')
expect(await searchFiles(missing, 'x')).toEqual({ matches: [], truncated: false })
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})
+113
View File
@@ -0,0 +1,113 @@
/**
* fs-tree symlink listing: the explorer must show a symlink as what it
* points at (a symlink to a directory expands like a directory) and flag
* links whose target is missing. The host listing keeps the probe cheap:
* only entries that are actually symlinks are stat'ed.
*/
import { describe, expect, it } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { listDirectory } from '../src/fs-tree.ts'
/**
* Symlink creation needs extra privileges on Windows; skip the suite there
* rather than fail (mirror of the smoke spec's platform-tolerant fixtures).
*/
const canSymlink = (() => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-symlink-probe-'))
try {
symlinkSync('target', join(dir, 'link'))
return true
} catch {
return false
} finally {
rmSync(dir, { recursive: true, force: true })
}
})()
/** A scratch level with real entries plus three symlinks (dir/file/dangling). */
function makeFixture(): string {
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-symlink-'))
mkdirSync(join(dir, 'real-dir'))
writeFileSync(join(dir, 'real-file.txt'), 'content')
symlinkSync(join(dir, 'real-dir'), join(dir, 'link-to-dir'))
symlinkSync(join(dir, 'real-file.txt'), join(dir, 'link-to-file'))
symlinkSync(join(dir, 'missing-target'), join(dir, 'broken-link'))
return dir
}
describe.skipIf(!canSymlink)('fs-tree symlink listing', () => {
it('reports a symlink to a directory as an expandable directory', async () => {
const dir = makeFixture()
try {
const listing = await listDirectory(dir)
const row = listing.entries.find(entry => entry.name === 'link-to-dir')
expect(row).toMatchObject({ isDir: true, isSymlink: true, broken: false })
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('reports a symlink to a file as a file with a link badge', async () => {
const dir = makeFixture()
try {
const listing = await listDirectory(dir)
const row = listing.entries.find(entry => entry.name === 'link-to-file')
expect(row).toMatchObject({ isDir: false, isSymlink: true, broken: false })
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('flags a dangling symlink as broken', async () => {
const dir = makeFixture()
try {
const listing = await listDirectory(dir)
const row = listing.entries.find(entry => entry.name === 'broken-link')
expect(row).toMatchObject({ isDir: false, isSymlink: true, broken: true })
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('leaves regular entries unmarked and sorts symlinked dirs with the directories', async () => {
const dir = makeFixture()
try {
const listing = await listDirectory(dir)
const realDir = listing.entries.find(entry => entry.name === 'real-dir')
const realFile = listing.entries.find(entry => entry.name === 'real-file.txt')
expect(realDir).toMatchObject({ isDir: true, isSymlink: false, broken: false })
expect(realFile).toMatchObject({ isDir: false, isSymlink: false, broken: false })
// Directory-first ordering counts a symlinked directory as a directory.
const dirs = listing.entries.filter(entry => entry.isDir).map(entry => entry.name)
expect(dirs).toEqual(['link-to-dir', 'real-dir'])
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('classifies every row correctly across a symlink-heavy level (bounded probe)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-symlink-many-'))
mkdirSync(join(dir, 'real-dir'))
writeFileSync(join(dir, 'real-file.txt'), 'content')
try {
// More links than the probe concurrency cap exercises the worker pool;
// every directory link must still classify as a directory and every
// file link as a file (order is already checked by the sort assertion).
for (let index = 0; index < 48; index += 1) {
symlinkSync(join(dir, 'real-dir'), join(dir, `dir-link-${index}`))
symlinkSync(join(dir, 'real-file.txt'), join(dir, `file-link-${index}`))
}
const listing = await listDirectory(dir)
for (let index = 0; index < 48; index += 1) {
expect(listing.entries.find(entry => entry.name === `dir-link-${index}`))
.toMatchObject({ isDir: true, isSymlink: true, broken: false })
expect(listing.entries.find(entry => entry.name === `file-link-${index}`))
.toMatchObject({ isDir: false, isSymlink: true, broken: false })
}
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'
import { resolve } from 'node:path'
import { compareEntries, isWithin, parentOf, requireAbsolute, rootLabel } from '../src/fs-tree.ts'
import { isWin32 } from './platform.ts'
describe('fs-tree', () => {
it('sorts directories first, then names case-insensitively', () => {
const rows = [
{ name: 'b.txt', path: '/x/b.txt', isDir: false, hidden: false, isSymlink: false, broken: false },
{ name: 'A', path: '/x/A', isDir: true, hidden: false, isSymlink: false, broken: false },
{ name: 'a.txt', path: '/x/a.txt', isDir: false, hidden: false, isSymlink: false, broken: false },
{ name: '.hidden', path: '/x/.hidden', isDir: false, hidden: true, isSymlink: false, broken: false },
]
expect(rows.sort(compareEntries).map(row => row.name)).toEqual(['A', '.hidden', 'a.txt', 'b.txt'])
})
it('derives root labels and parents (POSIX-style)', () => {
// POSIX-style inputs behave identically on both platforms (win32 parses '/'
// as a separator), so these assertions are platform-independent.
expect(rootLabel('/Users/me/code')).toBe('code')
expect(rootLabel('/')).toBe('/')
expect(parentOf('/Users/me/code')).toBe('/Users/me')
expect(parentOf('/')).toBeUndefined()
})
it.skipIf(!isWin32)('derives root labels and parents for Windows drives', () => {
expect(rootLabel('C:\\')).toBe('C:\\')
expect(parentOf('C:\\')).toBeUndefined()
expect(rootLabel('C:\\Users\\me')).toBe('me')
expect(parentOf('C:\\Users\\me')).toBe('C:\\Users')
})
it('accepts POSIX absolute paths and rejects relative ones', () => {
// resolve() is platform-native: '/a/b' roots to the current drive on win32.
expect(requireAbsolute('/a/b')).toBe(resolve('/a/b'))
expect(() => requireAbsolute('a/b')).toThrow(/not an absolute path/)
expect(() => requireAbsolute('../a')).toThrow(/not an absolute path/)
})
// Windows-only path semantics — skipped (not silently passing) on POSIX:
// drive letters and UNC shares are absolute on win32, drive-relative 'C:foo'
// is not.
describe.skipIf(!isWin32)('win32 path semantics', () => {
it('accepts drive letters and normalizes their separators', () => {
expect(requireAbsolute('C:/proj')).toBe('C:\\proj')
expect(requireAbsolute('C:\\proj')).toBe('C:\\proj')
// A drive path resolves against its own drive, never a bare root.
expect(resolve('/a/b')).toMatch(/^[A-Za-z]:/)
})
it('accepts UNC network shares in both separator styles', () => {
expect(requireAbsolute('\\\\server\\share\\proj')).toBe('\\\\server\\share\\proj')
expect(requireAbsolute('//server/share/proj')).toBe('\\\\server\\share\\proj')
})
it('rejects drive-relative paths', () => {
expect(() => requireAbsolute('C:proj')).toThrow(/not an absolute path/)
})
})
// POSIX-only path semantics — the reverse branch of the win32 suite: forms
// the host can never access must be refused loudly instead of mangled.
describe.skipIf(isWin32)('POSIX path semantics', () => {
it('rejects Windows drive paths', () => {
expect(() => requireAbsolute('C:/proj')).toThrow(/not an absolute path/)
expect(() => requireAbsolute('C:\\proj')).toThrow(/not an absolute path/)
})
it('rejects backslash UNC paths (not absolute on POSIX)', () => {
expect(() => requireAbsolute('\\\\server\\share\\proj')).toThrow(/not an absolute path/)
})
})
it('isWithin tolerates separators and (on win32) letter case', () => {
expect(isWithin('/work/proj', '/work/proj/src/a.ts')).toBe(true)
expect(isWithin('/work/proj', '/work/proj')).toBe(true)
expect(isWithin('/work/proj', '/work/proj2/a.ts')).toBe(false)
expect(isWithin('/work/proj', '/other/a.ts')).toBe(false)
// Mixed separators normalize on every platform.
expect(isWithin('C:\\Users\\me', 'C:/Users/me/src/a.ts')).toBe(true)
// Case sensitivity follows the platform's filesystem semantics (the
// platform parameter makes both branches assertable on any host).
expect(isWithin('C:\\Users\\Me', 'c:/users/me/file.png', 'win32')).toBe(true)
expect(isWithin('/Users/Me', '/users/me/file.png', 'win32')).toBe(true)
expect(isWithin('/Users/Me', '/users/me/file.png', 'linux')).toBe(false)
expect(isWithin('/Users/Me', '/users/me/file.png', 'darwin')).toBe(false)
// Windows drive-root containment.
expect(isWithin('C:\\', 'C:\\Users\\me\\a.png', 'win32')).toBe(true)
expect(isWithin('c:\\users', 'C:/USERS/me/b.png', 'win32')).toBe(true)
// UNC network-share containment: the '//' share prefix must not defeat
// the prefix test, and a sibling share must stay outside. The platform
// parameter is injected, so these win32-semantics assertions run on
// every host without any platform guard.
expect(isWithin('\\\\server\\share\\proj', '\\\\server\\share\\proj\\src\\a.ts', 'win32')).toBe(true)
expect(isWithin('\\\\server\\share\\proj', '\\\\server\\share\\proj2\\a.ts', 'win32')).toBe(false)
expect(isWithin('\\\\server\\share\\proj', '\\\\other\\share\\a.ts', 'win32')).toBe(false)
})
})
+136
View File
@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest'
import { parseUnifiedDiff } from '../src/client/DiffView.tsx'
import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
describe('git parsing', () => {
it('parses porcelain -z entries including renames', () => {
const output = ['M src/a.ts', ' M src/b.ts', '?? src/c.ts', 'R src/new.ts', 'src/old.ts', ''].join('\0')
const entries = parsePorcelainZ(output)
expect(entries).toEqual([
{ path: 'src/a.ts', xy: 'M ' },
{ path: 'src/b.ts', xy: ' M' },
{ path: 'src/c.ts', xy: '??' },
{ path: 'src/new.ts', xy: 'R ' },
])
})
it('parses log rows with unit separators (full hash + refs)', () => {
const rows = parseLogLines(
'abc1234\x1fFirst subject\x1fAlice\x1f2024-01-01 10:00:00 +0800\x1fabc1234def5678abc1234def5678abc1234def5678\x1fHEAD -> main, origin/main\n'
+ 'def5678\x1fSecond subject\x1fBob\x1f2024-01-02 10:00:00 +0800\x1fdef5678abc1234def5678abc1234def5678abc1234\x1f\n',
)
expect(rows).toEqual([
{
hash: 'abc1234',
subject: 'First subject',
author: 'Alice',
date: '2024-01-01 10:00:00 +0800',
hashFull: 'abc1234def5678abc1234def5678abc1234def5678',
refs: 'HEAD -> main, origin/main',
},
{
hash: 'def5678',
subject: 'Second subject',
author: 'Bob',
date: '2024-01-02 10:00:00 +0800',
hashFull: 'def5678abc1234def5678abc1234def5678abc1234',
refs: '',
},
])
})
it('parses a multi-file unified diff with aligned line numbers', () => {
const diff = [
'diff --git a/src/a.ts b/src/a.ts',
'index 1234567..89abcde 100644',
'--- a/src/a.ts',
'+++ b/src/a.ts',
'@@ -1,4 +1,5 @@ section with @@ inside',
' line1',
'-line2',
'+line2b',
' context',
'+trailing',
'diff --git a/README.md b/README.md',
'new file mode 100644',
'index 0000000..1234567',
'--- /dev/null',
'+++ b/README.md',
'@@ -0,0 +1,2 @@',
'+hello',
'+world',
'',
].join('\n')
const parsed = parseUnifiedDiff(diff)
expect(parsed.files).toHaveLength(2)
const first = parsed.files[0]!
expect(first.oldPath).toBe('a/src/a.ts')
expect(first.newPath).toBe('b/src/a.ts')
expect(first.binary).toBe(false)
expect(first.hunks).toHaveLength(1)
expect(first.hunks[0]!.oldStart).toBe(1)
expect(first.hunks[0]!.newStart).toBe(1)
expect(first.hunks[0]!.header).toBe(' section with @@ inside')
expect(first.hunks[0]!.lines).toEqual([
{ kind: 'ctx', text: 'line1', oldNum: 1, newNum: 1 },
{ kind: 'del', text: 'line2', oldNum: 2, newNum: null },
{ kind: 'add', text: 'line2b', oldNum: null, newNum: 2 },
{ kind: 'ctx', text: 'context', oldNum: 3, newNum: 3 },
{ kind: 'add', text: 'trailing', oldNum: null, newNum: 4 },
])
const second = parsed.files[1]!
expect(second.oldPath).toBe('/dev/null')
expect(second.hunks[0]!.lines[0]).toEqual({ kind: 'add', text: 'hello', oldNum: null, newNum: 1 })
expect(second.hunks[0]!.lines[1]).toEqual({ kind: 'add', text: 'world', oldNum: null, newNum: 2 })
})
it('parses binary, deletion and no-newline markers', () => {
const diff = [
'diff --git a/img.png b/img.png',
'index 111..222 100644',
'Binary files a/img.png and b/img.png differ',
'diff --git a/gone.ts b/gone.ts',
'deleted file mode 100644',
'--- a/gone.ts',
'+++ /dev/null',
'@@ -1,2 +0,0 @@',
'-one',
'-two',
'\\ No newline at end of file',
'',
].join('\n')
const parsed = parseUnifiedDiff(diff)
expect(parsed.files).toHaveLength(2)
expect(parsed.files[0]!.binary).toBe(true)
expect(parsed.files[0]!.hunks).toHaveLength(0)
const gone = parsed.files[1]!
expect(gone.newPath).toBe('/dev/null')
expect(gone.hunks[0]!.lines).toEqual([
{ kind: 'del', text: 'one', oldNum: 1, newNum: null },
{ kind: 'del', text: 'two', oldNum: 2, newNum: null },
{ kind: 'meta', text: ' No newline at end of file', oldNum: null, newNum: null },
])
})
it('keeps mode/rename-only sections hunkless', () => {
const parsed = parseUnifiedDiff([
'diff --git a/run.sh b/run.sh',
'old mode 100644',
'new mode 100755',
'diff --git a/old.ts b/new.ts',
'similarity index 90%',
'rename from old.ts',
'rename to new.ts',
'',
].join('\n'))
expect(parsed.files).toHaveLength(2)
expect(parsed.files[0]!.oldPath).toBe('')
expect(parsed.files[0]!.hunks).toHaveLength(0)
expect(parsed.files[1]!.hunks).toHaveLength(0)
})
it('parses an empty or junk diff into no files', () => {
expect(parseUnifiedDiff('').files).toEqual([])
expect(parseUnifiedDiff('no diff here\n').files).toEqual([])
})
})
+55 -3
View File
@@ -8,6 +8,7 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { resolve } from 'node:path' import { resolve } from 'node:path'
import { decodeHtmlUrl, encodeHtmlUrl } from '../src/html-route.ts' import { decodeHtmlUrl, encodeHtmlUrl } from '../src/html-route.ts'
import { isWin32 } from './platform.ts'
describe('encodeHtmlUrl', () => { describe('encodeHtmlUrl', () => {
it('encodes a POSIX absolute path into route segments', () => { it('encodes a POSIX absolute path into route segments', () => {
@@ -20,6 +21,21 @@ describe('encodeHtmlUrl', () => {
.toBe('/sidebar/html/sess-1/C%3A/Users/me/a.html') .toBe('/sidebar/html/sess-1/C%3A/Users/me/a.html')
}) })
it('encodes a UNC path with the // marker (backslash and forward-slash forms)', () => {
expect(encodeHtmlUrl('sess-1', '\\\\server\\share\\proj\\a.html'))
.toBe('/sidebar/html/sess-1//server/share/proj/a.html')
expect(encodeHtmlUrl('sess-1', '//server/share/proj/a.html'))
.toBe('/sidebar/html/sess-1//server/share/proj/a.html')
})
it('keeps a POSIX // path marker-encoded (the marker is platform-neutral)', () => {
// The decoder rebuilds '//server/share/...', which node:path resolves to
// '/server/share/...' on POSIX and '\\server\share\...' on win32 — so the
// leading double slash round-trips without any platform signal.
expect(encodeHtmlUrl('s-1', '//server/share/a.html'))
.toBe('/sidebar/html/s-1//server/share/a.html')
})
it('percent-encodes special characters in segments', () => { it('percent-encodes special characters in segments', () => {
expect(encodeHtmlUrl('s-1', '/a b/中文/100%.html')) expect(encodeHtmlUrl('s-1', '/a b/中文/100%.html'))
.toBe('/sidebar/html/s-1/a%20b/%E4%B8%AD%E6%96%87/100%25.html') .toBe('/sidebar/html/s-1/a%20b/%E4%B8%AD%E6%96%87/100%25.html')
@@ -45,13 +61,37 @@ describe('decodeHtmlUrl', () => {
ok: true, ok: true,
ref: { sessionId: 'sess-1', path: 'C:/Users/me/a.html' }, ref: { sessionId: 'sess-1', path: 'C:/Users/me/a.html' },
}) })
})
it.skipIf(!isWin32)('decoded drive paths resolve back verbatim on win32', () => {
// The host runs requireAbsolute() over the decoded path: with the old // The host runs requireAbsolute() over the decoded path: with the old
// '/C:/...' form node's resolve() produced 'C:\C:\Users\...' (drive // '/C:/...' form node's resolve() produced 'C:\C:\Users\...' (drive
// doubled) on Windows and the isWithin(cwd) fence rejected every drive // doubled) on Windows and the isWithin(cwd) fence rejected every drive
// path. The slash-free form must resolve back to the original path. // path. The slash-free form must resolve back to the original path.
if (process.platform === 'win32') { expect(resolve('C:/Users/me/a.html')).toBe('C:\\Users\\me\\a.html')
expect(resolve('C:/Users/me/a.html')).toBe('C:\\Users\\me\\a.html') })
}
it('decodes a UNC round-trip back to the platform-neutral forward-slash form', () => {
const url = encodeHtmlUrl('sess-1', '\\\\server\\share\\proj\\a.html')
expect(decodeHtmlUrl(url)).toEqual({
ok: true,
ref: { sessionId: 'sess-1', path: '//server/share/proj/a.html' },
})
// The host's requireAbsolute resolves that form per-platform:
// '\\server\share\proj\a.html' on win32, '/server/share/proj/a.html' on POSIX.
})
it('round-trips a POSIX // path through the same marker', () => {
const url = encodeHtmlUrl('s-1', '//server/share/a.html')
expect(decodeHtmlUrl(url)).toEqual({
ok: true,
ref: { sessionId: 's-1', path: '//server/share/a.html' },
})
})
it('refuses a marker-only UNC URL and stray double slashes (400)', () => {
expect(decodeHtmlUrl('/sidebar/html/s//').ok).toBe(false)
expect(decodeHtmlUrl('/sidebar/html/s//server//x.html').ok).toBe(false)
}) })
it('decodes a lowercase-drive Windows path without a leading slash', () => { it('decodes a lowercase-drive Windows path without a leading slash', () => {
@@ -122,4 +162,16 @@ describe('relative asset resolution stays in-route', () => {
expect(new URL('img/x.png', doc).pathname).toBe('/sidebar/html/s/a/b/img/x.png') expect(new URL('img/x.png', doc).pathname).toBe('/sidebar/html/s/a/b/img/x.png')
expect(new URL('../c.css', doc).pathname).toBe('/sidebar/html/s/a/c.css') expect(new URL('../c.css', doc).pathname).toBe('/sidebar/html/s/a/c.css')
}) })
it('relative assets of a UNC document stay inside the same route', () => {
// The WHATWG URL preserves the '//' marker during relative resolution,
// so ./style.css lands back on the route with the UNC prefix intact.
const doc = `http://h${encodeHtmlUrl('s', '\\\\server\\share\\proj\\index.html')}`
expect(new URL('./style.css', doc).pathname)
.toBe('/sidebar/html/s//server/share/proj/style.css')
expect(decodeHtmlUrl(new URL('./style.css', doc).pathname)).toEqual({
ok: true,
ref: { sessionId: 's', path: '//server/share/proj/style.css' },
})
})
}) })
+144
View File
@@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest'
import { registerOpenPathInterception } from '../src/client/intercept.tsx'
import { wrapOpenPath, type OpenPathInterceptDeps, type OpenPathService } from '../src/client/openpath-intercept.ts'
import { createSidebarStore } from '../src/client/state.ts'
import type { Context } from '../src/context-types.ts'
describe('open-path interception', () => {
/** A minimal fake of the workspaces.openPath service method. */
const service = (): OpenPathService & { calls: string[]; opened: string[] } => {
const fake = {
calls: [] as string[],
opened: [] as string[],
async openPath(path: string): Promise<void> {
this.calls.push(path)
this.opened.push(path)
},
}
return fake
}
const deps = (overrides: Partial<OpenPathInterceptDeps> = {}): OpenPathInterceptDeps & {
sidebar: string[]
} => {
const sidebar: string[] = []
return {
sidebar,
takeoverEnabled: () => true,
currentSessionId: () => 's1',
openInSidebar: (path, sessionId) => { sidebar.push(`${sessionId}:${path}`) },
...overrides,
}
}
it('routes an intercepted open into the sidebar and resolves without the original call', async () => {
const ws = service()
const d = deps()
const restore = wrapOpenPath(ws, d)
await expect(ws.openPath('/abs/a.ts')).resolves.toBeUndefined()
expect(ws.calls).toEqual([])
expect(d.sidebar).toEqual(['s1:/abs/a.ts'])
restore()
})
it('falls through to the original when the takeover is disabled', async () => {
const ws = service()
const d = deps({ takeoverEnabled: () => false })
const restore = wrapOpenPath(ws, d)
await ws.openPath('/abs/a.ts')
expect(ws.opened).toEqual(['/abs/a.ts'])
expect(d.sidebar).toEqual([])
restore()
})
it('falls through when no session is current (nothing to scope the editor load to)', async () => {
const ws = service()
const d = deps({ currentSessionId: () => undefined })
const restore = wrapOpenPath(ws, d)
await ws.openPath('/abs/a.ts')
expect(ws.opened).toEqual(['/abs/a.ts'])
expect(d.sidebar).toEqual([])
restore()
})
it('passes the current session into the sidebar opener', async () => {
const ws = service()
let current = 's1'
const d = deps({ currentSessionId: () => current })
const restore = wrapOpenPath(ws, d)
await ws.openPath('/abs/a.ts')
current = 's2'
await ws.openPath('/abs/b.ts')
expect(d.sidebar).toEqual(['s1:/abs/a.ts', 's2:/abs/b.ts'])
restore()
})
it('restores the original method on dispose (HMR-safe)', async () => {
const ws = service()
const d = deps()
const original = ws.openPath
const restore = wrapOpenPath(ws, d)
expect(ws.openPath).not.toBe(original)
restore()
expect(ws.openPath).toBe(original)
await ws.openPath('/abs/a.ts')
expect(ws.opened).toEqual(['/abs/a.ts'])
})
it('treats a rejected promise like the original would (no swallowing)', async () => {
const failing: OpenPathService = {
async openPath() { throw new Error('host refused') },
}
const d = deps({ takeoverEnabled: () => false })
const restore = wrapOpenPath(failing, d)
await expect(failing.openPath('/abs/a.ts')).rejects.toThrow('host refused')
restore()
})
})
describe('open-path interception wiring', () => {
it('registerOpenPathInterception routes chat opens into the editor tab and restores on dispose', async () => {
// A realistic client-context fake: the sessions list feed (current + cwd),
// the workspaces funnel, and the sidebar service the editor goes through.
const opened: Array<Record<string, unknown>> = []
const funnel = { openPath: async (): Promise<void> => {} }
const ctx = {
sessions: {
list: { getSnapshot: () => ({ current: 's1', byId: { s1: { cwd: '/w' } } }) },
},
workspaces: funnel,
betterSidebar: { openTab: (seed: unknown) => { opened.push(seed as Record<string, unknown>) } },
} as unknown as Context
const store = createSidebarStore()
const original = ctx.workspaces.openPath
const restore = registerOpenPathInterception(ctx, store)
// Default prefs: the takeover routes the open into the sidebar editor
// with the session-scoped absolute path (chat already resolved it).
await ctx.workspaces.openPath('/w/src/a.ts')
expect(opened).toEqual([{
type: 'editor',
title: 'a.ts',
path: '/w/src/a.ts',
id: 'editor:/w/src/a.ts',
}])
// The interceptOpenPath pref off → the original funnel runs untouched.
store.setPrefs({ ...store.getPrefs(), interceptOpenPath: false })
const calls: string[] = []
ctx.workspaces.openPath = async (path: string) => { calls.push(path) }
await ctx.workspaces.openPath('/w/src/b.ts')
expect(calls).toEqual(['/w/src/b.ts'])
expect(opened).toHaveLength(1)
// The editor tab disabled → falls through too (an editor that cannot
// open must not swallow opens).
store.setPrefs({ ...store.getPrefs(), interceptOpenPath: true, tabsEnabled: { editor: false } })
await ctx.workspaces.openPath('/w/src/c.ts')
expect(calls).toEqual(['/w/src/b.ts', '/w/src/c.ts'])
// Disposal restores the raw original method (HMR-safe).
restore()
expect(ctx.workspaces.openPath).toBe(original)
})
})
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import { isAbsolutePath, relativeTo } from '../src/client/paths.ts'
import { resolveSidebarPath } from '../src/client/produced-files.ts'
import { htmlUrl } from '../src/client/api.ts'
describe('path helpers', () => {
it('derives relative paths under the cwd (and "." for the cwd itself)', () => {
expect(relativeTo('/Users/me/code', '/Users/me/code/src/main.ts')).toBe('src/main.ts')
expect(relativeTo('/Users/me/code', '/Users/me/code')).toBe('.')
expect(relativeTo('/Users/me/code/', '/Users/me/code/src/a/b.ts')).toBe('src/a/b.ts')
})
it('falls back to the path unchanged when it lies outside the cwd', () => {
expect(relativeTo('/Users/me/code', '/Users/other/x.ts')).toBe('/Users/other/x.ts')
expect(relativeTo('/Users/me/code', '/Users/me/codex/y.ts')).toBe('/Users/me/codex/y.ts')
})
it('handles windows roots and mixed separators', () => {
expect(relativeTo('C:\\Users\\me', 'C:\\Users\\me\\src\\a.ts')).toBe('src/a.ts')
expect(relativeTo('C:\\Users\\me', 'C:/Users/me/src/a.ts')).toBe('src/a.ts')
expect(relativeTo('C:\\Users\\me\\', 'C:\\Users\\me')).toBe('.')
})
it('containment is case-insensitive (windows/macOS case-insensitive volumes)', () => {
expect(relativeTo('C:\\Users\\Me', 'c:/users/me/src/a.ts')).toBe('src/a.ts')
expect(relativeTo('/Users/Me/code', '/users/me/code/src/main.ts')).toBe('src/main.ts')
// The returned relative text keeps the caller's own casing.
expect(relativeTo('C:\\Users\\me', 'C:\\Users\\Me\\SRC\\a.ts')).toBe('SRC/a.ts')
})
it('resolves produced paths against windows cwds', () => {
expect(resolveSidebarPath('C:\\work\\proj', 'src/a.ts')).toBe('C:\\work\\proj\\src/a.ts')
expect(resolveSidebarPath('C:\\work\\proj', 'C:\\abs\\x.ts')).toBe('C:\\abs\\x.ts')
expect(resolveSidebarPath('C:\\work\\proj\\', 'C:\\abs\\x.ts')).toBe('C:\\abs\\x.ts')
})
it('keeps UNC produced paths absolute instead of joining them onto the cwd', () => {
// Pure client function: UNC detection is platform-independent, so these
// assertions run on every host without a platform guard.
expect(resolveSidebarPath('C:\\work\\proj', '\\\\server\\share\\abs\\x.ts'))
.toBe('\\\\server\\share\\abs\\x.ts')
expect(resolveSidebarPath('C:\\work\\proj', '//server/share/abs/x.ts'))
.toBe('//server/share/abs/x.ts')
// A relative path under a UNC cwd joins with backslashes.
expect(resolveSidebarPath('\\\\server\\share\\proj', 'src/a.ts'))
.toBe('\\\\server\\share\\proj\\src/a.ts')
})
it('mirrors the host absolute-path notion without node:path', () => {
expect(isAbsolutePath('/abs/x.ts')).toBe(true)
expect(isAbsolutePath('C:\\abs\\x.ts')).toBe(true)
expect(isAbsolutePath('C:/abs/x.ts')).toBe(true)
expect(isAbsolutePath('\\\\server\\share\\x.ts')).toBe(true)
expect(isAbsolutePath('//server/share/x.ts')).toBe(true)
expect(isAbsolutePath('C:relative.ts')).toBe(false)
expect(isAbsolutePath('rel/x.ts')).toBe(false)
})
it('htmlUrl always marks UNC paths (platform-neutral marker)', () => {
// The marker is platform-neutral now: the host resolves the decoded
// '//server/share/...' form per-platform, so no cwd/OS signal is needed.
expect(htmlUrl({ sessionId: 's' }, '\\\\server\\share\\proj\\a.html'))
.toBe('/sidebar/html/s//server/share/proj/a.html')
expect(htmlUrl({ sessionId: 's', cwd: '/home/me' }, '//server/share/a.html'))
.toBe('/sidebar/html/s//server/share/a.html')
expect(htmlUrl({ sessionId: 's', cwd: '/home/me' }, '/home/me/index.html'))
.toBe('/sidebar/html/s/home/me/index.html')
})
})
+10
View File
@@ -0,0 +1,10 @@
/**
* Shared platform guards for tests. Use vitest's `describe.skipIf` /
* `it.skipIf` with these so platform-specific cases are REPORTED as skipped
* on other hosts instead of silently passing through an inline `if` — and
* so the opposite branch (e.g. POSIX rejecting drive paths) is always
* asserted on the matching host.
*/
/** True when running on Windows (drive letters / UNC path semantics apply). */
export const isWin32 = process.platform === 'win32'
+12 -3
View File
@@ -34,6 +34,13 @@ describe('dsh-better-sidebar plugin export shape', () => {
expect(resolved.listLimit).toBe(1000) expect(resolved.listLimit).toBe(1000)
expect(resolved.terminalsPerSession).toBe(3) expect(resolved.terminalsPerSession).toBe(3)
expect(resolved.reconnectGraceMs).toBe(30_000) expect(resolved.reconnectGraceMs).toBe(30_000)
// The terminal shell config defaults to auto-resolution (empty shell =
// the platform chain in defaultShell()).
expect(resolved.shell).toBe('')
const configured = (schema as unknown as {
(input: Record<string, unknown> | undefined): Record<string, unknown>
})({ shell: 'pwsh.exe' })
expect(configured.shell).toBe('pwsh.exe')
}) })
it('registers the side card preferences schema with the documented defaults', async () => { it('registers the side card preferences schema with the documented defaults', async () => {
@@ -42,8 +49,8 @@ describe('dsh-better-sidebar plugin export shape', () => {
const resolved = (PrefsSchema as unknown as { const resolved = (PrefsSchema as unknown as {
(input: Record<string, unknown> | undefined): Record<string, unknown> (input: Record<string, unknown> | undefined): Record<string, unknown>
})(undefined) })(undefined)
expect(resolved.openByDefault).toBe(true) expect(resolved.openByDefault).toBe(false)
expect(resolved.defaultWidthPercent).toBe(30) expect(resolved.defaultWidthPercent).toBe(35)
expect(resolved.autoOpenSubagent).toBe(true) expect(resolved.autoOpenSubagent).toBe(true)
// A new background job auto-opens the Jobs page too. // A new background job auto-opens the Jobs page too.
expect(resolved.autoOpenJobs).toBe(true) expect(resolved.autoOpenJobs).toBe(true)
@@ -61,12 +68,14 @@ describe('dsh-better-sidebar plugin export shape', () => {
// The enable-switch maps resolve to {} (everything on) for old documents. // The enable-switch maps resolve to {} (everything on) for old documents.
expect(resolved.tabsEnabled).toEqual({}) expect(resolved.tabsEnabled).toEqual({})
expect(resolved.viewersEnabled).toEqual({}) expect(resolved.viewersEnabled).toEqual({})
// The merged editor-explorer mode defaults ON.
expect(resolved.editorExplorer).toBe(true)
// A stored overridden value resolves through (the range contract is // A stored overridden value resolves through (the range contract is
// enforced by the settings service on write); the new pref keeps its // enforced by the settings service on write); the new pref keeps its
// default when the stored document predates it. // default when the stored document predates it.
const overridden = (PrefsSchema as unknown as { const overridden = (PrefsSchema as unknown as {
(input: Record<string, unknown> | undefined): Record<string, unknown> (input: Record<string, unknown> | undefined): Record<string, unknown>
})({ openByDefault: false, defaultWidthPercent: 45 }) })({ openByDefault: false, defaultWidthPercent: 45 })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
}) })
}) })
+318
View File
@@ -0,0 +1,318 @@
import { describe, expect, it } from 'vitest'
import { loadExternalDisable, loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts'
import { allLeaves, createSidebarStore, defaultWidthFor, makeDefaultState } from '../src/client/state.ts'
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
/** A fake settings wire face whose settingsGet resolves to one raw value. */
const wire = (value: unknown): SidebarSettingsClient => ({
settingsGet: async () => ({ value, revision: 1 }),
settingsUpdate: async () => ({ value, revision: 2 }),
})
/** A fake wire carrying an explicit externalDisable flag. */
const wireWithDisable = (externalDisable: boolean): SidebarSettingsClient => ({
settingsGet: async () => ({ value: {}, revision: 1, externalDisable }),
settingsUpdate: async () => ({ value: {}, revision: 2 }),
})
const rejecting = (): SidebarSettingsClient => ({
settingsGet: async () => { throw new Error('route rejected') },
settingsUpdate: async () => { throw new Error('route rejected') },
})
describe('side card preferences', () => {
it('falls back to the defaults when the settings route rejects', async () => {
expect(await loadPrefs(rejecting())).toEqual(SIDEBAR_PREFS_DEFAULTS)
})
it('falls back to the defaults when the value is absent or malformed', async () => {
expect(await loadPrefs(wire(undefined))).toEqual(SIDEBAR_PREFS_DEFAULTS)
expect(await loadPrefs(wire('garbage'))).toEqual(SIDEBAR_PREFS_DEFAULTS)
})
it('parses a valid value and clamps the percent into the contract range', async () => {
expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 80, autoOpenSubagent: false, agentTerminalTools: true })))
.toEqual({
openByDefault: false,
defaultWidthPercent: 60,
autoOpenSubagent: false,
autoOpenJobs: true,
agentTerminalTools: true,
bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: 13,
interceptOpenPath: true,
editorExplorer: true,
titleBarCompat: false,
titleBarStripPx: 40,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
browserNoSandbox: false,
browserInterceptLinks: true,
browserInterceptHttp: true,
browserInterceptHttps: false,
tabsEnabled: {},
viewersEnabled: {},
pluginSettings: {},
})
})
it('falls back per-field when a stored field is malformed', async () => {
expect(await loadPrefs(wire({ openByDefault: 'yes', defaultWidthPercent: 33, autoOpenSubagent: 'no', agentTerminalTools: 'yes' })))
.toEqual({
openByDefault: false,
defaultWidthPercent: 33,
autoOpenSubagent: true,
autoOpenJobs: true,
agentTerminalTools: false,
bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: 13,
interceptOpenPath: true,
editorExplorer: true,
titleBarCompat: false,
titleBarStripPx: 40,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
browserNoSandbox: false,
browserInterceptLinks: true,
browserInterceptHttp: true,
browserInterceptHttps: false,
tabsEnabled: {},
viewersEnabled: {},
pluginSettings: {},
})
})
it('defaults autoOpenSubagent to true and agentTerminalTools to false when the stored value is absent or malformed', async () => {
expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 40 })))
.toEqual({
openByDefault: false,
defaultWidthPercent: 40,
autoOpenSubagent: true,
autoOpenJobs: true,
agentTerminalTools: false,
bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: 13,
interceptOpenPath: true,
editorExplorer: true,
titleBarCompat: false,
titleBarStripPx: 40,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
browserNoSandbox: false,
browserInterceptLinks: true,
browserInterceptHttp: true,
browserInterceptHttps: false,
tabsEnabled: {},
viewersEnabled: {},
pluginSettings: {},
})
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenSubagent: 1 }))).autoOpenSubagent)
.toBe(true)
// The terminal-tools feature is OFF by default; only an explicit true turns it on.
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40 }))).agentTerminalTools)
.toBe(false)
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: 1 }))).agentTerminalTools)
.toBe(false)
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: true }))).agentTerminalTools)
.toBe(true)
// The job auto-open is ON by default; only an explicit false turns it off.
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenJobs: 1 }))).autoOpenJobs)
.toBe(true)
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenJobs: false }))).autoOpenJobs)
.toBe(false)
})
it('defaults interceptOpenPath to true; only an explicit false turns the takeover off', async () => {
// Absent or malformed → on (the takeover is the safe default).
expect((await loadPrefs(wire({}))).interceptOpenPath).toBe(true)
expect((await loadPrefs(wire({ interceptOpenPath: 'yes' }))).interceptOpenPath).toBe(true)
expect((await loadPrefs(wire({ interceptOpenPath: 0 }))).interceptOpenPath).toBe(true)
// Explicit booleans survive verbatim.
expect((await loadPrefs(wire({ interceptOpenPath: false }))).interceptOpenPath).toBe(false)
expect((await loadPrefs(wire({ interceptOpenPath: true }))).interceptOpenPath).toBe(true)
})
it('defaults editorExplorer to true; only an explicit false restores the plain editor', async () => {
// Absent or malformed → on (the merged editor-explorer is the default).
expect((await loadPrefs(wire({}))).editorExplorer).toBe(true)
expect((await loadPrefs(wire({ editorExplorer: 'yes' }))).editorExplorer).toBe(true)
expect((await loadPrefs(wire({ editorExplorer: 0 }))).editorExplorer).toBe(true)
// Explicit booleans survive verbatim.
expect((await loadPrefs(wire({ editorExplorer: false }))).editorExplorer).toBe(false)
expect((await loadPrefs(wire({ editorExplorer: true }))).editorExplorer).toBe(true)
})
it('defaults titleBarCompat to false; only an explicit true turns the position-compat mode on', async () => {
// Absent or malformed → off (the normal layout is the default).
expect((await loadPrefs(wire({}))).titleBarCompat).toBe(false)
expect((await loadPrefs(wire({ titleBarCompat: 'yes' }))).titleBarCompat).toBe(false)
expect((await loadPrefs(wire({ titleBarCompat: 1 }))).titleBarCompat).toBe(false)
// Explicit booleans survive verbatim.
expect((await loadPrefs(wire({ titleBarCompat: false }))).titleBarCompat).toBe(false)
expect((await loadPrefs(wire({ titleBarCompat: true }))).titleBarCompat).toBe(true)
})
it('defaults titleBarStripPx to 40 and clamps stored values into the contract range', async () => {
// Absent or malformed → 40 (the strip default).
expect((await loadPrefs(wire({}))).titleBarStripPx).toBe(40)
expect((await loadPrefs(wire({ titleBarStripPx: 'yes' }))).titleBarStripPx).toBe(40)
// Out-of-range numbers clamp into 0–120.
expect((await loadPrefs(wire({ titleBarStripPx: -5 }))).titleBarStripPx).toBe(0)
expect((await loadPrefs(wire({ titleBarStripPx: 200 }))).titleBarStripPx).toBe(120)
expect((await loadPrefs(wire({ titleBarStripPx: 47.6 }))).titleBarStripPx).toBe(48)
// In-range values survive verbatim.
expect((await loadPrefs(wire({ titleBarStripPx: 0 }))).titleBarStripPx).toBe(0)
expect((await loadPrefs(wire({ titleBarStripPx: 64 }))).titleBarStripPx).toBe(64)
})
it('defaults the link-takeover protocol flags: http on, https off, master on', async () => {
// Absent or malformed → the per-protocol defaults.
expect((await loadPrefs(wire({}))).browserInterceptLinks).toBe(true)
expect((await loadPrefs(wire({}))).browserInterceptHttp).toBe(true)
expect((await loadPrefs(wire({}))).browserInterceptHttps).toBe(false)
expect((await loadPrefs(wire({ browserInterceptHttp: 'yes' }))).browserInterceptHttp).toBe(true)
expect((await loadPrefs(wire({ browserInterceptHttps: 0 }))).browserInterceptHttps).toBe(false)
// Explicit booleans survive verbatim.
expect((await loadPrefs(wire({ browserInterceptHttp: false }))).browserInterceptHttp).toBe(false)
expect((await loadPrefs(wire({ browserInterceptHttps: true }))).browserInterceptHttps).toBe(true)
// The master is independent of the protocol flags (an explicit master
// false stays "never take over" regardless of the flags).
expect((await loadPrefs(wire({ browserInterceptLinks: false, browserInterceptHttp: true, browserInterceptHttps: true }))))
.toMatchObject({ browserInterceptLinks: false, browserInterceptHttp: true, browserInterceptHttps: true })
})
it('resolves the terminal font prefs (family passthrough, size clamp)', async () => {
// Absent → theme default (empty family) + default size.
expect((await loadPrefs(wire({}))).terminalFontFamily).toBe('')
expect((await loadPrefs(wire({}))).terminalFontSize).toBe(13)
// A custom family survives verbatim; a malformed one falls back.
expect((await loadPrefs(wire({ terminalFontFamily: '"JetBrains Mono", monospace' }))).terminalFontFamily)
.toBe('"JetBrains Mono", monospace')
expect((await loadPrefs(wire({ terminalFontFamily: 42 }))).terminalFontFamily).toBe('')
// The size clamps into 9–32 (rounded); non-numbers fall back.
expect((await loadPrefs(wire({ terminalFontSize: 5 }))).terminalFontSize).toBe(9)
expect((await loadPrefs(wire({ terminalFontSize: 40 }))).terminalFontSize).toBe(32)
expect((await loadPrefs(wire({ terminalFontSize: 15.6 }))).terminalFontSize).toBe(16)
expect((await loadPrefs(wire({ terminalFontSize: 'big' }))).terminalFontSize).toBe(13)
expect((await loadPrefs(wire({ terminalFontSize: 18 }))).terminalFontSize).toBe(18)
})
it('validates the per-tab / per-viewer enable maps (absent keys mean enabled)', async () => {
// A non-object map falls back to {} (everything enabled).
expect((await loadPrefs(wire({ tabsEnabled: 'nope' }))).tabsEnabled).toEqual({})
expect((await loadPrefs(wire({ viewersEnabled: [1, 2] }))).viewersEnabled).toEqual({})
// Non-boolean entries are dropped; boolean entries survive verbatim.
const parsed = await loadPrefs(wire({
tabsEnabled: { git: false, explorer: true, bad: 'yes' },
viewersEnabled: { image: false, code: 1 },
}))
expect(parsed.tabsEnabled).toEqual({ git: false, explorer: true })
expect(parsed.viewersEnabled).toEqual({ image: false })
})
it('seeds new-session defaults from the store prefs (open flag + width)', () => {
const store = createSidebarStore()
// Node environment: no window → the width falls back to PANEL_DEFAULT,
// while the open flag still follows the preference.
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
store.setSession('fresh-session')
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
const snapshot = store.getSnapshot()
expect(snapshot.sessionId).toBe('fresh-session')
expect(snapshot.state?.panelOpen).toBe(false)
expect(snapshot.state?.width).toBe(400)
// The default prefs keep the panel closed (openByDefault defaults off).
const openStore = createSidebarStore()
openStore.setSession('another-fresh')
expect(openStore.getSnapshot().state?.panelOpen).toBe(false)
})
it('seeds a brand-new session COLLAPSED on narrow viewports (the panel is a full-screen drawer there)', () => {
// Stub a narrow window (the file otherwise runs without one): only
// innerWidth is read while seeding a fresh session.
const original = (globalThis as Record<string, unknown>).window
;(globalThis as Record<string, unknown>).window = {
innerWidth: 390,
clearTimeout: () => {},
setTimeout: (_fn: () => void) => 0,
}
try {
const store = createSidebarStore()
// The narrow viewport keeps a fresh session collapsed for the FIRST
// seeding only (a later user expansion persists).
store.setSession('narrow-fresh')
expect(store.getSnapshot().state?.panelOpen).toBe(false)
// The width seeding still follows the window (clamped to the floor).
expect(store.getSnapshot().state?.width).toBe(280)
} finally {
if (original === undefined) delete (globalThis as Record<string, unknown>).window
else (globalThis as Record<string, unknown>).window = original
}
})
it('skips the default seed tab when the editor (files window) type is disabled', () => {
const store = createSidebarStore()
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: { editor: false }, viewersEnabled: {}, pluginSettings: {} })
store.setSession('no-editor')
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
expect(tabs).toHaveLength(0)
expect(state.splits.kind).toBe('leaf')
// Re-enabling seeds the files window (editor home tab) again — in BOTH
// editorExplorer modes.
for (const editorExplorer of [true, false]) {
const openStore = createSidebarStore()
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
openStore.setSession(`with-editor-${editorExplorer}`)
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
expect(openTabs.map(tab => tab.type)).toEqual(['editor'])
}
})
it('seeds the empty editor home tab (files window) in both editorExplorer modes', () => {
for (const editorExplorer of [true, false]) {
const store = createSidebarStore()
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
store.setSession(`fresh-${editorExplorer}`)
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
expect(tabs).toHaveLength(1)
expect(tabs[0]!.type).toBe('editor')
expect(tabs[0]!.title).toBe('Files')
expect(tabs[0]!.path).toBeUndefined()
expect(tabs[0]!.meta).toEqual({ treeOpen: true })
}
})
it('derives the default width from the window percent with clamps', () => {
expect(defaultWidthFor(1440, 30)).toBe(432)
expect(defaultWidthFor(800, 30)).toBe(280) // the panel floor
expect(defaultWidthFor(1440, 100)).toBe(1440) // the viewport cap
})
it('makeDefaultState honors the open flag', () => {
expect(makeDefaultState().panelOpen).toBe(true)
expect(makeDefaultState(400, false).panelOpen).toBe(false)
expect(makeDefaultState(400, false).width).toBe(400)
// The 'none' seed starts with an empty pane (no default tab).
expect(makeDefaultState(400, true, 'none').splits.kind).toBe('leaf')
expect((makeDefaultState(400, true, 'none').splits as { tabs: unknown[] }).tabs).toHaveLength(0)
})
})
describe('external disable (aionui-panel provider choice)', () => {
it('reads true when the host reports the aionui provider active', async () => {
expect(await loadExternalDisable(wireWithDisable(true))).toBe(true)
})
it('reads false when the host reports no external disable', async () => {
expect(await loadExternalDisable(wireWithDisable(false))).toBe(false)
})
it('reads false when the flag is absent or the wire rejects', async () => {
expect(await loadExternalDisable(wire({}))).toBe(false)
expect(await loadExternalDisable(rejecting())).toBe(false)
})
})
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import { producedForClosing, resolveSidebarPath, selectProducedFiles } from '../src/client/produced-files.ts'
describe('produced-files derivation', () => {
const diffResult = (path: string) => ({
kind: 'tool-result', isError: false, callView: { card: 'diff', locations: [{ path }] },
})
const editResult = (path: string) => ({
kind: 'tool-result', isError: false, callView: { card: 'generic', kind: 'edit', locations: [{ path }] },
})
it('collects diff/edit locations of the closing turn, first-seen order', () => {
const nodes = [
{ kind: 'assistant', seq: 1, turn: 1 },
diffResult('a.ts'),
editResult('b.ts'),
diffResult('a.ts'),
{ kind: 'assistant', seq: 2, turn: 1 },
]
expect(producedForClosing(nodes, 2)).toEqual(['a.ts', 'b.ts'])
})
it('resets on user messages and turn changes', () => {
const nodes = [
{ kind: 'assistant', seq: 1, turn: 1 },
diffResult('old.ts'),
{ kind: 'user' },
{ kind: 'assistant', seq: 2, turn: 2 },
diffResult('new.ts'),
{ kind: 'assistant', seq: 3, turn: 2 },
]
expect(producedForClosing(nodes, 3)).toEqual(['new.ts'])
})
it('ignores reads, deletes, errors, and unknown cards', () => {
const nodes = [
{ kind: 'assistant', seq: 1, turn: 1 },
{ kind: 'tool-result', isError: true, callView: { card: 'diff', locations: [{ path: 'x.ts' }] } },
{ kind: 'tool-result', isError: false, callView: { card: 'read', locations: [{ path: 'r.ts' }] } },
{ kind: 'tool-result', isError: false, callView: { card: 'generic', kind: 'delete', locations: [{ path: 'd.ts' }] } },
]
expect(producedForClosing(nodes, 1)).toEqual([])
})
it('selector claims only when files exist', () => {
expect(selectProducedFiles({ nodes: [{ kind: 'assistant', seq: 1, turn: 1 }], seq: 1 })).toBeNull()
expect(selectProducedFiles({ nodes: [diffResult('a.ts'), { kind: 'assistant', seq: 1, turn: 1 }], seq: 1 })).toEqual(['a.ts'])
expect(selectProducedFiles(null)).toBeNull()
})
it('resolves relative paths against the session cwd', () => {
expect(resolveSidebarPath('/work/proj', 'src/a.ts')).toBe('/work/proj/src/a.ts')
expect(resolveSidebarPath('/work/proj', '/abs/x.ts')).toBe('/abs/x.ts')
expect(resolveSidebarPath(undefined, 'a.ts')).toBe('a.ts')
})
})
+205
View File
@@ -0,0 +1,205 @@
/**
* pty-deps unit tests (issue #140, plugin side): lazy node-pty loading,
* profile/plugin-root discovery, and the pasteable repair command — the
* degraded mode the terminal tab enters when node-pty cannot load, instead
* of the whole plugin (and `dsh web`) failing to boot.
*/
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, dirname } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
buildRepairCommand,
depsStatus,
DSH_NODE_PTY_RANGE,
findPluginRoot,
findProfileDir,
loadNodePty,
loadRequiredNodePty,
nodePtyLoadCause,
resetNodePtyCache,
} from '../src/pty-deps.ts'
import { SidebarError } from '../src/wire.ts'
/** A throw-only require: node-pty missing or its native binding broken. */
const failingRequire = (): never => { throw new Error('Cannot find package node-pty') }
describe('loadNodePty', () => {
beforeEach(() => { resetNodePtyCache() })
it('loads the real node-pty module in this repository', () => {
const mod = loadNodePty()
expect(mod).not.toBeNull()
expect(typeof mod?.spawn).toBe('function')
})
it('returns null and records the cause when the require throws', () => {
expect(loadNodePty(failingRequire)).toBeNull()
expect((nodePtyLoadCause() as Error).message).toBe('Cannot find package node-pty')
})
it('caches the first outcome (a later working require does not override)', () => {
expect(loadNodePty(failingRequire)).toBeNull()
expect(loadNodePty(() => ({ spawn: () => undefined }))).toBeNull()
resetNodePtyCache()
expect(loadNodePty(() => ({ spawn: () => undefined }))).not.toBeNull()
})
it('loadRequiredNodePty throws the canonical degraded-mode error', () => {
loadNodePty(failingRequire)
let thrown: unknown
try {
loadRequiredNodePty()
} catch (error) {
thrown = error
}
expect(thrown).toBeInstanceOf(SidebarError)
expect((thrown as SidebarError).code).toBe('pty-deps-missing')
expect((thrown as SidebarError).status).toBe(503)
})
})
describe('buildRepairCommand', () => {
let root: string
let pluginRoot: string
let profileDir: string
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'pty-deps-'))
pluginRoot = join(root, 'plugin')
profileDir = join(root, 'profiles', 'web')
mkdirSync(join(pluginRoot, 'scripts'), { recursive: true })
mkdirSync(profileDir, { recursive: true })
writeFileSync(join(pluginRoot, 'scripts', 'install.sh'), '#!/usr/bin/env bash\n')
writeFileSync(join(pluginRoot, 'scripts', 'install.ps1'), '# ps1\n')
})
afterEach(() => { rmSync(root, { recursive: true, force: true }) })
it('posix: bash installer with --repair and the detected profile', () => {
const { command, note } = buildRepairCommand({ pluginRoot, profileDir, platform: 'darwin' })
expect(command).toBe(`bash "${join(pluginRoot, 'scripts', 'install.sh')}" --repair --profile "web"`)
expect(note).toBeUndefined()
})
it('posix: omits --profile when the profile could not be detected', () => {
const { command } = buildRepairCommand({ pluginRoot, profileDir: null, platform: 'linux' })
expect(command).toBe(`bash "${join(pluginRoot, 'scripts', 'install.sh')}" --repair`)
})
it('win32: powershell -ExecutionPolicy Bypass with -Repair and -Profile', () => {
const { command } = buildRepairCommand({ pluginRoot, profileDir, platform: 'win32' })
const expected = join(pluginRoot, 'scripts', 'install.ps1')
expect(command).toBe(`powershell -ExecutionPolicy Bypass -File "${expected}" -Repair -Profile "web"`)
})
it('falls back to the dsh plugin command when the plugin scripts are unavailable', () => {
const bareRoot = join(root, 'bare')
mkdirSync(bareRoot)
const { command, note } = buildRepairCommand({ pluginRoot: bareRoot, profileDir, platform: 'darwin' })
expect(command).toBe('dsh plugin --profile "web" install')
expect(note).toContain('allowBuilds')
})
it('falls back to the dsh plugin command (default web) when nothing is detectable', () => {
const { command } = buildRepairCommand({ pluginRoot: null, profileDir: null, platform: 'darwin' })
expect(command).toBe('dsh plugin --profile "web" install')
})
})
describe('findProfileDir / findPluginRoot', () => {
let root: string
let moduleFile: string
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'pty-deps-'))
// A fake plugin module deep inside the profile's pnpm node_modules.
const pkgRoot = join(root, 'profiles', 'web', 'node_modules', '.pnpm', 'dsh-better-sidebar@0.0.0', 'node_modules', 'dsh-better-sidebar')
const libDir = join(pkgRoot, 'lib')
mkdirSync(libDir, { recursive: true })
writeFileSync(join(root, 'profiles', 'web', 'package.json'), JSON.stringify({ name: 'dsh-profile-web' }))
writeFileSync(join(root, 'profiles', 'web', 'pnpm-workspace.yaml'), '')
writeFileSync(join(pkgRoot, 'package.json'), JSON.stringify({ name: 'dsh-better-sidebar' }))
moduleFile = join(libDir, 'index.js')
writeFileSync(moduleFile, '')
})
afterEach(() => { rmSync(root, { recursive: true, force: true }) })
it('walks up from the plugin module to the profile root (pnpm-workspace.yaml)', () => {
expect(findProfileDir(moduleFile)).toBe(realpathSync(join(root, 'profiles', 'web')))
})
it('walks up to the plugin package root by package.json name', () => {
expect(findPluginRoot(moduleFile)).toBe(realpathSync(join(root, 'profiles', 'web', 'node_modules', '.pnpm', 'dsh-better-sidebar@0.0.0', 'node_modules', 'dsh-better-sidebar')))
})
it('falls back to $DSH_HOME/profiles/web when no ancestor looks like a profile', () => {
const home = join(root, 'home')
const web = join(home, 'profiles', 'web')
mkdirSync(web, { recursive: true })
writeFileSync(join(web, 'package.json'), JSON.stringify({ name: 'dsh-profile-web' }))
writeFileSync(join(web, 'pnpm-workspace.yaml'), '')
// A bare module path with no profile-like ancestor (the walk-up must miss).
const bareModule = join(root, 'elsewhere', 'lib', 'index.js')
mkdirSync(dirname(bareModule), { recursive: true })
writeFileSync(bareModule, '')
const previous = process.env.DSH_HOME
process.env.DSH_HOME = home
try {
expect(findProfileDir(bareModule)).toBe(realpathSync(web))
} finally {
if (previous === undefined) delete process.env.DSH_HOME
else process.env.DSH_HOME = previous
}
})
})
describe('depsStatus', () => {
let root: string
let moduleFile: string
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'pty-deps-'))
const pkgRoot = join(root, 'profiles', 'web', 'node_modules', '.pnpm', 'dsh-better-sidebar@0.0.0', 'node_modules', 'dsh-better-sidebar')
mkdirSync(join(pkgRoot, 'scripts'), { recursive: true })
mkdirSync(join(pkgRoot, 'lib'), { recursive: true })
writeFileSync(join(root, 'profiles', 'web', 'package.json'), JSON.stringify({ name: 'dsh-profile-web' }))
writeFileSync(join(root, 'profiles', 'web', 'pnpm-workspace.yaml'), '')
writeFileSync(join(pkgRoot, 'package.json'), JSON.stringify({ name: 'dsh-better-sidebar' }))
writeFileSync(join(pkgRoot, 'scripts', 'install.sh'), '#!/usr/bin/env bash\n')
moduleFile = join(pkgRoot, 'lib', 'index.js')
writeFileSync(moduleFile, '')
resetNodePtyCache()
})
afterEach(() => {
rmSync(root, { recursive: true, force: true })
resetNodePtyCache()
})
it('reports ok:true when node-pty loads', () => {
expect(depsStatus({ fromFile: moduleFile })).toEqual({ ok: true })
})
it('reports the cause, the repair command and the profile when the load fails', () => {
loadNodePty(failingRequire)
const status = depsStatus({ fromFile: moduleFile })
expect(status.ok).toBe(false)
if (status.ok) throw new Error('unreachable')
expect(status.cause).toBe('Cannot find package node-pty')
const script = realpathSync(join(root, 'profiles', 'web', 'node_modules', '.pnpm', 'dsh-better-sidebar@0.0.0', 'node_modules', 'dsh-better-sidebar', 'scripts', 'install.sh'))
expect(status.command).toBe(`bash "${script}" --repair --profile "web"`)
expect(status.profile).toBe('web')
})
})
describe('DSH sync contract', () => {
it('the declared node-pty range stays identical to DSH core (dsh-subprocess-local ^1.1.0)', () => {
expect(DSH_NODE_PTY_RANGE).toBe('^1.1.0')
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
dependencies: Record<string, string>
}
expect(pkg.dependencies['node-pty']).toBe(DSH_NODE_PTY_RANGE)
})
})
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it, vi } from 'vitest'
// Pin the passwd login shell to a non-bash value for the pty helpers tests:
// the CI runner's own login shell is /bin/bash, which the old hardcoded
// fallback would have satisfied by coincidence, so the fallback chain needs
// a mock passwd to be genuinely discriminating. No other test reads os.userInfo.
vi.mock('node:os', async (importOriginal) => {
const os = await importOriginal<typeof import('node:os')>()
return {
...os,
userInfo: () => ({ ...os.userInfo(), shell: '/usr/bin/zsh' }),
}
})
import { resolveSidebarConfig } from '../src/config.ts'
import { defaultShell, ensureSpawnHelper } from '../src/pty-manager.ts'
describe('pty helpers', () => {
it('prefers an explicit shell, then SHELL, then the account login shell on POSIX', () => {
// The platform is injected so this chain also runs (and is asserted) on
// non-POSIX developer machines. The userInfo mock at the top of the file
// pins the passwd login shell to /usr/bin/zsh.
expect(defaultShell({ platform: 'linux', env: { SHELL: '/explicit/zsh' } })).toBe('/explicit/zsh')
// Surrounding whitespace must not leak into the spawned executable path.
expect(defaultShell({ platform: 'linux', env: { SHELL: ' /explicit/zsh ' } })).toBe('/explicit/zsh')
expect(defaultShell({ platform: 'linux', env: { SHELL: ' ' } })).toBe('/usr/bin/zsh')
expect(defaultShell({ platform: 'linux', env: {} })).toBe('/usr/bin/zsh')
})
it('Windows: explicit shell wins, then DSH_SIDEBAR_SHELL, then a probed pwsh.exe, then powershell.exe 5.1', () => {
// The explicit shell (the `shell` config field) beats every automatic
// source, and whitespace-only values count as unset.
expect(defaultShell({ platform: 'win32', explicit: 'C:\\Tools\\pwsh.exe', env: { DSH_SIDEBAR_SHELL: 'pwsh.exe' } }))
.toBe('C:\\Tools\\pwsh.exe')
expect(defaultShell({ platform: 'win32', explicit: ' ', env: { DSH_SIDEBAR_SHELL: 'pwsh.exe' } }))
.toBe('pwsh.exe')
// PATH is probed entry by entry; the first directory containing pwsh.exe
// wins and the returned value is the full resolved path. The candidate is
// normalized to forward slashes before comparing so the assertion holds
// under BOTH join() implementations: on the ubuntu runners node:path is
// POSIX and joins 'C:\\other' + 'pwsh.exe' as 'C:\\other/pwsh.exe'.
const fromPath = defaultShell({
platform: 'win32',
env: { PATH: 'C:\\tools;C:\\other' },
exists: path => path.replaceAll('\\', '/') === 'C:/other/pwsh.exe',
})
expect(fromPath.replaceAll('\\', '/')).toBe('C:/other/pwsh.exe')
// PATH misses fall through to the known install directories. On 32-bit
// Node, ProgramW6432 is the real 64-bit Program Files and is preferred.
const fromProgramW6432 = defaultShell({
platform: 'win32',
env: { ProgramW6432: 'C:\\PF64' },
exists: path => path.replaceAll('\\', '/') === 'C:/PF64/PowerShell/7/pwsh.exe',
})
expect(fromProgramW6432.replaceAll('\\', '/')).toBe('C:/PF64/PowerShell/7/pwsh.exe')
const fromProgramFiles = defaultShell({
platform: 'win32',
env: { ProgramFiles: 'C:\\PF', LOCALAPPDATA: 'C:\\Users\\x\\AppData\\Local' },
exists: path => path.replaceAll('\\', '/') === 'C:/PF/PowerShell/7/pwsh.exe',
})
expect(fromProgramFiles.replaceAll('\\', '/')).toBe('C:/PF/PowerShell/7/pwsh.exe')
// Nothing installed: keep the inbox 5.1 fallback instead of breaking.
expect(defaultShell({ platform: 'win32', env: {}, exists: () => false })).toBe('powershell.exe')
})
it('trims the configured shell and defaults it to auto for old documents', () => {
expect(resolveSidebarConfig(undefined).shell).toBe('')
expect(resolveSidebarConfig({ shell: ' pwsh.exe ' }).shell).toBe('pwsh.exe')
})
it('restores the spawn-helper executable bit idempotently', () => {
// node-pty's spawn-helper is a macOS-only artifact: binding.gyp builds
// the executable only for OS=="mac", and no other platform ships one to
// restore (linux uses forkpty directly). Skip elsewhere.
if (process.platform !== 'darwin') return
ensureSpawnHelper()
ensureSpawnHelper()
const { existsSync, statSync } = require('node:fs') as typeof import('node:fs')
const { dirname, join } = require('node:path') as typeof import('node:path')
const { createRequire } = require('node:module') as typeof import('node:module')
const entry = createRequire(import.meta.url).resolve('node-pty')
const root = dirname(dirname(entry))
// Prebuilt (tarball) or node-gyp-compiled (build/Release): mirror
// ensureSpawnHelper's candidate list — either location is acceptable.
const candidates = [
join(root, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'),
join(root, 'build', 'Release', 'spawn-helper'),
]
const helper = candidates.find(existsSync)
expect(helper).toBeTruthy()
expect((statSync(helper!).mode & 0o111) !== 0).toBe(true)
})
})
+33 -9
View File
@@ -21,7 +21,7 @@ if (g.localStorage === undefined) {
} }
import { createBetterSidebarService, matchUrlTarget, SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from '../src/client/service.ts' import { createBetterSidebarService, matchUrlTarget, SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from '../src/client/service.ts'
import { createSidebarStore, allLeaves, makeDefaultState, openDiffTab, sanitizeState } from '../src/client/state.ts' import { createSidebarStore, allLeaves, makeDefaultState, openDiffTab, openTabInActivePane, sanitizeState } from '../src/client/state.ts'
describe('BetterSidebar service', () => { describe('BetterSidebar service', () => {
it('registerTab adds to the registry and dispose removes it', () => { it('registerTab adds to the registry and dispose removes it', () => {
@@ -108,8 +108,9 @@ describe('enable switches (declarative settings)', () => {
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { explorer: false } }) store.setPrefs({ ...store.getPrefs(), tabsEnabled: { explorer: false } })
store.setSession('s1') store.setSession('s1')
service.openTab({ type: 'explorer', title: 'Explorer' }) service.openTab({ type: 'explorer', title: 'Explorer' })
// The seeded files-window home tab stays; no EXPLORER tab landed.
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs) const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs)
expect(tabs).toHaveLength(0) expect(tabs.filter(t => t.type === 'explorer')).toHaveLength(0)
}) })
it('matchFileViewer skips a disabled viewer (files fall through)', () => { it('matchFileViewer skips a disabled viewer (files fall through)', () => {
@@ -352,7 +353,8 @@ describe('service.openTab dedupe', () => {
store.setSession('s1') store.setSession('s1')
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' }) service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
const state = store.getSnapshot().state! const state = store.getSnapshot().state!
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'editor') // Find by path: the seeded files-window home tab is an editor tab too.
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'editor' && t.path === '/p/main.ts')
expect(tab?.title).toBe('main.ts') expect(tab?.title).toBe('main.ts')
}) })
@@ -643,7 +645,7 @@ describe('service.openTab auto-expand for content opens', () => {
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' }) service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
const state = store.getSnapshot().state! const state = store.getSnapshot().state!
expect(state.panelOpen).toBe(true) expect(state.panelOpen).toBe(true)
expect(allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'editor')).toHaveLength(1) expect(allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'editor' && t.path === '/p/main.ts')).toHaveLength(1)
}) })
}) })
@@ -671,7 +673,7 @@ describe('state subscription (v0.12.0)', () => {
const snapshot = service.getSnapshot() const snapshot = service.getSnapshot()
expect(snapshot.sessionId).toBe('s1') expect(snapshot.sessionId).toBe('s1')
expect(snapshot.state).toBeDefined() expect(snapshot.state).toBeDefined()
expect(snapshot.prefs.openByDefault).toBe(true) expect(snapshot.prefs.openByDefault).toBe(false)
}) })
it('subscribeState fires on state changes but NOT on registry changes', () => { it('subscribeState fires on state changes but NOT on registry changes', () => {
@@ -739,7 +741,7 @@ describe('targeted openTab (v0.12.0)', () => {
service.registerTab({ id: 'notes', title: 'Notes', component: () => null }) service.registerTab({ id: 'notes', title: 'Notes', component: () => null })
store.setSession('s1') store.setSession('s1')
service.openTab({ type: 'notes', title: 'Notes', id: 'notes:1' }, { sessionId: 's2' }) service.openTab({ type: 'notes', title: 'Notes', id: 'notes:1' }, { sessionId: 's2' })
// The UI snapshot still shows s1, untouched (its default explorer tab // The UI snapshot still shows s1, untouched (its default files-window tab
// is the only one — no notes tab landed there). // is the only one — no notes tab landed there).
const snapshot = store.getSnapshot() const snapshot = store.getSnapshot()
expect(snapshot.sessionId).toBe('s1') expect(snapshot.sessionId).toBe('s1')
@@ -783,7 +785,8 @@ describe('openFile (v0.12.0)', () => {
store.setSession('s1') store.setSession('s1')
service.openFile({ sessionId: 's1', cwd: '/p' }, '/p/src/main.ts') service.openFile({ sessionId: 's1', cwd: '/p' }, '/p/src/main.ts')
const state = store.getSnapshot().state! const state = store.getSnapshot().state!
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'editor') // Find by path: the seeded files-window home tab is an editor tab too.
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'editor' && t.path !== undefined)
expect(tab?.title).toBe('main.ts') expect(tab?.title).toBe('main.ts')
expect(tab?.path).toBe('/p/src/main.ts') expect(tab?.path).toBe('/p/src/main.ts')
// Windows separators are handled too. // Windows separators are handled too.
@@ -892,8 +895,9 @@ describe('tab meta (v0.12.0)', () => {
}) })
it('older persisted tabs (no meta) sanitize unchanged', () => { it('older persisted tabs (no meta) sanitize unchanged', () => {
const state = makeDefaultState(400, true, true) const state = makeDefaultState(400, true, 'none')
const sanitized = sanitizeState(JSON.parse(JSON.stringify(state))) const withTab = openTabInActivePane(state, { id: 'tab:old', type: 'git', title: 'Git' })
const sanitized = sanitizeState(JSON.parse(JSON.stringify(withTab)))
const tabs = allLeaves(sanitized!.splits).flatMap(l => l.tabs) const tabs = allLeaves(sanitized!.splits).flatMap(l => l.tabs)
expect(tabs[0]?.meta).toBeUndefined() expect(tabs[0]?.meta).toBeUndefined()
}) })
@@ -1031,4 +1035,24 @@ describe('independent CR follow-up fixes', () => {
{ kind: 'close', cwd: '/work' }, { kind: 'close', cwd: '/work' },
]) ])
}) })
it('SIDEBAR_FEATURES snapshot and service version checks', () => {
expect(SIDEBAR_FEATURES).toContain('urlTarget')
expect(SIDEBAR_FEATURES).toContain('pluginSettings')
expect(SIDEBAR_FEATURES).toContain('tabLifecycle')
expect(SIDEBAR_FEATURES).toContain('badge')
expect(SIDEBAR_FEATURES).toContain('updateTab')
expect(SIDEBAR_FEATURES).toContain('openFile')
expect(SIDEBAR_FEATURES).toContain('targetedOpen')
expect(SIDEBAR_FEATURES).toContain('stateSubscription')
expect(SIDEBAR_FEATURES).toContain('tabMeta')
expect(SIDEBAR_SERVICE_VERSION).toMatch(/^\d+\.\d+\.\d+/)
})
it('registerFileViewer throws on duplicate id', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerFileViewer({ id: 'csv', exts: ['csv'], fetchStrategy: 'custom', component: () => null })
expect(() => service.registerFileViewer({ id: 'csv', exts: ['csv'], fetchStrategy: 'custom', component: () => null })).toThrow(/already registered/)
})
}) })
+64
View File
@@ -0,0 +1,64 @@
// @vitest-environment jsdom
/** Settings-navigation icon marker tests (including delayed dialog mount). */
import { afterEach, describe, expect, it } from 'vitest'
import { registerSettingsNavIcon, SETTINGS_NAV_MARKER } from '../src/client/settings-nav-icon.ts'
function navButton(label: string): HTMLButtonElement {
const button = document.createElement('button')
button.innerHTML = `<svg data-fallback="gear"></svg><span>${label}</span>`
return button
}
function dialogWith(...buttons: HTMLButtonElement[]): HTMLElement {
const dialog = document.createElement('div')
dialog.setAttribute('role', 'dialog')
const nav = document.createElement('nav')
nav.append(...buttons)
dialog.append(nav)
return dialog
}
async function mutationTick(): Promise<void> {
await new Promise<void>(resolve => { setTimeout(resolve, 0) })
}
afterEach(() => {
document.body.innerHTML = ''
})
describe('registerSettingsNavIcon', () => {
it('marks only the matching settings row and preserves the shell icon DOM', () => {
const general = navButton('General')
const sideCard = navButton('Side card')
document.body.append(dialogWith(general, sideCard))
const dispose = registerSettingsNavIcon(() => 'Side card')
expect(general.hasAttribute(SETTINGS_NAV_MARKER)).toBe(false)
expect(sideCard.hasAttribute(SETTINGS_NAV_MARKER)).toBe(true)
expect(sideCard.querySelector('[data-fallback="gear"]')).not.toBeNull()
dispose()
expect(sideCard.hasAttribute(SETTINGS_NAV_MARKER)).toBe(false)
})
it('marks a dialog mounted later and follows a localized label change', async () => {
let label = 'Side card'
const dispose = registerSettingsNavIcon(() => label)
const english = navButton('Side card')
const chinese = navButton('侧边卡片')
const dialog = dialogWith(english, chinese)
document.body.append(dialog)
await mutationTick()
expect(english.hasAttribute(SETTINGS_NAV_MARKER)).toBe(true)
expect(chinese.hasAttribute(SETTINGS_NAV_MARKER)).toBe(false)
label = '侧边卡片'
chinese.querySelector('span')!.textContent = '侧边卡片 '
await mutationTick()
expect(english.hasAttribute(SETTINGS_NAV_MARKER)).toBe(false)
expect(chinese.hasAttribute(SETTINGS_NAV_MARKER)).toBe(true)
dispose()
})
})
+103 -5
View File
@@ -1,9 +1,11 @@
/** /**
* Interactive tests for the settings popup's text/number rows: the draft is * Interactive tests for the settings popup's text/number/select rows: the
* local state committed on blur through the parent's onCommit; the parent's * draft is local state committed on blur through the parent's onCommit; the
* canonical return is adopted (clamped numbers, stored value for invalid * parent's canonical return is adopted (clamped numbers, stored value for
* input), and the row remounts when the committed pref value changes (a * invalid input), and the row remounts when the committed pref value changes
* failed commit reverts prefs → the stored value reappears). * (a failed commit reverts prefs → the stored value reappears). Select rows
* commit the picked option's value (single) or the picked values in options
* order (multi) through onSelectValue.
* *
* Rendered with createRoot + act() in jsdom (the SSR specs stay in * Rendered with createRoot + act() in jsdom (the SSR specs stay in
* side-card-section.spec.tsx; this file exercises the event paths). * side-card-section.spec.tsx; this file exercises the event paths).
@@ -173,3 +175,99 @@ describe('FeatureSettingsRows typed rows (interactive)', () => {
unmount() unmount()
}) })
}) })
/** Open one select row's dropdown: click the anchor, the Menu portals its
* list into document.body (role="menuitem" rows). */
function openSelect(container: HTMLDivElement): HTMLElement[] {
const anchor = container.querySelector<HTMLButtonElement>('button[aria-haspopup="listbox"]')!
act(() => { anchor.click() })
return [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
}
describe('FeatureSettingsRows select rows (interactive)', () => {
const options: NonNullable<SidebarSettingToggle['options']> = [
{ value: true, icon: (size: number) => createElement('i', { 'data-size': size }), title: () => 'Merged', desc: () => 'Docked tree' },
{ value: false, icon: (size: number) => createElement('i', { 'data-size': size }), title: () => 'Split', desc: () => 'Two tabs' },
]
it('commits the picked option value and closes (iconed single select)', () => {
const commits: Array<[string, unknown]> = []
const toggle: SidebarSettingToggle = {
key: 'editorExplorer',
type: 'select',
title: () => 'Editor explorer',
options,
}
const { container, unmount } = mount(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs,
onToggle: () => {},
onSelectValue: (t, next) => { commits.push([t.key, next]) },
}))
// The closed anchor shows the selected option's title (icon variant).
expect(container.textContent).toContain('Merged')
const items = openSelect(container)
// Big-icon cards: title + desc per option.
expect(items.map(item => item.textContent)).toEqual(['MergedDocked tree', 'SplitTwo tabs'])
act(() => { items[1]!.click() })
expect(commits).toEqual([['editorExplorer', false]])
// Single-pick closes the dropdown.
expect(document.querySelectorAll('[role="menuitem"]')).toHaveLength(0)
unmount()
})
it('toggles membership and commits the picked values in options order (multi)', () => {
const commits: Array<[string, unknown]> = []
const plain: NonNullable<SidebarSettingToggle['options']> = [
{ value: 'a', title: () => 'Alpha' },
{ value: 'b', title: () => 'Beta' },
{ value: 'c', title: () => 'Gamma' },
]
const toggle: SidebarSettingToggle = {
key: 'pluginKey',
type: 'select',
multi: true,
title: () => 'Pick several',
options: plain,
}
const rows = (value: unknown) => createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs,
onToggle: () => {},
onSelectValue: (t, next) => { commits.push([t.key, next]) },
valueSource: () => value,
})
const { container, rerender, unmount } = mount(rows(['c', 'a']))
// The anchor shows the picked titles; options follow the declared order.
const items = openSelect(container)
expect(items.map(item => item.textContent)).toEqual(['Alpha', 'Beta', 'Gamma'])
// Deselect 'a' → ['c'] is normalized to options order… (['c'] here).
act(() => { items[0]!.click() })
expect(commits).toEqual([['pluginKey', ['c']]])
// The menu stays open under multi; re-render with the committed value.
rerender(rows(['c']))
const again = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
expect(again).toHaveLength(3)
// Select 'b' → committed in OPTIONS order, not pick order.
act(() => { again[1]!.click() })
expect(commits[1]).toEqual(['pluginKey', ['b', 'c']])
unmount()
})
it('shows an em dash when nothing is selected', () => {
const toggle: SidebarSettingToggle = {
key: 'editorExplorer',
type: 'select',
title: () => 'Editor explorer',
options,
}
const { container, unmount } = mount(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs,
onToggle: () => {},
valueSource: () => undefined,
}))
expect(container.querySelector('button[aria-haspopup="listbox"]')!.textContent).toContain('—')
unmount()
})
})
+12 -11
View File
@@ -80,13 +80,14 @@ describe('SideCardSection declarative inventory', () => {
expect(html).toContain('>explorer<') expect(html).toContain('>explorer<')
expect(html).toContain('data-icon="subagent"') expect(html).toContain('data-icon="subagent"')
expect(html).toContain('>Subagents<') expect(html).toContain('>Subagents<')
// Default prefs: openByDefault + interceptOpenPath switches checked, and // Default prefs: only the interceptOpenPath switch is checked (openByDefault
// both tabs + the image viewer cards pressed (3 aria-pressed cards). // now defaults off), and both tabs + the image viewer cards pressed
// (3 aria-pressed cards).
// The nested auto-open toggle is NOT an inline card (it lives in the popup). // The nested auto-open toggle is NOT an inline card (it lives in the popup).
expect(pressedCount(html, 'true')).toBe(3) expect(pressedCount(html, 'true')).toBe(3)
expect(pressedCount(html, 'false')).toBe(0) expect(pressedCount(html, 'false')).toBe(0)
// The general toggles are custom switches (real checkboxes, checked). // The general toggles are custom switches (real checkboxes, one checked).
expect(html.match(/checked=""/g)?.length).toBe(2) expect(html.match(/checked=""/g)?.length).toBe(1)
expect(html).not.toContain('Auto-open Subagents') expect(html).not.toContain('Auto-open Subagents')
}) })
@@ -140,9 +141,9 @@ describe('SideCardSection declarative inventory', () => {
expect(html).toContain('>Subagents<') expect(html).toContain('>Subagents<')
expect(html).toContain('>Image<') expect(html).toContain('>Image<')
expect(pressedCount(html, 'false')).toBe(2) expect(pressedCount(html, 'false')).toBe(2)
// The explorer card stays pressed; the general switches stay checked. // The explorer card stays pressed; the one default-on general switch stays checked.
expect(pressedCount(html, 'true')).toBe(1) expect(pressedCount(html, 'true')).toBe(1)
expect(html.match(/checked=""/g)?.length).toBe(2) expect(html.match(/checked=""/g)?.length).toBe(1)
}) })
it('hides the gear of a disabled feature (its related settings are dormant)', () => { it('hides the gear of a disabled feature (its related settings are dormant)', () => {
@@ -158,11 +159,11 @@ describe('SideCardSection declarative inventory', () => {
// The general row renders its title and description. // The general row renders its title and description.
expect(html).toContain('Position compatibility mode') expect(html).toContain('Position compatibility mode')
expect(html).toContain('Reserve space for the native Windows title bar') expect(html).toContain('Reserve space for the native Windows title bar')
// Three general rows now: openByDefault + interceptOpenPath checked, // Three general rows now: only interceptOpenPath is checked by default
// the new titleBarCompat row UNCHECKED (default off) — the checked // (openByDefault defaults off, the titleBarCompat row starts UNCHECKED) —
// checkbox count stays at 2 while the total checkbox count is 3. // the checked checkbox count is 1 while the total checkbox count is 3.
expect(html.match(/type="checkbox"/g)?.length).toBe(3) expect(html.match(/type="checkbox"/g)?.length).toBe(3)
expect(html.match(/checked=""/g)?.length).toBe(2) expect(html.match(/checked=""/g)?.length).toBe(1)
// The row's gear (customize the shift distance) is dormant while the // The row's gear (customize the shift distance) is dormant while the
// mode is off — the feature-card convention. // mode is off — the feature-card convention.
expect(html).not.toContain('Position compatibility mode Feature settings') expect(html).not.toContain('Position compatibility mode Feature settings')
@@ -170,7 +171,7 @@ describe('SideCardSection declarative inventory', () => {
// When the pref is on, the new switch is checked and the gear appears. // When the pref is on, the new switch is checked and the gear appears.
store.setPrefs({ ...store.getPrefs(), titleBarCompat: true }) store.setPrefs({ ...store.getPrefs(), titleBarCompat: true })
html = renderSection(store, service) html = renderSection(store, service)
expect(html.match(/checked=""/g)?.length).toBe(3) expect(html.match(/checked=""/g)?.length).toBe(2)
expect(html).toContain('aria-label="Position compatibility mode Feature settings"') expect(html).toContain('aria-label="Position compatibility mode Feature settings"')
}) })
}) })
+2 -1
View File
@@ -53,7 +53,8 @@ function mountSidebar(): MountedSidebar {
document.body.append(container) document.body.append(container)
const store = createSidebarStore() const store = createSidebarStore()
const service = createBetterSidebarService(store) const service = createBetterSidebarService(store)
// Fresh-session seed: the panel starts OPEN (openByDefault default true). // Fresh-session seed: open the panel explicitly (openByDefault defaults off).
store.setPrefs({ ...store.getPrefs(), openByDefault: true })
store.setSession('s1') store.setSession('s1')
// useSyncExternalStore requires STABLE snapshots across calls (the real DSH // useSyncExternalStore requires STABLE snapshots across calls (the real DSH
// services return stable objects) — a fresh object per call loops forever. // services return stable objects) — a fresh object per call loops forever.
+57 -10
View File
@@ -12,7 +12,7 @@ import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-setti
import { apply, mediaTypeForPath } from '../src/index.ts' import { apply, mediaTypeForPath } from '../src/index.ts'
import * as git from '../src/git.ts' import * as git from '../src/git.ts'
import { listDirectory } from '../src/fs-tree.ts' import { listDirectory } from '../src/fs-tree.ts'
import { defaultShell, PtyManager } from '../src/pty-manager.ts' import { defaultShell, PtyManager, type SidebarPty } from '../src/pty-manager.ts'
import type { SidebarWebRoute, SidebarWebUpgradeRoute } from '../src/context-types.ts' import type { SidebarWebRoute, SidebarWebUpgradeRoute } from '../src/context-types.ts'
interface FakeContext { interface FakeContext {
@@ -31,6 +31,32 @@ interface FakeContext {
get: (key: string) => undefined get: (key: string) => undefined
} }
/**
* The login-shell test spawns a real pty whose bash may still be writing to
* the temp HOME (history files, etc.) when `disposeAll()` returns — `close()`
* only requests the kill and the process exit lands asynchronously in
* `onExit`. Deleting the directory immediately then races the shell and
* fails with ENOTEMPTY on CI. Wait for the spawned handle to report `exited`
* (bounded), then remove with a short retry loop as a belt-and-braces
* fallback for any straggler fd.
*/
async function rmTempDirAfterPtyExit(handle: { exited: boolean }, dir: string): Promise<void> {
const deadline = Date.now() + 2000
while (Date.now() < deadline && !handle.exited) {
await new Promise((resolve) => setTimeout(resolve, 50))
}
for (let attempt = 0; ; attempt++) {
try {
rmSync(dir, { recursive: true, force: true })
return
} catch (error) {
const busy = (error as NodeJS.ErrnoException).code === 'ENOTEMPTY' || (error as NodeJS.ErrnoException).code === 'EBUSY'
if (!busy || attempt >= 4) throw error
await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1)))
}
}
}
describe('host plugin smoke', () => { describe('host plugin smoke', () => {
it('serves PDF with the browser-native content type', () => { it('serves PDF with the browser-native content type', () => {
expect(mediaTypeForPath('/work/report.PDF')).toBe('application/pdf') expect(mediaTypeForPath('/work/report.PDF')).toBe('application/pdf')
@@ -191,6 +217,7 @@ describe('host plugin smoke', () => {
it.skipIf(process.platform === 'win32')('spawns the shell as a login shell (loads ~/.profile)', async () => { it.skipIf(process.platform === 'win32')('spawns the shell as a login shell (loads ~/.profile)', async () => {
const home = mkdtempSync(join(tmpdir(), 'dsh-sidebar-login-')) const home = mkdtempSync(join(tmpdir(), 'dsh-sidebar-login-'))
const previousHome = process.env.HOME const previousHome = process.env.HOME
let handle: SidebarPty | undefined
try { try {
// A login bash reads ~/.profile (a non-login interactive bash reads // A login bash reads ~/.profile (a non-login interactive bash reads
// ~/.bashrc instead), so this marker proves the spawn used a login // ~/.bashrc instead), so this marker proves the spawn used a login
@@ -199,7 +226,7 @@ describe('host plugin smoke', () => {
process.env.HOME = home process.env.HOME = home
const manager = new PtyManager('/bin/bash', 3) const manager = new PtyManager('/bin/bash', 3)
try { try {
const handle = manager.open('s5', 't1', process.cwd(), 80, 24) handle = manager.open('s5', 't1', process.cwd(), 80, 24)
handle.pty.write('echo $DSH_LOGIN_MARKER\r') handle.pty.write('echo $DSH_LOGIN_MARKER\r')
const deadline = Date.now() + 5000 const deadline = Date.now() + 5000
while (!handle.transcript.includes('loaded-from-profile') && Date.now() < deadline) { while (!handle.transcript.includes('loaded-from-profile') && Date.now() < deadline) {
@@ -212,7 +239,7 @@ describe('host plugin smoke', () => {
} finally { } finally {
if (previousHome === undefined) delete process.env.HOME if (previousHome === undefined) delete process.env.HOME
else process.env.HOME = previousHome else process.env.HOME = previousHome
rmSync(home, { recursive: true, force: true }) await rmTempDirAfterPtyExit(handle ?? { exited: true }, home)
} }
}) })
@@ -449,11 +476,15 @@ describe('session cwd resolution over the API route', () => {
describe('side card settings routes', () => { describe('side card settings routes', () => {
/** A minimal settings seam: register/describe/update with the revision guard. */ /** A minimal settings seam: register/describe/update with the revision guard. */
const createFakeSettings = () => { const namespaces = new Map<string, { const createFakeSettings = (pre?: Record<string, Record<string, unknown>>) => {
const namespaces = new Map<string, {
schema: unknown schema: unknown
value: Record<string, unknown> | undefined value: Record<string, unknown> | undefined
revision: number revision: number
}>() }>()
for (const [ns, value] of Object.entries(pre ?? {})) {
namespaces.set(ns, { schema: (input: unknown) => input, value, revision: 0 })
}
const resolve = (entry: { schema: unknown; value: Record<string, unknown> | undefined }): unknown => { const resolve = (entry: { schema: unknown; value: Record<string, unknown> | undefined }): unknown => {
const schema = entry.schema as (input: unknown) => unknown const schema = entry.schema as (input: unknown) => unknown
return entry.value === undefined ? schema(undefined) : schema(entry.value) return entry.value === undefined ? schema(undefined) : schema(entry.value)
@@ -530,7 +561,21 @@ describe('side card settings routes', () => {
const route = mountWithSettings(undefined) const route = mountWithSettings(undefined)
const result = await invoke(route, 'settings.get', {}) const result = await invoke(route, 'settings.get', {})
expect(result.ok).toBe(true) expect(result.ok).toBe(true)
expect(result.value).toEqual({ value: undefined, revision: undefined }) expect(result.value).toEqual({ value: undefined, revision: undefined, externalDisable: false })
})
it('reports externalDisable false when the aionui namespace is absent', async () => {
const route = mountWithSettings(createFakeSettings())
const result = await invoke(route, 'settings.get', {})
expect(result.ok).toBe(true)
expect((result.value as { externalDisable?: boolean }).externalDisable).toBe(false)
})
it('reports externalDisable true while the aionui provider is selected', async () => {
const route = mountWithSettings(createFakeSettings({ 'aionui-panel': { rightPanel: 'aionui-panel' } }))
const result = await invoke(route, 'settings.get', {})
expect(result.ok).toBe(true)
expect((result.value as { externalDisable?: boolean }).externalDisable).toBe(true)
}) })
it('reads the resolved prefs and writes a patch through the seam', async () => { it('reads the resolved prefs and writes a patch through the seam', async () => {
@@ -539,8 +584,8 @@ describe('side card settings routes', () => {
expect(read.ok).toBe(true) expect(read.ok).toBe(true)
expect(read.value).toEqual({ expect(read.value).toEqual({
value: { value: {
openByDefault: true, openByDefault: false,
defaultWidthPercent: 30, defaultWidthPercent: 35,
autoOpenSubagent: true, autoOpenSubagent: true,
autoOpenJobs: true, autoOpenJobs: true,
agentTerminalTools: false, agentTerminalTools: false,
@@ -548,6 +593,7 @@ describe('side card settings routes', () => {
terminalFontFamily: '', terminalFontFamily: '',
terminalFontSize: 13, terminalFontSize: 13,
interceptOpenPath: true, interceptOpenPath: true,
editorExplorer: true,
titleBarCompat: false, titleBarCompat: false,
titleBarStripPx: 40, titleBarStripPx: 40,
htmlViewerNoSandbox: false, htmlViewerNoSandbox: false,
@@ -563,13 +609,14 @@ describe('side card settings routes', () => {
pluginSettings: {}, pluginSettings: {},
}, },
revision: 0, revision: 0,
externalDisable: false,
}) })
const written = await invoke(route, 'settings.update', { patch: { openByDefault: false } }) const written = await invoke(route, 'settings.update', { patch: { openByDefault: true } })
expect(written.ok).toBe(true) expect(written.ok).toBe(true)
const view = written.value as { value: { openByDefault: boolean; defaultWidthPercent: number }; revision: number } const view = written.value as { value: { openByDefault: boolean; defaultWidthPercent: number }; revision: number }
expect(view.value.openByDefault).toBe(false) expect(view.value.openByDefault).toBe(true)
expect(view.value.defaultWidthPercent).toBe(30) expect(view.value.defaultWidthPercent).toBe(35)
expect(view.revision).toBe(1) expect(view.revision).toBe(1)
}) })
+875
View File
@@ -0,0 +1,875 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
activateTab, allLeaves, BOTTOM_DEFAULT, BOTTOM_MIN, closeTab, createSidebarStore,
insertLeafAt, makeDefaultState, migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab,
openTabInActivePane, patchTab, resizeSplit, resizeSplitIn, sanitizeState, setBottomHeight,
splitPane, tabOpenIn, toggleBottomPanel, toggleExpanded, togglePanel,
type SidebarState, type SidebarTab, type SplitNode,
} from '../src/client/state.ts'
describe('sidebar state', () => {
const state = (): SidebarState => makeDefaultState()
it('makeDefaultState seeds per the seed enum (editor-home / none)', () => {
// Default and explicit 'editor-home' seed an EMPTY editor tab (the files
// window) with the tree panel pinned open.
for (const s of [makeDefaultState(), makeDefaultState(400, true, 'editor-home')]) {
const leaf = s.splits as { tabs: SidebarTab[]; active: string | null }
expect(leaf.tabs).toHaveLength(1)
expect(leaf.tabs[0]!.type).toBe('editor')
expect(leaf.tabs[0]!.title).toBe('Files')
expect(leaf.tabs[0]!.path).toBeUndefined()
expect(leaf.tabs[0]!.meta).toEqual({ treeOpen: true })
expect(leaf.active).toBe(leaf.tabs[0]!.id)
}
// The seeded home tab survives the persist round-trip (meta intact).
const restored = sanitizeState(JSON.parse(JSON.stringify(makeDefaultState())))
const restoredLeaf = restored!.splits as { tabs: SidebarTab[] }
expect(restoredLeaf.tabs[0]!.meta).toEqual({ treeOpen: true })
// 'none' seeds an empty pane.
const bare = makeDefaultState(400, true, 'none')
const bareLeaf = bare.splits as { tabs: SidebarTab[]; active: string | null }
expect(bareLeaf.tabs).toHaveLength(0)
expect(bareLeaf.active).toBeNull()
})
it('sanitizeState migrates persisted explorer tabs to editor home tabs (both trees)', () => {
const valid = sanitizeState({
panelOpen: true,
width: 400,
nextTerminal: 1,
activePane: 'pane:1',
expanded: [],
splits: {
kind: 'leaf',
id: 'pane:1',
active: 'ex-right',
tabs: [{ id: 'ex-right', type: 'explorer', title: 'Explorer', meta: { treeWidth: 300 } }],
},
bottomSplits: {
kind: 'leaf',
id: 'pane:b',
active: 'ex-bottom',
tabs: [{ id: 'ex-bottom', type: 'explorer', title: 'Explorer' }],
},
})
const right = (valid?.splits as { tabs: SidebarTab[] }).tabs
expect(right).toHaveLength(1)
// Migrated: editor home tab (no path), tree pinned open, prior meta kept.
expect(right[0]).toMatchObject({ id: 'ex-right', type: 'editor', title: 'Files', meta: { treeOpen: true, treeWidth: 300 } })
expect(right[0]!.path).toBeUndefined()
const bottom = (valid?.bottomSplits as { tabs: SidebarTab[] }).tabs
expect(bottom).toHaveLength(1)
expect(bottom[0]).toMatchObject({ id: 'ex-bottom', type: 'editor', title: 'Files', meta: { treeOpen: true } })
})
it('opens tabs into the active pane and dedupes by id (safety net)', () => {
let s = state()
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
s = openTabInActivePane(s, gitTab)
expect(s.splits.kind).toBe('leaf')
expect((s.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
// Reopening with the SAME id focuses the existing tab instead of duplicating.
const after = openTabInActivePane(s, { id: 'git', type: 'git' as const, title: 'Git' })
expect((after.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
// A different id opens a new tab (type-level dedupe is the service's job).
const after2 = openTabInActivePane(s, { id: 'git2', type: 'git' as const, title: 'Git' })
expect((after2.splits as { tabs: unknown[] }).tabs).toHaveLength(3)
})
it('opens multiple editors with distinct ids (path-level dedupe is the service descriptor\'s job)', () => {
let s = state()
const firstId = (s.splits as { tabs: { id: string }[] }).tabs[0]!.id
s = openTabInActivePane(s, { id: 'e1', type: 'editor', title: 'a.ts', path: '/p/a.ts' })
const after = openTabInActivePane(s, { id: 'e2', type: 'editor', title: 'a.ts', path: '/p/a.ts' })
expect((after.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([firstId, 'e1', 'e2'])
})
const diffTab = (id: string): SidebarTab => ({
id,
type: 'diff',
title: id,
diff: { kind: 'worktree', path: 'src/a.ts', staged: false },
})
it('first diff splits the source pane vertically (diff below)', () => {
const s = state()
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
const withGit = openTabInActivePane(s, gitTab)
const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id
const after = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts'))
expect(after.splits.kind).toBe('split')
const split = after.splits as { dir: string; children: { kind: string; tabs?: SidebarTab[]; id: string }[] }
expect(split.dir).toBe('col')
expect(split.children).toHaveLength(2)
// The source stays on TOP (first child), the diff lands in the new bottom leaf.
expect(split.children[0]!.id).toBe(sourcePane)
expect(split.children[1]!.tabs?.map(tab => tab.id)).toEqual(['diff:w:u:src/a.ts'])
expect(after.activePane).toBe(split.children[1]!.id)
})
it('reopening the same diff focuses its existing tab', () => {
const s = state()
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
const withGit = openTabInActivePane(s, gitTab)
const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id
const first = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts'))
const second = openDiffTab(first, sourcePane, diffTab('diff:w:u:src/a.ts'))
// No new panes, no duplicate tabs.
expect(second.splits.kind).toBe('split')
const split = second.splits as { children: { kind: string; tabs?: SidebarTab[] }[] }
const allTabs = split.children.flatMap(child => child.tabs ?? [])
expect(allTabs.filter(tab => tab.type === 'diff')).toHaveLength(1)
})
it('subsequent diffs stack into the existing diff pane', () => {
const s = state()
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
const withGit = openTabInActivePane(s, gitTab)
const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id
const first = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts'))
const second = openDiffTab(first, sourcePane, diffTab('diff:c:abc1234def5678abc1234def5678abc1234def5678'))
// Still one split: the second diff joins the bottom leaf instead of splitting again.
expect(second.splits.kind).toBe('split')
const split = second.splits as { children: { kind: string; tabs?: SidebarTab[] }[] }
const diffLeaves = split.children.filter(child => child.tabs?.some(tab => tab.type === 'diff'))
expect(diffLeaves).toHaveLength(1)
expect(diffLeaves[0]!.tabs?.map(tab => tab.id)).toEqual([
'diff:w:u:src/a.ts',
'diff:c:abc1234def5678abc1234def5678abc1234def5678',
])
})
it('openDiffTab degrades to a regular open when the source pane is gone', () => {
const s = state()
const after = openDiffTab(s, 'pane:gone', diffTab('diff:w:u:src/a.ts'))
expect(after.splits.kind).toBe('leaf')
expect((after.splits as { tabs: SidebarTab[] }).tabs.map(tab => tab.id)).toContain('diff:w:u:src/a.ts')
})
it('sanitize drops diff tabs (ephemeral, like VSCode diff editors)', () => {
const valid = sanitizeState({
panelOpen: true,
width: 400,
nextTerminal: 1,
activePane: 'pane:1',
expanded: [],
splits: {
kind: 'leaf',
id: 'pane:1',
active: 'd1',
tabs: [
{ id: 'explorer-tab', type: 'explorer', title: 'Explorer' },
{ id: 'd1', type: 'diff', title: 'a.ts', diff: { kind: 'worktree', path: 'src/a.ts', staged: false } },
],
},
})
expect(valid?.splits.kind).toBe('leaf')
const tabs = (valid?.splits as { tabs: SidebarTab[] }).tabs
expect(tabs.map(tab => tab.id)).toEqual(['explorer-tab'])
// The dropped diff tab was the active one: the leaf falls back to a null
// active instead of resetting the whole state.
expect((valid?.splits as { active: string | null }).active).toBeNull()
// A leaf of ONLY diff tabs survives as an empty pane (welcome cards).
const onlyDiff = sanitizeState({
panelOpen: true,
width: 400,
nextTerminal: 1,
activePane: 'pane:1',
expanded: [],
splits: {
kind: 'leaf',
id: 'pane:1',
active: 'd1',
tabs: [{ id: 'd1', type: 'diff', title: 'a.ts' }],
},
})
expect(onlyDiff?.splits.kind).toBe('leaf')
expect((onlyDiff?.splits as { tabs: SidebarTab[] }).tabs).toEqual([])
})
it('dedupes the single-instance subagent tab (focuses instead of duplicating)', () => {
let s = state()
s = openTabInActivePane(s, { id: 'subagent', type: 'subagent', title: 'Subagents' })
expect((s.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
// Reopening (e.g. the auto-activation effect) focuses the existing tab.
const after = openTabInActivePane(s, { id: 'subagent', type: 'subagent', title: 'Subagents' })
expect((after.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
const tabs = (after.splits as { tabs: { type: string; id: string }[] }).tabs
expect(tabs.filter(tab => tab.type === 'subagent')).toHaveLength(1)
})
it('splits panes and moves tabs between them', () => {
let s = state()
s = splitPane(s, 'row')
expect(s.splits.kind).toBe('split')
const split = s.splits as Extract<SplitNode, { kind: 'split' }>
expect(split.children).toHaveLength(2)
const explorerId = (split.children[0] as { id: string }).id
const otherId = (split.children[1] as { id: string }).id
expect((split.children[1] as { tabs: unknown[] }).tabs).toHaveLength(0)
const explorerTab = ((split.children[0] as { tabs: { id: string }[] }).tabs[0]!).id
s = moveTab(s, explorerId, explorerTab, otherId)
// The source pane emptied and was removed; the target leaf is promoted.
expect(s.splits.kind).toBe('leaf')
expect((s.splits as { id: string }).id).toBe(otherId)
expect((s.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([explorerTab])
})
it('dragging a tab to a pane edge splits the pane with the tab in a fresh leaf', () => {
let s = state()
s = splitPane(s, 'row')
const split = s.splits as Extract<SplitNode, { kind: 'split' }>
const paneA = split.children[0] as { id: string; tabs: { id: string }[] }
const paneB = split.children[1] as { id: string; tabs: { id: string }[] }
const tabId = paneA.tabs[0]!.id
// 先给 paneB 一个 tab,然后拖 paneA 的 tab 到 paneB 的 right 边缘。
s = openTabInActivePane(s, { id: 't2', type: 'terminal', title: 'T2' })
s = moveTabToEdge(s, paneA.id, tabId, paneB.id, 'right')
const after = s.splits as Extract<SplitNode, { kind: 'split' }>
// paneB 现在是 split(row) [旧leaf, 新leaf(tabId)];其父 split 仍存在。
const bSplit = after.children.find(child => child.kind === 'split') as Extract<SplitNode, { kind: 'split' }> | undefined
expect(bSplit).toBeDefined()
expect(bSplit!.dir).toBe('row')
const newLeaf = bSplit!.children[1] as { tabs: { id: string }[] }
expect(newLeaf.tabs.map(t => t.id)).toContain(tabId)
})
it('dragging a tab to a pane center merges it into the pane', () => {
let s = state()
s = splitPane(s, 'col')
const split = s.splits as Extract<SplitNode, { kind: 'split' }>
const paneA = split.children[0] as { id: string; tabs: { id: string }[] }
const paneB = split.children[1] as { id: string; tabs: { id: string }[] }
const tabId = paneA.tabs[0]!.id
s = moveTabToEdge(s, paneA.id, tabId, paneB.id, 'center')
// paneA 空了被移除,树退化为 paneB(含 tab)。
expect(s.splits.kind).toBe('leaf')
expect((s.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([tabId])
})
it('dragging a tab back onto its own pane center reorders it', () => {
let s = state()
s = openTabInActivePane(s, { id: 't2', type: 'terminal', title: 'T2' })
const leaf = s.splits as { id: string; tabs: { id: string }[] }
const first = leaf.tabs[0]!.id
s = moveTabToEdge(s, leaf.id, first, leaf.id, 'center')
const after = s.splits as { tabs: { id: string }[] }
expect(after.tabs[after.tabs.length - 1]!.id).toBe(first)
expect(after.tabs).toHaveLength(2)
})
it('closing the last tab removes the pane (promotes the sibling)', () => {
let s = state()
s = splitPane(s, 'col')
const split = s.splits as Extract<SplitNode, { kind: 'split' }>
const paneA = split.children[0] as { id: string; tabs: { id: string }[] }
const paneB = split.children[1] as { id: string }
const explorerId = paneA.tabs[0]!.id
// paneA gets a terminal; the explorer moves to paneB; closing the
// terminal empties paneA, which is removed, promoting paneB.
s = openTabInActivePane(s, { id: 't', type: 'terminal', title: 'Terminal 1' })
s = moveTab(s, paneA.id, explorerId, paneB.id)
s = activateTab(s, paneA.id, 't')
s = closeTab(s, paneA.id, 't')
expect(s.splits.kind).toBe('leaf')
expect((s.splits as { id: string }).id).toBe(paneB.id)
})
it('resizes splits within the clamp range', () => {
let s = state()
s = splitPane(s, 'row')
const split = s.splits as Extract<SplitNode, { kind: 'split' }>
const id = split.id
s = { ...s, splits: resizeSplit(s.splits, id, 0, 0.2) }
const after = s.splits as Extract<SplitNode, { kind: 'split' }>
expect(after.sizes[0]).toBeCloseTo(0.7)
expect(after.sizes[1]).toBeCloseTo(0.3)
})
it('tracks explorer expansion and tab activation', () => {
let s = state()
s = toggleExpanded(s, '/p/a')
s = toggleExpanded(s, '/p/b')
expect(s.expanded).toEqual(['/p/a', '/p/b'])
s = toggleExpanded(s, '/p/a')
expect(s.expanded).toEqual(['/p/b'])
const leaf = s.splits as { id: string; tabs: { id: string }[]; active: string | null }
const tabId = leaf.tabs[0]!.id
const after = activateTab(s, leaf.id, tabId)
expect((after.splits as { active: string | null }).active).toBe(tabId)
})
it('patchTab updates the title and path of one open tab (browser persistence)', () => {
let s = state()
const leaf = s.splits as { id: string; tabs: { id: string; type: string; title: string; path?: string }[] }
s = openTabInActivePane(s, { id: 'browser:1', type: 'browser', title: 'Browser' })
const browserId = 'browser:1'
s = patchTab(s, browserId, { path: 'https://example.com/', title: 'example.com' })
const tab = (s.splits as { tabs: { id: string; title: string; path?: string }[] }).tabs.find(t => t.id === browserId)
expect(tab).toMatchObject({ title: 'example.com', path: 'https://example.com/' })
// A partial patch leaves the other field untouched.
s = patchTab(s, browserId, { title: 'example.org' })
const again = (s.splits as { tabs: { id: string; title: string; path?: string }[] }).tabs.find(t => t.id === browserId)
expect(again).toMatchObject({ title: 'example.org', path: 'https://example.com/' })
// Other tabs are untouched.
expect(leaf.tabs[0]).toBeDefined()
})
it('patchTab is a no-op for a missing tab id', () => {
const s = state()
const after = patchTab(s, 'nope', { title: 'X', path: 'https://x/' })
expect(after).toBe(s)
})
it('sanitize accepts nextBrowser (defaulting a missing/malformed one to 1)', () => {
const base = {
panelOpen: true,
width: 400,
nextTerminal: 1,
activePane: 'pane:1',
expanded: [],
splits: {
kind: 'leaf',
id: 'pane:1',
active: null,
tabs: [{ id: 't', type: 'explorer', title: 'Explorer' }],
},
}
// Older persisted states lack the field: they must keep loading.
expect(sanitizeState(base)?.nextBrowser).toBe(1)
// A present valid value survives; a malformed one falls back to 1.
expect(sanitizeState({ ...base, nextBrowser: 7 })?.nextBrowser).toBe(7)
expect(sanitizeState({ ...base, nextBrowser: 'x' })?.nextBrowser).toBe(1)
expect(sanitizeState({ ...base, nextBrowser: 0 })?.nextBrowser).toBe(1)
// The default state seeds 1.
expect(makeDefaultState().nextBrowser).toBe(1)
})
it('tabOpenIn: a tab is open until it is truly closed, wherever it lives', () => {
let s = state()
const leaf = s.splits as { id: string; tabs: { id: string }[] }
const explorerId = leaf.tabs[0]!.id
expect(tabOpenIn(s, explorerId)).toBe(true)
// Moving the tab to another pane keeps it open.
s = splitPane(s, 'row')
const split = s.splits as Extract<SplitNode, { kind: 'split' }>
const paneA = split.children[0] as { id: string; tabs: { id: string }[] }
const paneB = split.children[1] as { id: string }
s = moveTab(s, paneA.id, explorerId, paneB.id)
expect(tabOpenIn(s, explorerId)).toBe(true)
// Closing it removes it from the whole tree.
const target = s.splits as { id: string; tabs: { id: string }[] }
s = closeTab(s, target.id, explorerId)
expect(tabOpenIn(s, explorerId)).toBe(false)
// A terminal tab added later is open too.
s = openTabInActivePane(s, { id: 'terminal:9', type: 'terminal', title: 'Terminal 9' })
expect(tabOpenIn(s, 'terminal:9')).toBe(true)
})
// ── Bottom panel (the second, independent workbench) ───────────────────
it('toggleBottomPanel flips the bottom panel independently of the right panel', () => {
let s = state()
expect(s.bottomOpen).toBe(false)
s = toggleBottomPanel(s)
expect(s.bottomOpen).toBe(true)
// Collapsing the right panel leaves the bottom panel open (independent toggles).
s = togglePanel(s)
expect(s.panelOpen).toBe(false)
expect(s.bottomOpen).toBe(true)
})
it('setBottomHeight clamps to the contract range', () => {
expect(setBottomHeight(state(), 50).bottomHeight).toBe(BOTTOM_MIN)
const g = globalThis as Record<string, unknown>
const previous = g.window
g.window = { innerHeight: 800 }
try {
// The bottom panel must leave the center column at least PANEL_MIN
// tall (800 - 280), regardless of the right panel's open state.
expect(setBottomHeight(state(), 9999).bottomHeight).toBe(800 - 280)
expect(setBottomHeight({ ...state(), panelOpen: false }, 9999).bottomHeight).toBe(800 - 280)
} finally {
if (previous === undefined) delete g.window
else g.window = previous
}
})
// ── Narrow-viewport merge (bottom tabs thrown into the right sidebar) ──
it('migrateBottomTabs throws the bottom tree tabs into the right tree’s FIRST leaf', () => {
let s = state()
s = toggleBottomPanel(s)
const bottomPane = (s.bottomSplits as { id: string }).id
// Two bottom tabs in their own pane; the right pane holds explorer.
s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:1', type: 'terminal', title: 'T1' })
s = openTabInActivePane(s, { id: 'terminal:2', type: 'terminal', title: 'T2' })
const migrated = migrateBottomTabs(s)
// All tabs now live in the right tree's first leaf, bottom tabs appended.
expect((migrated.splits as { tabs: SidebarTab[] }).tabs.map(t => t.id))
.toEqual([expect.stringMatching(/^tab:/), 'terminal:1', 'terminal:2'])
// The bottom tree is emptied (structure stays), the panel closes, and
// new tabs land in the right tree.
expect((migrated.bottomSplits as { tabs: SidebarTab[] }).tabs).toHaveLength(0)
expect(migrated.bottomOpen).toBe(false)
expect(migrated.activePane).toBe((migrated.splits as { id: string }).id)
// The migrated tabs are fully functional: closing one works through the
// right tree.
expect(tabOpenIn(migrated, 'terminal:1')).toBe(true)
expect(tabOpenIn(closeTab(migrated, migrated.activePane!, 'terminal:1'), 'terminal:1')).toBe(false)
})
it('migrateBottomTabs appends into the FIRST leaf when the right tree is a split', () => {
let s = state()
s = splitPane(s, 'row') // splits the active pane into two leaves
s = toggleBottomPanel(s)
const bottomPane = (s.bottomSplits as { id: string }).id
s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:9', type: 'terminal', title: 'T9' })
const migrated = migrateBottomTabs(s)
// The first (leftmost) leaf carries the bottom tab; the second leaf
// keeps its own tabs untouched.
const leaves = allLeaves(migrated.splits)
expect(leaves[0]!.tabs.map(t => t.id)).toContain('terminal:9')
expect(allLeaves(migrated.bottomSplits).flatMap(l => l.tabs)).toHaveLength(0)
})
it('migrateBottomTabs is idempotent (same reference) once the bottom tree is empty and closed', () => {
const s = state()
expect(migrateBottomTabs(s)).toBe(s)
// With the panel open but no tabs, the migration only closes the panel.
const open = toggleBottomPanel(s)
const migrated = migrateBottomTabs(open)
expect(migrated).not.toBe(open)
expect(migrated.bottomOpen).toBe(false)
expect(migrateBottomTabs(migrated)).toBe(migrated)
})
it('migrateBottomTabs repoints an active pane that lives in the bottom tree', () => {
let s = state()
const bottomPane = (s.bottomSplits as { id: string }).id
s = { ...s, activePane: bottomPane } // empty bottom pane, panel closed
const migrated = migrateBottomTabs(s)
expect(migrated.activePane).toBe((migrated.splits as { id: string }).id)
// A tab opened after the migration lands in the VISIBLE right tree.
const landed = openTabInActivePane(migrated, { id: 'git', type: 'git' as const, title: 'Git' })
expect((landed.splits as { tabs: SidebarTab[] }).tabs.map(t => t.type)).toContain('git')
})
it('openTabInActivePane lands in the bottom tree when the active pane lives there', () => {
let s = state()
s = toggleBottomPanel(s)
const bottomPane = (s.bottomSplits as { id: string }).id
s = { ...s, activePane: bottomPane }
const tab = { id: 'git', type: 'git' as const, title: 'Git' }
s = openTabInActivePane(s, tab)
expect((s.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toContain('git')
// The right tree is untouched (its seeded files-window home tab stays).
expect((s.splits as { tabs: SidebarTab[] }).tabs.map(t => t.type)).toEqual(['editor'])
expect(s.activePane).toBe(bottomPane)
// The id safety net works across trees: reopening the same id focuses it.
const after = openTabInActivePane(s, tab)
expect((after.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toEqual(['git'])
})
it('openTabInActivePane falls back to the right tree when the active pane is stale', () => {
let s = state()
s = toggleBottomPanel(s)
s = { ...s, activePane: 'pane:gone' }
const after = openTabInActivePane(s, { id: 'git', type: 'git' as const, title: 'Git' })
expect((after.splits as { tabs: SidebarTab[] }).tabs.map(t => t.type)).toContain('git')
})
it('closeTab routes to the bottom tree', () => {
let s = state()
s = toggleBottomPanel(s)
const bottomPane = (s.bottomSplits as { id: string }).id
s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:1', type: 'terminal', title: 'T1' })
expect(tabOpenIn(s, 'terminal:1')).toBe(true)
s = closeTab(s, bottomPane, 'terminal:1')
expect(tabOpenIn(s, 'terminal:1')).toBe(false)
// The right tree is untouched.
expect(tabOpenIn(s, (s.splits as { tabs: { id: string }[] }).tabs[0]!.id)).toBe(true)
})
it('moveTabToEdge splits within the bottom tree', () => {
let s = state()
s = toggleBottomPanel(s)
const bottomPane = (s.bottomSplits as { id: string }).id
s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:1', type: 'terminal', title: 'T1' })
s = moveTabToEdge(s, bottomPane, 'terminal:1', bottomPane, 'right')
expect(s.bottomSplits.kind).toBe('split')
expect(s.splits.kind).toBe('leaf')
expect(tabOpenIn(s, 'terminal:1')).toBe(true)
// The fresh leaf (the drop's active pane) differs from the source pane.
expect(s.activePane).not.toBe(bottomPane)
})
it('resizeSplitIn routes a divider to its own tree', () => {
let s = state()
s = toggleBottomPanel(s)
const bottomPane = (s.bottomSplits as { id: string }).id
s = splitPane({ ...s, activePane: bottomPane }, 'row')
const split = s.bottomSplits as Extract<SplitNode, { kind: 'split' }>
s = resizeSplitIn(s, split.id, 0, 0.1)
const next = s.bottomSplits as Extract<SplitNode, { kind: 'split' }>
expect(next.sizes[0]).toBeCloseTo(0.6)
expect(s.splits.kind).toBe('leaf')
})
it('sanitize defaults the bottom fields for older persisted states and repairs a broken bottom tree', () => {
const base = {
panelOpen: true,
width: 400,
nextTerminal: 1,
activePane: 'pane:1',
expanded: [],
splits: {
kind: 'leaf',
id: 'pane:1',
active: null,
tabs: [{ id: 't', type: 'explorer', title: 'Explorer' }],
},
}
// Older persisted states lack the bottom fields: defaults, state kept.
const s = sanitizeState(base)
expect(s?.bottomOpen).toBe(false)
expect(s?.bottomHeight).toBe(BOTTOM_DEFAULT)
expect(s?.bottomSplits.kind).toBe('leaf')
expect((s?.bottomSplits as { tabs: SidebarTab[] }).tabs).toHaveLength(0)
// A malformed bottom tree is replaced with a fresh empty pane.
const broken = sanitizeState({ ...base, bottomSplits: 'junk' })
expect(broken?.splits).toBeDefined()
expect(broken?.bottomSplits.kind).toBe('leaf')
// A valid persisted bottom tree survives.
const withBottom = sanitizeState({
...base,
bottomOpen: true,
bottomHeight: 300,
bottomSplits: {
kind: 'leaf',
id: 'pane:9',
active: 'b1',
tabs: [{ id: 'b1', type: 'terminal', title: 'T' }],
},
})
expect(withBottom?.bottomOpen).toBe(true)
expect(withBottom?.bottomHeight).toBe(300)
expect((withBottom?.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toEqual(['b1'])
// Heights are clamped to the contract range.
expect(sanitizeState({ ...base, bottomHeight: 10 })?.bottomHeight).toBe(BOTTOM_MIN)
// A stale full-height bottom panel must not squeeze the center column
// (the agent output area) to zero: the cap leaves it at least PANEL_MIN
// tall, regardless of the right panel's open state.
const g = globalThis as Record<string, unknown>
const previous = g.window
g.window = { innerHeight: 800 }
try {
expect(sanitizeState({ ...base, panelOpen: true, bottomHeight: 9999 })?.bottomHeight).toBe(800 - 280)
expect(sanitizeState({ ...base, panelOpen: false, bottomHeight: 9999 })?.bottomHeight).toBe(800 - 280)
} finally {
if (previous === undefined) delete g.window
else g.window = previous
}
})
it('tabOpenIn and patchTab see tabs in the bottom tree', () => {
let s = state()
s = toggleBottomPanel(s)
const bottomPane = (s.bottomSplits as { id: string }).id
s = openTabInActivePane(
{ ...s, activePane: bottomPane },
{ id: 'browser:1', type: 'browser', title: 'example.com', path: 'https://example.com' },
)
expect(tabOpenIn(s, 'browser:1')).toBe(true)
s = patchTab(s, 'browser:1', { title: 'other.com', path: 'https://other.com' })
const tab = allLeaves(s.bottomSplits).flatMap(leaf => leaf.tabs).find(t => t.id === 'browser:1')
expect(tab?.title).toBe('other.com')
})
it('moves a tab across panels (center merge into the other tree)', () => {
let s = state()
s = toggleBottomPanel(s)
const rightPane = (s.splits as { id: string }).id
const bottomPane = (s.bottomSplits as { id: string }).id
const explorerId = (s.splits as { tabs: { id: string }[] }).tabs[0]!.id
// Drag the explorer tab from the right panel into the bottom panel (center).
s = moveTabToEdge(s, rightPane, explorerId, bottomPane, 'center')
expect((s.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toContain(explorerId)
expect((s.splits as { tabs: SidebarTab[] }).tabs).toHaveLength(0)
expect(s.activePane).toBe(bottomPane)
// And back, inserted at an index.
s = moveTab(s, bottomPane, explorerId, rightPane, 0)
expect((s.splits as { tabs: SidebarTab[] }).tabs[0]!.id).toBe(explorerId)
expect((s.bottomSplits as { tabs: SidebarTab[] }).tabs).toHaveLength(0)
})
it('moves a tab across panels by splitting the target pane (edge drop)', () => {
let s = state()
s = toggleBottomPanel(s)
const rightPane = (s.splits as { id: string }).id
const bottomPane = (s.bottomSplits as { id: string }).id
const explorerId = (s.splits as { tabs: { id: string }[] }).tabs[0]!.id
s = moveTabToEdge(s, rightPane, explorerId, bottomPane, 'right')
// The source tree empties back to a leaf; the target tree splits.
expect(s.splits.kind).toBe('leaf')
expect((s.splits as { tabs: SidebarTab[] }).tabs).toHaveLength(0)
expect(s.bottomSplits.kind).toBe('split')
expect(tabOpenIn(s, explorerId)).toBe(true)
const split = s.bottomSplits as Extract<SplitNode, { kind: 'split' }>
expect(split.children.some(
child => child.kind === 'leaf' && (child as { tabs: SidebarTab[] }).tabs.some(t => t.id === explorerId),
)).toBe(true)
// The fresh leaf (the drop's active pane) differs from the source pane.
expect(s.activePane).not.toBe(rightPane)
})
it('moveTab with a non-existent source or target pane is safe', () => {
const s = state()
const pane = (s.splits as { id: string }).id
// Missing source: returns unchanged state
expect(moveTab(s, 'pane:ghost', 'tab:1', pane)).toBe(s)
// Missing target: returns unchanged state
expect(moveTab(s, pane, 'tab:1', 'pane:ghost')).toBe(s)
})
it('closeTab with non-existent tab or pane returns equivalent state without throwing', () => {
const s = state()
const pane = (s.splits as { id: string }).id
expect(closeTab(s, 'pane:ghost', 'tab:1')).toEqual(s)
expect(closeTab(s, pane, 'tab:ghost')).toEqual(s)
})
it('moveTabToEdge with non-existent pane returns unchanged state', () => {
const s = state()
const pane = (s.splits as { id: string }).id
expect(moveTabToEdge(s, 'pane:ghost', 'tab:1', pane, 'right')).toBe(s)
expect(moveTabToEdge(s, pane, 'tab:1', 'pane:ghost', 'right')).toBe(s)
})
})
describe('persisted state sanitization', () => {
it('accepts a well-formed state unchanged (node environment: no width clamp)', () => {
const state = makeDefaultState(400)
const clean = sanitizeState(JSON.parse(JSON.stringify(state)))
expect(clean).toEqual(state)
})
it('accepts a subagent tab as a known type', () => {
const raw = JSON.parse(JSON.stringify(makeDefaultState(400)))
raw.splits.tabs.push({ id: 'tab:9', type: 'subagent', title: 'Subagents' })
raw.splits.active = 'tab:9'
const clean = sanitizeState(raw)
expect(clean).toBeDefined()
const tabs = (clean!.splits as { tabs: { type: string }[] }).tabs
expect(tabs.some(tab => tab.type === 'subagent')).toBe(true)
})
it('clamps undersized widths to the panel minimum', () => {
const state = { ...makeDefaultState(400), width: 10 }
const clean = sanitizeState(JSON.parse(JSON.stringify(state)))
expect(clean?.width).toBe(280)
})
it('rejects malformed shapes instead of crashing the panel', () => {
expect(sanitizeState(null)).toBeUndefined()
expect(sanitizeState('nope')).toBeUndefined()
expect(sanitizeState({})).toBeUndefined()
expect(sanitizeState({ ...makeDefaultState(400), width: 'wide' })).toBeUndefined()
expect(sanitizeState({ ...makeDefaultState(400), panelOpen: 1 })).toBeUndefined()
// A split whose sizes do not match its children is rejected.
const withSplit = JSON.parse(JSON.stringify(makeDefaultState(400)))
withSplit.splits = { kind: 'split', id: 's1', dir: 'row', sizes: [0.5], children: [] }
expect(sanitizeState(withSplit)).toBeUndefined()
// Unknown tab types (external plugins not yet loaded) are accepted —
// they render as <OrphanedTab/> at view time and recover if the plugin
// loads later. Only diff tabs are dropped (ephemeral).
const withExternalTab = JSON.parse(JSON.stringify(makeDefaultState(400)))
withExternalTab.splits.tabs[0].type = 'my-plugin:db'
const externalClean = sanitizeState(withExternalTab)
expect(externalClean).toBeDefined()
if (externalClean !== undefined && externalClean.splits.kind === 'leaf') {
expect(externalClean.splits.tabs[0]!.type).toBe('my-plugin:db')
}
// An active id that no tab carries is rejected.
const withBadActive = JSON.parse(JSON.stringify(makeDefaultState(400)))
withBadActive.splits.active = 'ghost-tab'
expect(sanitizeState(withBadActive)).toBeUndefined()
})
it('re-ids stale duplicate pane/split ids and follows the activePane rename', () => {
// The pre-seeding counter reset could mint a fresh "pane:1" beside the
// persisted "pane:1": mapLeaf then hit BOTH leaves and every open landed
// in both panes. Sanitize must give the repeat a fresh id.
const corrupted = JSON.parse(JSON.stringify(makeDefaultState(400)))
corrupted.activePane = 'pane:1'
corrupted.splits = {
kind: 'split',
id: 'split:1',
dir: 'col',
sizes: [0.5, 0.5],
children: [
{ kind: 'leaf', id: 'pane:1', tabs: [], active: null },
{ kind: 'leaf', id: 'pane:1', tabs: [{ id: 'tab:1', type: 'explorer', title: 'Explorer' }], active: 'tab:1' },
],
}
const clean = sanitizeState(corrupted)
expect(clean).toBeDefined()
const leaves = allLeaves(clean!.splits)
// The first occurrence keeps its id; the repeat gets a fresh unique one
// (exact suffix depends on the module-level uid counter, so assert shape).
expect(leaves[0]!.id).toBe('pane:1')
expect(new Set(leaves.map(leaf => leaf.id)).size).toBe(2)
expect(clean!.activePane).toBe(leaves[1]!.id)
// And an open must land in exactly one pane of the healed tree.
const opened = openTabInActivePane(clean!, { id: 'editor:/a.ts', type: 'editor', title: 'a.ts', path: '/a.ts' })
const owners = allLeaves(opened.splits).filter(leaf => leaf.tabs.some(tab => tab.path === '/a.ts'))
expect(owners).toHaveLength(1)
})
it('falls back from a stale active pane instead of dropping the open', () => {
let s = makeDefaultState()
const paneA = allLeaves(s.splits)[0]!.id
const seededTab = allLeaves(s.splits)[0]!.tabs.find(tab => tab.type === 'editor')!.id
s = closeTab(s, paneA, seededTab)
s = openTabInActivePane(s, { id: 'editor:/a.ts', type: 'editor', title: 'a.ts', path: '/a.ts' })
const split = insertLeafAt(s.splits, paneA, 'col', { id: 'terminal:1', type: 'terminal', title: 'Terminal 1' }, false)
s = { ...s, splits: split.node, activePane: paneA }
// Closing the editor empties paneA; the pane is removed but activePane
// still points at it. The next open must land in the surviving pane.
s = closeTab(s, paneA, 'editor:/a.ts')
s = openTabInActivePane(s, { id: 'editor:/b.ts', type: 'editor', title: 'b.ts', path: '/b.ts' })
const owners = allLeaves(s.splits).filter(leaf => leaf.tabs.some(tab => tab.path === '/b.ts'))
expect(owners).toHaveLength(1)
expect(owners[0]!.tabs.some(tab => tab.type === 'terminal')).toBe(true)
})
it('handles state with deeply corrupted split children gracefully', () => {
const corrupted = {
...makeDefaultState(400),
splits: {
kind: 'split',
id: 's1',
dir: 'row',
sizes: [0.5, 0.5],
children: [
{ kind: 'leaf', id: 'pane:1', tabs: null, active: null },
{ kind: 'leaf', id: 'pane:2', tabs: [{ id: 'tab:1', type: 'explorer', title: 'Explorer' }], active: 'tab:1' },
],
},
}
expect(sanitizeState(corrupted)).toBeUndefined()
})
})
describe('v0.12.0 store additions', () => {
// These blocks exercise store reduce/reduceFor (which schedule the
// localStorage persist through window timers) and sanitizeState (which
// reads window.innerHeight). Stub the browser globals ONLY inside this
// scope so the earlier describes keep their window-less environment.
beforeEach(() => {
const g = globalThis as Record<string, unknown>
g.window = { clearTimeout: () => {}, setTimeout: () => 0, innerWidth: 1024, innerHeight: 800 }
g.localStorage = { getItem: () => null, setItem: () => {} }
})
afterEach(() => {
const g = globalThis as Record<string, unknown>
delete g.window
delete g.localStorage
})
describe('store.reduceFor (targeted opens, v0.12.0)', () => {
it('mutates the target session, persists it, and leaves the active snapshot untouched', () => {
const store = createSidebarStore()
store.setSession('s1')
let calls = 0
store.subscribe(() => { calls++ })
store.reduceFor('s2', (state) => ({ ...state, expanded: ['/x'] }))
// No notify, no snapshot switch.
expect(calls).toBe(0)
expect(store.getSnapshot().sessionId).toBe('s1')
// The target session's state updated and loads back on switch.
store.setSession('s2')
expect(store.getSnapshot().state?.expanded).toEqual(['/x'])
})
it('loads a fresh state for a never-visited target session', () => {
const store = createSidebarStore()
store.setSession('s1')
store.reduceFor('brand-new', (state) => ({ ...state, panelOpen: false }))
store.setSession('brand-new')
expect(store.getSnapshot().state?.panelOpen).toBe(false)
expect(store.getSnapshot().state?.splits).toBeDefined()
})
it('reduceFor never lowers the shared uid counter below the active session needs (no pane-id collision)', () => {
const store = createSidebarStore()
// Session 'b' is cached FIRST with a LOW id range (pane:1 / tab:2).
store.setSession('b')
// Session 'a' then loads and its operations raise the shared counter
// well above b's max (default pane, plus fresh pane ids from splits).
store.setSession('a')
store.reduce(s => splitPane(s, 'row'))
const before = allLeaves(store.getSnapshot().state!.splits).map(leaf => leaf.id).sort()
// A targeted reduce into the OLD, low-id session must not lower the
// counter: the next split in the ACTIVE session would otherwise mint
// an id that already exists (mapLeaf visits both leaves → corruption).
store.reduceFor('b', (state) => state)
store.reduce(s => splitPane(s, 'row'))
const after = allLeaves(store.getSnapshot().state!.splits).map(leaf => leaf.id)
expect(new Set(after).size).toBe(after.length)
// The active session's pre-existing pane ids all survived untouched.
for (const id of before) expect(after).toContain(id)
})
it('persists each session independently (per-session debounce timers)', () => {
const g = globalThis as Record<string, unknown>
let seq = 0
const timers = new Map<number, () => void>()
const writes: string[] = []
g.window = {
clearTimeout: (id: number) => { timers.delete(id) },
setTimeout: (fn: () => void) => { const id = ++seq; timers.set(id, fn); return id },
innerWidth: 1024,
innerHeight: 800,
}
g.localStorage = {
getItem: () => null,
setItem: (key: string) => { writes.push(key) },
}
try {
const store = createSidebarStore()
store.setSession('a')
store.reduce(s => ({ ...s, expanded: ['/a'] })) // schedules persist(a)
store.reduceFor('b', s => ({ ...s, expanded: ['/b'] })) // schedules persist(b)
// A shared timer would have cancelled persist(a) — with per-session
// timers BOTH writes are pending and both land when they fire.
expect(timers.size).toBe(2)
for (const [, fn] of [...timers]) fn()
expect(writes).toEqual(['dsh-sidebar:v1:a', 'dsh-sidebar:v1:b'])
} finally {
delete g.window
delete g.localStorage
}
})
})
describe('tab meta persistence (v0.12.0)', () => {
it('sanitizeState carries plugin meta through a reload round-trip', () => {
const store = createSidebarStore()
store.setSession('s1')
store.reduce(s => ({
...s,
splits: {
kind: 'leaf' as const,
id: 'pane:1',
tabs: [{ id: 'tab:1', type: 'db', title: 'DB', meta: { q: [1, 2], n: 0 } }],
active: 'tab:1',
},
}))
const sanitized = sanitizeState(JSON.parse(JSON.stringify(store.getSnapshot().state!)))
const tabs = allLeaves(sanitized!.splits).flatMap(leaf => leaf.tabs)
expect(tabs[0]?.meta).toEqual({ q: [1, 2], n: 0 })
})
})
})
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import { contentText, lastActivity } from '../src/client/subagent-activity.ts'
import type { SidebarHistoryEntry } from '../src/context-types.ts'
describe('subagent activity summary parser', () => {
/** One history entry from raw event fields. */
const entry = (type: string, data: Record<string, unknown>): SidebarHistoryEntry => ({
event: { type, seq: 0, time: 0, data },
})
it('extracts text blocks and skips non-text content', () => {
// Text blocks join as paragraphs (newline-separated).
expect(contentText([{ type: 'text', text: 'hello' }, { type: 'text', text: ' world' }])).toBe('hello\n world')
expect(contentText([{ type: 'tool_use', name: 'bash' }])).toBeUndefined()
expect(contentText(undefined)).toBeUndefined()
expect(contentText('nope')).toBeUndefined()
})
it('lastActivity returns the LAST text output and the LAST tool call', () => {
const live = lastActivity([
entry('turn/start', { turn: 1 }),
entry('user/message', { content: [{ type: 'text', text: '请检查代码' }] }),
entry('tool/call', { callId: 'c1', name: 'bash', arguments: '{"command":"ls -la"}' }),
entry('assistant/message', {
turn: 1, step: 1,
message: { content: [{ type: 'text', text: '检查完毕' }] },
}),
entry('tool/call', { callId: 'c2', name: 'read', arguments: '{"path":"a.ts"}' }),
entry('assistant/message', {
turn: 1, step: 2,
message: { content: [{ type: 'text', text: '再看一眼' }] },
}),
])
expect(live).toEqual({
text: '再看一眼',
tool: { name: 'read', args: '{"path":"a.ts"}' },
})
})
it('lastActivity keeps only the fields the tail actually has', () => {
expect(lastActivity([
entry('tool/call', { callId: 'c1', name: 'bash', arguments: '{"command":"ls"}' }),
])).toEqual({ tool: { name: 'bash', args: '{"command":"ls"}' } })
expect(lastActivity([
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'ok' }] } }),
])).toEqual({ text: 'ok' })
})
it('lastActivity ignores lifecycle events, chunks, and text-less messages', () => {
const live = lastActivity([
entry('turn/end', { turn: 1, reason: 'success' }),
entry('step/start', { turn: 1, step: 1 }),
entry('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text', delta: 'x' } }),
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'tool_use', name: 'bash' }] } }),
])
expect(live).toEqual({})
expect(lastActivity([])).toEqual({})
})
it('lastActivity defaults a missing tool name and tolerates non-string arguments', () => {
const live = lastActivity([
entry('tool/call', { callId: 'c1' }),
entry('tool/call', { callId: 'c2', name: 'web', arguments: { url: 'x' } }),
])
expect(live.tool).toEqual({ name: 'web', args: '' })
})
})
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest'
import {
collectBranchIds, countSubagentDescendants, detectNewDirectSubagent,
directSubagentCount, rootAncestor,
} from '../src/client/subagent-detect.ts'
import type { SidebarSessionList, SidebarSubagentCatalog } from '../src/context-types.ts'
describe('subagent detection over the sessions list feed', () => {
/** A list snapshot carrying the given direct subagent children of `parent`. */
const list = (
parent: string,
childIds: string[],
running: string[] = [],
): SidebarSessionList => {
const byId: SidebarSessionList['byId'] = { [parent]: { id: parent, displayTitle: 'Parent' } }
for (const id of childIds) {
byId[id] = {
id,
displayTitle: `Child ${id}`,
origin: 'subagent',
parentId: parent,
running: running.includes(id),
}
}
return { current: parent, byId }
}
it('counts only the direct subagent children of the given session', () => {
const snapshot = list('p1', ['c1', 'c2'])
snapshot.byId['other'] = { id: 'other', displayTitle: 'Other', origin: 'subagent', parentId: 'p2' }
expect(directSubagentCount(snapshot.byId, 'p1')).toBe(2)
expect(directSubagentCount(snapshot.byId, 'p2')).toBe(1)
expect(directSubagentCount(snapshot.byId, 'p1-nobody')).toBe(0)
})
it('fires only on the 0 → N transition of the current session', () => {
const empty = list('p1', [])
const one = list('p1', ['c1'])
const two = list('p1', ['c1', 'c2'])
expect(detectNewDirectSubagent(empty, empty, 'p1')).toBe(false)
expect(detectNewDirectSubagent(empty, one, 'p1')).toBe(true)
// Already-present children never re-trigger (session switch, reload).
expect(detectNewDirectSubagent(one, two, 'p1')).toBe(false)
expect(detectNewDirectSubagent(two, one, 'p1')).toBe(false)
// A child arriving under ANOTHER session does not trigger this one.
expect(detectNewDirectSubagent(empty, list('p2', ['x']), 'p1')).toBe(false)
})
it('indexes descendants through uninterrupted subagent lineage', () => {
// p1 → c1 (subagent) → g1 (subagent child of c1).
const byId: SidebarSessionList['byId'] = {
p1: { id: 'p1', displayTitle: 'P1' },
c1: { id: 'c1', displayTitle: 'C1', origin: 'subagent', parentId: 'p1', running: true },
g1: { id: 'g1', displayTitle: 'G1', origin: 'subagent', parentId: 'c1' },
}
expect(countSubagentDescendants(byId, 'p1')).toEqual({ count: 2, runningCount: 1 })
expect(countSubagentDescendants(byId, 'c1')).toEqual({ count: 1, runningCount: 0 })
expect(countSubagentDescendants(byId, 'g1')).toEqual({ count: 0, runningCount: 0 })
// An ordinary fork between the parent and the subagent cuts that lineage:
// c2 is c1's sibling in origin but its parent `fork` is not a subagent,
// so it never reaches p1 (only c1 and g1 count under p1).
const forked: SidebarSessionList['byId'] = {
...byId,
fork: { id: 'fork', displayTitle: 'Fork', parentId: 'p1' },
c2: { id: 'c2', displayTitle: 'C2', origin: 'subagent', parentId: 'fork' },
}
expect(countSubagentDescendants(forked, 'p1')).toEqual({ count: 2, runningCount: 1 })
expect(countSubagentDescendants(forked, 'fork')).toEqual({ count: 1, runningCount: 0 })
// Cycles terminate (fail soft, never hang); both rows in a 2-cycle reach
// the queried node once each.
const cyclic: SidebarSessionList['byId'] = {
a: { id: 'a', displayTitle: 'A', origin: 'subagent', parentId: 'b' },
b: { id: 'b', displayTitle: 'B', origin: 'subagent', parentId: 'a' },
}
expect(countSubagentDescendants(cyclic, 'a')).toEqual({ count: 2, runningCount: 0 })
})
it('resolves the main-agent root of the current session tree', () => {
const byId: SidebarSessionList['byId'] = {
main: { id: 'main', displayTitle: 'Main' },
child: { id: 'child', displayTitle: 'Child', origin: 'subagent', parentId: 'main' },
grand: { id: 'grand', displayTitle: 'Grand', origin: 'subagent', parentId: 'child' },
}
// An ordinary session is its own root; a deep subagent walks up to the main agent.
expect(rootAncestor(byId, 'main')).toBe('main')
expect(rootAncestor(byId, 'child')).toBe('main')
expect(rootAncestor(byId, 'grand')).toBe('main')
// A broken chain (parent not in the mirror) degrades to the session itself.
expect(rootAncestor(byId, 'orphan')).toBe('orphan')
// A hydrating session row degrades to the session itself.
expect(rootAncestor(byId, 'not-listed')).toBe('not-listed')
expect(rootAncestor(byId, undefined)).toBeUndefined()
})
it('collects every catalog branch of the topology, cycle-safe', () => {
const child = (id: string, hasChildren: boolean): SidebarSubagentCatalog['entries'][number] => ({
kind: 'child', id, activity: 'inactive', hasChildren, mode: 'one-shot',
})
const catalogs: Record<string, SidebarSubagentCatalog> = {
root: { entries: [child('a', true), child('b', false)], parentAvailable: true, state: 'ready', error: null },
a: { entries: [child('c', false)], parentAvailable: true, state: 'ready', error: null },
}
expect(collectBranchIds(catalogs, 'root')).toEqual(['a'])
expect(collectBranchIds(catalogs, undefined)).toEqual([])
// A cycle terminates (each branch id collected at most once, no hang).
const cyclic: Record<string, SidebarSubagentCatalog> = {
root: { entries: [child('a', true)], parentAvailable: true, state: 'ready', error: null },
a: { entries: [child('root', true)], parentAvailable: true, state: 'ready', error: null },
}
expect(collectBranchIds(cyclic, 'root')).toEqual(['a', 'root'])
})
})
+82
View File
@@ -0,0 +1,82 @@
/**
* TerminalDepsBanner tests (issue #140): the degraded-mode banner the
* terminal tab shows when node-pty fails to load. It must render the
* failure title, the PASTEABLE repair command (bash / cmd / PowerShell),
* a copy button that writes the command to the clipboard with a transient
* "Copied" feedback, and a retry button.
*
* The banner is tested directly (like PluginListBody): it is a pure
* presentational component, so server-side renderToString covers the
* static content and createRoot + act() covers the copy click.
*/
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { renderToString } from 'react-dom/server'
import { createRoot } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
import { TerminalDepsBanner } from '../src/client/TerminalView.tsx'
// The act() environment flag (React 18.2 reads it before flushing effects).
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
const deps = {
ok: false as const,
cause: 'Cannot find package node-pty',
command: 'bash "/Users/u/.dsh/profiles/web/node_modules/.pnpm/dsh-better-sidebar@0.0.0/node_modules/dsh-better-sidebar/scripts/install.sh" --repair --profile "web"',
profile: 'web',
}
describe('TerminalDepsBanner (render)', () => {
it('renders the failure title, the pasteable command and the actions', () => {
const html = renderToString(createElement(TerminalDepsBanner, { deps, onRetry: () => {} }))
// The jsdom locale is en-US, so the en dictionary applies.
expect(html).toContain('Terminal dependency node-pty failed to load')
// renderToString HTML-escapes the quotes inside the command block.
expect(html).toContain(deps.command.replaceAll('"', '&quot;'))
expect(html).toContain('Copy')
expect(html).toContain('Retry')
// The profile the command targets is shown next to the hint.
expect(html).toContain('detected profile: web')
})
})
describe('TerminalDepsBanner copy click (interactive)', () => {
it('clicking Copy writes the repair command and flashes "Copied"', async () => {
vi.spyOn(primitives, 'writeClipboard').mockResolvedValue(true)
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
const onRetry = vi.fn()
act(() => {
root.render(createElement(TerminalDepsBanner, { deps, onRetry }))
})
const copyButton = container.querySelector('button[aria-label="Copy"]') as HTMLButtonElement
expect(copyButton).not.toBeNull()
expect(copyButton.textContent).toBe('Copy')
await act(async () => { copyButton.click() })
expect(primitives.writeClipboard).toHaveBeenCalledWith(deps.command)
expect(copyButton.textContent).toBe('Copied')
act(() => { root.unmount() })
container.remove()
vi.restoreAllMocks()
})
it('clicking Retry fires the callback (the view reconnects)', async () => {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
const onRetry = vi.fn()
act(() => {
root.render(createElement(TerminalDepsBanner, { deps, onRetry }))
})
const buttons = [...container.querySelectorAll('button')] as HTMLButtonElement[]
const retry = buttons.find(button => button.textContent === 'Retry')
expect(retry).toBeDefined()
await act(async () => { retry!.click() })
expect(onRetry).toHaveBeenCalledTimes(1)
act(() => { root.unmount() })
container.remove()
})
})
+82
View File
@@ -0,0 +1,82 @@
/**
* Live theme-token access tests — issue #90's guard.
*
* `effectiveTokenValue` must treat visually inert values (CSS reset
* keywords, `transparent`, and any color below the opacity floor) as UNSET
* so callers' `|| fallback` chains fire. Skin systems set global tokens like
* `--dsw-alias-bg-base` to `transparent` or translucent glass values (the
* dsh-web-ui skins use rgba 0.16–0.7); without this guard a
* truthy-but-inert value would leave the terminal/editor see-through over
* the skin's backdrop. Effectively opaque values (>= 0.9 alpha, including a
* skin's scoped 0.96 porcelain) pass through so the skin still controls the
* surface.
*/
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest'
import { colorAlpha, effectiveTokenValue, tokenValue } from '../src/client/theme.ts'
afterEach(() => {
document.body.removeAttribute('style')
})
describe('colorAlpha', () => {
it('parses the hex family', () => {
expect(colorAlpha('#fff')).toBe(1)
expect(colorAlpha('#fff8')).toBeCloseTo(0x88 / 255)
expect(colorAlpha('#112233')).toBe(1)
expect(colorAlpha('#11223344')).toBeCloseTo(0x44 / 255)
})
it('parses rgb()/rgba() in comma and space syntax', () => {
expect(colorAlpha('rgb(1, 2, 3)')).toBe(1)
expect(colorAlpha('rgba(255, 255, 255, 0.45)')).toBeCloseTo(0.45)
expect(colorAlpha('rgb(255 255 255 / 0.7)')).toBeCloseTo(0.7)
})
it('parses hsl()/hsla()', () => {
expect(colorAlpha('hsl(200, 50%, 50%)')).toBe(1)
expect(colorAlpha('hsla(200, 50%, 50%, 0.3)')).toBeCloseTo(0.3)
})
it('treats unparseable formats as opaque', () => {
expect(colorAlpha('white')).toBeNull()
expect(colorAlpha('')).toBeNull()
})
})
describe('effectiveTokenValue', () => {
it('passes through real opaque paint values verbatim', () => {
document.body.style.setProperty('--probe', '#112233')
expect(effectiveTokenValue('--probe')).toBe('#112233')
// Effectively opaque translucency (a skin's scoped 0.96 porcelain glass)
// is a deliberate surface choice — it passes through.
document.body.style.setProperty('--probe', 'rgba(10, 22, 54, 0.96)')
expect(effectiveTokenValue('--probe')).toBe('rgba(10, 22, 54, 0.96)')
})
it('treats transparent and CSS reset keywords as unset', () => {
for (const inert of ['transparent', 'initial', 'inherit', 'unset']) {
document.body.style.setProperty('--probe', inert)
expect(effectiveTokenValue('--probe'), inert).toBe('')
}
})
it('treats translucent glass values below the opacity floor as unset', () => {
// The dsh-web-ui skins set bg-base to rgba 0.16–0.7 for glass panels;
// the terminal must fall back to an opaque background there.
for (const glass of ['rgba(255, 255, 255, 0.45)', 'rgba(20, 26, 46, 0.7)', '#11223344']) {
document.body.style.setProperty('--probe', glass)
expect(effectiveTokenValue('--probe'), glass).toBe('')
}
})
it('treats a missing token as unset', () => {
expect(effectiveTokenValue('--never-defined')).toBe('')
})
it('tokenValue still returns the raw value', () => {
document.body.style.setProperty('--probe', 'transparent')
expect(tokenValue('--probe')).toBe('transparent')
expect(effectiveTokenValue('--probe')).toBe('')
})
})
-1813
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -248,7 +248,7 @@ function makeCssPlugin(pluginId: string): BuildPlugin {
resolveId(source: string, importer: string | undefined) { resolveId(source: string, importer: string | undefined) {
if (!source.endsWith('.css')) return null if (!source.endsWith('.css')) return null
// Relative/absolute paths resolve against the importer; bare // Relative/absolute paths resolve against the importer; bare
// specifiers (e.g. 'xterm/css/xterm.css') resolve from the package. // specifiers (e.g. '@xterm/xterm/css/xterm.css') resolve from the package.
let abs: string let abs: string
if (source.startsWith('.') || source.startsWith('/') || /^[A-Za-z]:[\\/]/.test(source)) { if (source.startsWith('.') || source.startsWith('/') || /^[A-Za-z]:[\\/]/.test(source)) {
abs = importer === undefined ? source : resolvePath(dirname(importer), source) abs = importer === undefined ? source : resolvePath(dirname(importer), source)