Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c891514b5 | ||
|
|
14ef8e4df8 | ||
|
|
27ea096f82 | ||
|
|
659bfadb47 | ||
|
|
4a407d78b8 | ||
|
|
bcd60234ac | ||
|
|
76aacddbb9 | ||
|
|
0bcafd476d | ||
|
|
e8757026a1 | ||
|
|
b5e8665499 | ||
|
|
c973d6ec1b | ||
|
|
498528d2eb | ||
|
|
23132560fb | ||
|
|
5e416eccef | ||
|
|
9bea6c5539 | ||
|
|
af637b4f1b | ||
|
|
9ad0a49b8a | ||
|
|
6ee1ba11d0 | ||
|
|
c923fc57b1 | ||
|
|
f39156607e | ||
|
|
ab785d71fb | ||
|
|
333961be71 | ||
|
|
618858b5b1 | ||
|
|
565e66d30e | ||
|
|
de5234a3d7 | ||
|
|
a673f2399f | ||
|
|
6f52aa79d2 | ||
|
|
8d9ab1cd2f | ||
|
|
a0918496f3 | ||
|
|
aea9fcd3a0 | ||
|
|
be95787c00 | ||
|
|
9652bbda59 | ||
|
|
0052061a72 | ||
|
|
db189e3852 | ||
|
|
bb3b40ff15 | ||
|
|
5c138ca851 | ||
|
|
fc4031e65f | ||
|
|
3c196fffc3 | ||
|
|
fd9544f16b | ||
|
|
835d1538e0 | ||
|
|
9b50f5fb64 | ||
|
|
d33d6764fc | ||
|
|
515ed0abd0 | ||
|
|
d7dc609553 | ||
|
|
c9372de69b | ||
|
|
ecebc97800 | ||
|
|
b6ef4d544a | ||
|
|
5d2d6e5801 | ||
|
|
30d0b103f6 | ||
|
|
8ad1766390 | ||
|
|
648ff27b5f | ||
|
|
1e7c72ccbe | ||
|
|
5c27882995 | ||
|
|
ad787ab22d | ||
|
|
ae71aecb83 | ||
|
|
1a0d530711 | ||
|
|
29f162dac0 | ||
|
|
5bd961f7f1 | ||
|
|
f842fa0029 | ||
|
|
0b5405117d | ||
|
|
2bace68af8 | ||
|
|
f903ac77d6 | ||
|
|
33ee02e747 | ||
|
|
7ee0b457a3 | ||
|
|
d478780e43 | ||
|
|
76fa7df264 | ||
|
|
acd4a8313c | ||
|
|
cb6f2cdc8f | ||
|
|
3825fced25 | ||
|
|
1f9af78c3c |
@@ -0,0 +1,101 @@
|
||||
name: 📋 问题报告 / Issue Report
|
||||
description: 报告 Bug、功能请求或建议(中英双语)| Report a bug, feature request or suggestion (bilingual)
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## 🙏 感谢反馈 / Thanks for reporting!
|
||||
请尽量完整填写以下信息,能显著加快定位与处理速度。
|
||||
Please fill out the following fields as completely as possible.
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: 提交前确认 / Pre-submit checklist
|
||||
description: 请逐项确认 | Please confirm each item
|
||||
options:
|
||||
- label: 我已搜索现有 issue,确认没有**完全相同**的条目(只有完全一致才算重复)| I searched existing issues and confirmed there is no **exact duplicate** (only exactly identical issues count as duplicates)
|
||||
required: true
|
||||
- label: 我已确认使用最新版本 dsh-better-sidebar / I confirmed I am on the latest dsh-better-sidebar version
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: environment-type
|
||||
attributes:
|
||||
label: 运行环境 / Environment
|
||||
description: 你使用的是哪种运行方式?| Which runtime are you using?
|
||||
multiple: false
|
||||
options:
|
||||
- DSH Web(浏览器访问)/ DSH Web (browser access)
|
||||
- Desktop 实现(如 deepseek-harness-desktop)/ Desktop build (e.g. deepseek-harness-desktop)
|
||||
- 其他 / 不确定 (Other / Not sure)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: dsh-web-version
|
||||
attributes:
|
||||
label: DSH Web 版本 + 浏览器 / DSH Web version + Browser
|
||||
description: |
|
||||
例:DSH 0.1.0-rc.8 + Chrome 151(macOS)。
|
||||
Example: DSH 0.1.0-rc.8 + Chrome 151 (macOS).
|
||||
placeholder: "DSH 版本 / version + 浏览器 / browser(如 0.1.0-rc.8 + Chrome 151)"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: desktop-repo
|
||||
attributes:
|
||||
label: Desktop 实现仓库 / Desktop repo(源头 / upstream)
|
||||
description: |
|
||||
若使用 Desktop 实现,请附上源头仓库链接,方便对照宿主行为。
|
||||
If using a Desktop build, please link the upstream Desktop repo for host-behavior reference.
|
||||
placeholder: "https://github.com/anywhere-labs/deepseek-harness-desktop"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: plugin-version
|
||||
attributes:
|
||||
label: 插件版本 / Plugin version
|
||||
description: dsh-better-sidebar 的版本号 | The dsh-better-sidebar version
|
||||
placeholder: "如 0.13.1(`dsh plugin --profile web add dsh-better-sidebar@latest` 安装的版本)"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: category
|
||||
attributes:
|
||||
label: 类别 / Category
|
||||
description: 请选择本 issue 的类型 | Please choose the type of this issue
|
||||
multiple: false
|
||||
options:
|
||||
- 🐛 Bug(功能异常 / Something is broken)
|
||||
- ✨ 功能请求(新功能 / Feature request)
|
||||
- 💡 建议(体验改进 / Suggestion & improvement)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 描述 / Description
|
||||
description: |
|
||||
请详细描述:现象、期望行为、(Bug 必填)复现步骤、截图/日志等。
|
||||
Describe in detail: what happened, expected behavior, reproduction steps (required for bugs), screenshots/logs, etc.
|
||||
placeholder: |
|
||||
现象 / What happened:
|
||||
期望行为 / Expected behavior:
|
||||
复现步骤(Bug 必填)/ Steps to reproduce (required for bugs):
|
||||
截图或日志 / Screenshots or logs:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: 补充信息 / Additional context
|
||||
description: 其他有助于定位的信息(可选)| Anything else that may help (optional)
|
||||
placeholder: "操作系统 / OS、配置文件、报错堆栈、临时 workaround 等 / OS, config files, error stack, temporary workarounds, etc."
|
||||
validations:
|
||||
required: false
|
||||
@@ -4,6 +4,14 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
# Cancel a superseded PR run (it is on a stale commit); never cancel a
|
||||
# push run — it is already producing the post-merge signal. The mount job
|
||||
# boots a real DSH + Chromium, so the savings matter there most.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
@@ -41,3 +49,66 @@ jobs:
|
||||
# shipped lib/types. Runs AFTER build (it reads the built output).
|
||||
- name: Check consumer type surface
|
||||
run: pnpm check:consumer-types
|
||||
|
||||
# Pack the plugin as an npm tarball, mount it into a REAL DSH instance
|
||||
# through the official `dsh plugin --profile web add` channel, and render
|
||||
# the DSH web UI headlessly (Playwright Chromium) to prove the plugin
|
||||
# mounts without crashing the shell. Drives `pnpm test:mount`, which boots
|
||||
# a keyless `dsh web` against a scratch profile and sweeps every built-in
|
||||
# sidebar tab. No API key needed — the whole lane is deterministic.
|
||||
plugin-mount:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# CI runs must never report to the production telemetry endpoint baked
|
||||
# into the DSH composition (same guard DSH's own CI uses).
|
||||
env:
|
||||
DSH_TELEMETRY_DISABLED: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 11
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Pin the DSH CLI the lane mounts into; the profile's bundle stack
|
||||
# (dsh-base / dsh-web-app) resolves from this installation. Keep in
|
||||
# step with the plugin's @deepseek-ai/* peer range when bumping.
|
||||
- name: Install dsh CLI (pinned)
|
||||
run: npm install -g @deepseek-ai/dsh@0.1.0-rc.8
|
||||
|
||||
# lib/ is gitignored; the tarball is produced from this build.
|
||||
- name: Build and pack the npm tarball
|
||||
run: pnpm build && pnpm pack
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Mount + headless-render smoke
|
||||
run: pnpm test:mount
|
||||
|
||||
# Aggregate double-mount regression: a fixture aggregate bundle mounts
|
||||
# dsh-better-sidebar under its own entry id BEFORE the plugin's bundle
|
||||
# row; the plugin must back off (no duplicate /sidebar/api, boot OK).
|
||||
# Uses the pinned DSH CLI installed above.
|
||||
- name: Aggregate double-mount regression
|
||||
env:
|
||||
DSH_CMD: dsh
|
||||
run: bash scripts/e2e-aggregate-mount.sh
|
||||
|
||||
- name: Upload Playwright report on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
playwright-report/
|
||||
test-results/
|
||||
if-no-files-found: ignore
|
||||
|
||||
@@ -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
|
||||
@@ -149,3 +149,8 @@ registry/
|
||||
|
||||
# stray npm cache scratch dir
|
||||
.npm-cache-tmp/
|
||||
|
||||
# Playwright headless-render lane (tests/e2e) artifacts
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
|
||||
@@ -9,9 +9,29 @@ better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context
|
||||
## 0. 仓库硬约束(所有本仓库插件必须遵守)
|
||||
|
||||
- **禁止修改 DeepSeek Harness (DSH) 源码**:对官方源码 checkout(`~/.dsh/source/current`)零写入——不得改 harness 包、不得把 harness 改动提交到它的分支。
|
||||
- **代码改动必须走 PR**:功能 / 修复 / 测试等非文档改动一律在分支上开发(`feat/*` / `fix/*`),用 `gh pr create` 发起 PR,review 合并后才进 main;**仅纯文档类改动**(README / AGENTS.md / docs/ 等)允许直接推送到 main。
|
||||
- **挂载只走 `cordis.patch.yml` + profile 机制**(`~/.dsh/profiles/<profile>/`),插件永远作为独立包被 profile 引用,不反向侵入 DSH。
|
||||
- 需要 harness 没有的能力时,用 DSH **现成的只读/公开 API** 或插件自有路由实现(参考 §7 的 `jobs.output` 事件回放:读会话事件日志而非动注册表);如果确实做不到,先向用户说明取舍,而不是直接改 DSH。
|
||||
|
||||
### CI 挂载冒烟(`plugin-mount` job / `pnpm test:mount`)
|
||||
|
||||
仓库的 CI 有一条「npm 打包 → 真实挂载 → 无头渲染」门禁(`.github/workflows/ci.yml` 的 `plugin-mount` job),证明**打包产物**在真实 DSH 上挂载后无头渲染不会 crash:
|
||||
|
||||
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`)。
|
||||
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`,独立模式:每个文件新开 tab,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.8`(与插件 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. 服务定位
|
||||
@@ -33,7 +53,7 @@ better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context
|
||||
{
|
||||
"name": "my-plugin",
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"cordis": "^4.0.0-rc.8",
|
||||
"dsh-better-sidebar": "workspace:*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -111,7 +131,7 @@ interface TabDescriptor {
|
||||
title: string | (() => string)
|
||||
/** 图标: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
|
||||
/** 从 + 菜单隐藏(editor/diff 用:由其他流程触发打开,不在菜单里) */
|
||||
hidden?: boolean
|
||||
@@ -125,7 +145,7 @@ interface TabDescriptor {
|
||||
/**
|
||||
* 去重键:openTab 时若已存在 dedupeKey 相同的 tab,则聚焦而非新开。
|
||||
* 返回 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 会求值两次,抛错会向外传播
|
||||
/**
|
||||
@@ -134,24 +154,38 @@ interface TabDescriptor {
|
||||
* 省略时用默认 { id, type, title } + seed 里的 path/diff。
|
||||
*/
|
||||
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
|
||||
/**
|
||||
* 外链点击目标认领(v0.13.0+):聊天/界面外链被接管(`browserInterceptLinks`
|
||||
* 总闸 + URL 对应协议开关均开)时,第一个 `urlTarget(url)` 命中且未被设置
|
||||
* 禁用的注册 tab 类型以 `openTab({ type, url, title: hostname })` 打开——
|
||||
* URL 即全部载荷(tab 从 `tab.path` 读取)。注册顺序先到先得;谓词抛错被
|
||||
* 吞掉(跳过该类型)。内置 browser 不声明 urlTarget,永远是隐式兜底,不会
|
||||
* 遮蔽插件声明。要同时容纳多个 URL 需用 createTab 铸造 per-URL id(browser
|
||||
* 同款模式);否则同类型二次点击被 id 安全网聚焦,新 URL 不会覆写既有 path。
|
||||
*/
|
||||
urlTarget?: (url: URL) => boolean
|
||||
/**
|
||||
* 声明式设置(v0.4.1+):每个注册的 tab 都会在 Side card 设置页获得一行
|
||||
* 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套设置行,
|
||||
* 绑定 SidebarPrefs 字段。嵌套设置仅父级启用时显示(v0.11.0 起行控件不限于
|
||||
* 布尔开关:`type: 'switch' | 'text' | 'number'`,缺省 'switch';text/number
|
||||
* 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀)。
|
||||
* 布尔开关:`type: 'switch' | 'text' | 'number' | 'select'`,缺省 'switch';
|
||||
* text/number 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀;
|
||||
* select 行是下拉选择:options 声明 `{ value, title, desc?, icon? }`,任一
|
||||
* 选项带 icon 时下拉渲染大图标选项卡、收起态同样显示图标,否则单行文本;
|
||||
* `multi: true`(缺省 false)多选,存储值为选中 value 的数组,按 options
|
||||
* 顺序提交)。
|
||||
* v0.12.0 起增加两个插件自有扩展(详见 §5 声明式设置):
|
||||
* `pluginToggles`(插件自有 key,持久化在 pluginSettings[id],无需宿主 schema 字段)
|
||||
* 与 `render`(自定义设置面板,替代行列表)。
|
||||
*/
|
||||
settings?: {
|
||||
toggles?: readonly {
|
||||
/** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'terminalFontFamily' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks') */
|
||||
/** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'terminalFontFamily' / 'editorExplorer' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks' / 'browserInterceptHttp' / 'browserInterceptHttps') */
|
||||
key: string
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
/** 行控件类型;缺省 'switch'(向后兼容)。 */
|
||||
type?: 'switch' | 'text' | 'number'
|
||||
type?: 'switch' | 'text' | 'number' | 'select'
|
||||
/** number 行的提交钳制下限。 */
|
||||
min?: number
|
||||
/** number 行的提交钳制上限。 */
|
||||
@@ -160,6 +194,15 @@ interface TabDescriptor {
|
||||
placeholder?: string
|
||||
/** 输入框后的单位后缀(如 'px')。 */
|
||||
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 是插件局部的,
|
||||
* 持久化在 `pluginSettings[<descriptor id>]`——不需要宿主 PrefsSchema 字段。 */
|
||||
@@ -167,11 +210,18 @@ interface TabDescriptor {
|
||||
key: string
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
type?: 'switch' | 'text' | 'number'
|
||||
type?: 'switch' | 'text' | 'number' | 'select'
|
||||
min?: number
|
||||
max?: number
|
||||
placeholder?: 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+):给出时齿轮弹窗渲染它而非行列表。
|
||||
* props 含 store/service/prefs、本 descriptor 的 pluginSettings blob、
|
||||
@@ -221,7 +271,7 @@ interface TabComponentProps {
|
||||
tab: SidebarTab // 当前 tab 实例(含 id/type/title/path?/diff?)
|
||||
visible: boolean // 是否是当前激活 tab 且面板打开(不可见时暂停轮询等)
|
||||
// 以下由内置 tab 使用,外部 tab 可忽略:
|
||||
expanded?: string[] // explorer 的展开目录集
|
||||
expanded?: string[] // 文件树的展开目录集
|
||||
onToggleDir?: (path: string) => void
|
||||
onReferenceFile?: (path: string) => void
|
||||
onOpenFile?: (path: string) => void
|
||||
@@ -278,12 +328,29 @@ ctx.effect(() =>
|
||||
)
|
||||
```
|
||||
|
||||
**认领外链点击**(v0.13.0+,`features.includes('urlTarget')` gate):聊天/界面里被拦截的 HTTP(S) 外链(协议开关开启时)路由到第一个 `urlTarget` 命中的 tab 类型,URL 作为 seed 预填 `tab.path`;内置浏览器是隐式兜底。多 URL 并存需用 `createTab` 铸造 per-URL id(browser 同款模式),否则同类型二次点击聚焦既有 tab、不覆写 path:
|
||||
```ts
|
||||
ctx.effect(() => {
|
||||
if (!ctx.betterSidebar.features.includes('urlTarget')) return
|
||||
return ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:web-docs',
|
||||
title: () => 'Docs',
|
||||
order: 80,
|
||||
urlTarget: (url) => url.hostname === 'docs.my-site.com', // 只有该域名被点击时认领
|
||||
createTab: (state) => ({
|
||||
tab: { id: `my-plugin:web-docs:${state.nextBrowser}`, type: 'my-plugin:web-docs', title: 'Docs' },
|
||||
patch: { nextBrowser: state.nextBrowser + 1 },
|
||||
}),
|
||||
component: ({ tab, scope }) => <WebDocsView url={tab.path} sessionId={scope.sessionId} />,
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 3.4 内置 tab(不可重复注册)
|
||||
|
||||
| id | order | single | hidden | 用途 |
|
||||
|---|---|---|---|---|
|
||||
| `editor` | -1 | 否(按 path 去重) | 是 | 文件编辑/预览(由 openSidebarFile 触发) |
|
||||
| `explorer` | 10 | 是 | 否 | 文件资源管理器 |
|
||||
| `editor` | 10 | 否(按 path 去重) | 否 | 唯一的「文件窗口」(文件编辑/预览 + 文件资源管理):文件 tab(有 path)在两种 `editorExplorer` 模式下 chrome 恒为合并形态——头部路径输入框 + 文本编辑器预览/编辑/保存控件 + 可开关的内嵌文件树面板(含全局文件名搜索,走 host `fs.search` 路由;左缘拖拽调宽),状态存 `tab.meta.treeOpen` / `tab.meta.treeWidth`;pref 控制**打开行为与无路径窗口形态**:关(默认,独立)= 走 `openSidebarFile` 按 path 新开,**无路径窗口即独立资源管理器——只渲染文件树面板**(搜索 + FileTree 撑满全窗,无编辑器 chrome);开(合并)= 树点击/输入框 Enter 原地切换当前 tab(`updateTab` 重写 path/title,id 与 meta 不变),无路径窗口 = 带 chrome 的空文件窗口(树默认展开)。树右键菜单提供「在新 Tab 中打开」「在侧边打开」(后者在当前 pane 右侧 split 出新 editor tab)。新会话在两种模式下都默认 seed 空文件窗口(`title: 'Files'`,无 path,树面板展开);持久化的旧 `explorer` tab 经 `sanitizeState` 迁移为该 home tab |
|
||||
| `git` | 20 | 是 | 否 | Git 面板 |
|
||||
| `subagent` | 30 | 是 | 否 | 子代理拓扑 |
|
||||
| `terminal` | 40 | 否 | 否 | 终端(nextTerminal 自增) |
|
||||
@@ -338,6 +405,11 @@ interface FileViewerProps {
|
||||
truncated?: boolean // fetchStrategy='fsRead' 时
|
||||
mediaUrl?: string // fetchStrategy='mediaUrl' 时
|
||||
customData?: unknown // fetchStrategy='custom' 时(load() 的返回值)
|
||||
// 以下三个为内置文本编辑器与 EditorHost 的内部协作字段(合并模式把
|
||||
// 预览/编辑/保存工具栏上移到路径输入框行);外部 viewer 忽略即可:
|
||||
toolbar?: 'self' | 'host'
|
||||
onToolbarState?: (state: EditorToolbarState) => void
|
||||
onToolbarControls?: (controls: EditorToolbarControls | null) => void
|
||||
}
|
||||
```
|
||||
|
||||
@@ -462,7 +534,7 @@ interface BetterSidebarService {
|
||||
readonly version: string
|
||||
/** 单调能力清单(只增不删):'badge' | 'tabLifecycle' | 'updateTab' |
|
||||
* 'openFile' | 'targetedOpen' | 'stateSubscription' | 'tabMeta' |
|
||||
* 'pluginSettings'——消费插件用 `features.includes('xxx')` 按能力 gate。 */
|
||||
* 'pluginSettings' | 'urlTarget' | 'settingSelect'——消费插件用 `features.includes('xxx')` 按能力 gate。 */
|
||||
readonly features: readonly string[]
|
||||
/** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。
|
||||
* session 未激活时 state/sessionId 为 undefined。 */
|
||||
@@ -475,7 +547,10 @@ interface BetterSidebarService {
|
||||
* 未知 id 严格 no-op);scope(v0.12.0+)随回调传递,同 closeTab */
|
||||
activateTab(tabId: string, scope?: SessionScope): void
|
||||
/** 在 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
|
||||
}
|
||||
|
||||
@@ -493,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`)。**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 起也有齿轮)。
|
||||
|
||||
---
|
||||
|
||||
@@ -516,12 +591,33 @@ interface OpenTabSeed {
|
||||
| **host 半无此服务** | `ctx.betterSidebar` 只在 client 侧存在;host 半需要 better-sidebar 数据走 `/sidebar/api/*` HTTP 路由 |
|
||||
| **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换 |
|
||||
| **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,漏传则代码块复制按钮回退硬编码中文 |
|
||||
| **懒加载 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`)。其内是**统一面板宿主层** `[data-dsh-panel-host]`(`position:fixed; inset:0; z-index:40; pointer-events:none`,v0.13.1+):面板/开关簇在其内 **absolute** 定位(层 inset:0 即视口坐标),免疫桌面套壳中间层 transform 对 fixed 含块的劫持;页面级 transform(罕见)触发 `data-dsh-panel-host-degraded` 降级同步。皮肤若要做作用域覆盖(deep-whale 的做法),限定在 `[data-dsh-better-sidebar]` 内即可,避免全局改写影响宿主。
|
||||
- **布局变量**(写在 `<html>` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`(面板几何;拖拽期间逐帧更新)。宿主推挤 = `#root` 的 `margin-right/width: calc(100% - var)`(v0.13.1+ 防桌面壳加性溢出)与 centerCol 的 `margin-bottom`;推挤锚点是 **`#root [data-dsh-frame] > [data-pane="conversation"]`**(稳定 data 属性,**禁止**退回 `nth-child` 位置锚定)。
|
||||
- **桌面信号**(v0.13.1+):DSH Desktop 壳在 URL 注入 `dsh-desktop-mode` / `dsh-desktop-platform`(preload 另暴露 `__DSH_DESKTOP_FILE_PATH__`);win32 `advanced` 壳右上角有 32px 窗口控制 overlay,插件自动启用标题栏兼容(`body[data-dsh-title-bar-compat]` + `--dsh-title-bar-strip: 32px`),手动 pref 可覆盖;`compatibility` 模式与无信号环境不避让。
|
||||
- **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` 文件预览器。
|
||||
|
||||
@@ -536,7 +632,7 @@ interface OpenTabSeed {
|
||||
"./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }
|
||||
},
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"cordis": "^4.0.0-rc.8",
|
||||
"dsh-better-sidebar": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"react": "^18.2.0"
|
||||
@@ -597,16 +693,17 @@ function parseCsv(text: string): string[][] { /* ... */ }
|
||||
|
||||
---
|
||||
|
||||
## 9. 参考实现
|
||||
## 10. 参考实现
|
||||
|
||||
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/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套设置行:switch/text/number + 持久化)
|
||||
- **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating 测试
|
||||
- **`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/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`**:设计文档(含实施偏差记录)
|
||||
|
||||
调试时直接读这些文件即可看到所有 API 的真实用法。
|
||||
|
||||
@@ -1,145 +1,122 @@
|
||||
# dsh-better-sidebar
|
||||
|
||||
<!-- 头部介绍区(HTML 排版) -->
|
||||
<!-- Hero -->
|
||||
<div align="center">
|
||||
<b style="font-size: 1.15em;">一个插件,一套完整工作台</b><br /><br />
|
||||
<code>文件管理</code> <code>编辑预览</code> <code>内嵌浏览器</code> <code>真实终端</code> <code>Git 面板</code> <code>后台任务页</code><br /><br />
|
||||
<b>右侧栏 + 底部面板双工作台</b>,一个插件全部搞定。<br />
|
||||
<small>支持 Tab 窗口随意拖拽,支持三方拓展注册新 Tab 页面和文件预览</small>
|
||||
<b style="font-size: 1.15em;">一个服务化的侧边栏框架,一套开箱即用的完整工作台</b><br /><br />
|
||||
<a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg" /></a>
|
||||
<a href="https://dshfind.com/zh/plugins/omdsh-dev/DSH-better-sidebar?ref=badge"><img alt="dshfind" src="https://dshfind.com/api/badge/omdsh-dev/DSH-better-sidebar?lang=zh" /></a><br /><br />
|
||||
<img alt="文件管理" src="https://img.shields.io/badge/-文件管理-4d6bfe" /> <img alt="编辑预览" src="https://img.shields.io/badge/-编辑预览-4d6bfe" /> <img alt="内嵌浏览器" src="https://img.shields.io/badge/-内嵌浏览器-4d6bfe" /> <img alt="真实终端" src="https://img.shields.io/badge/-真实终端-4d6bfe" /> <img alt="Git 面板" src="https://img.shields.io/badge/-Git%20面板-4d6bfe" /> <img alt="后台任务" src="https://img.shields.io/badge/-后台任务-4d6bfe" /> <img alt="插件接入" src="https://img.shields.io/badge/-插件接入-4d6bfe" /><br /><br />
|
||||
<b>右侧栏 + 底部面板双工作台</b>,并把 <code>ctx.betterSidebar</code> 服务开放给所有插件——<br />
|
||||
通过 <code>registerTab</code> / <code>registerFileViewer</code> 注册新的侧边栏页面与文件预览器。
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
🌏 <a href="./README.md"><b>中文</b></a> · <a href="./README_EN.md">English</a>
|
||||
</div>
|
||||
|
||||
https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86
|
||||
|
||||
<img width="2630" height="1794" alt="6c4293e1bec2e935031bf0e986d6ec65" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
|
||||
|
||||
## 🆕 最近更新
|
||||
|
||||
<small>v0.12.1</small>
|
||||
|
||||
| 功能 | 说明 | 截图 |
|
||||
|---|---|---|
|
||||
| 🔌 服务化基座 | 完整类型导出 + `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 预览盘符路径 | |
|
||||
<div align="center">
|
||||
<img alt="dsh-better-sidebar 工作台截图" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
|
||||
<video src="https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86" muted autoplay loop playsinline controls width="100%"></video>
|
||||
</div>
|
||||
|
||||
## ✨ 功能一览
|
||||
|
||||
- **🗂️ 资源管理器**:懒加载目录树(根 = 会话 cwd),点击打开、`@文件` 引用、右键复制路径
|
||||
- **📝 编辑与预览**:CodeMirror 6 编辑(Ctrl/Cmd+S 原子保存,切 Tab 不丢草稿);图片 / Markdown / HTML / PDF / Word / Excel / PPT 内联预览(HTML 走沙箱 iframe)
|
||||
- **⚡ 客户端懒加载**:启动只拉 ~325KB 核心,终端 / 编辑器等重依赖用到才按需拉取(详见 `docs/plans/2026-08-12-lazy-chunks-design.md`)
|
||||
- **🌐 浏览器**:多开内嵌网页 tab,后退/前进/刷新;内容运行在沙箱 iframe(无法访问界面数据与本地文件,拒绝本机地址),可临时解锁(红色警示);被站点拒绝嵌入时显示原因;外链默认在侧边栏打开
|
||||
- **💻 终端**:xterm.js + node-pty 真实 shell、断线重连回放;可选为模型注入 `terminal_*` 工具;支持自定义字体(字体族 + 9–32px 字号,实时生效)
|
||||
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存/提交/还原等
|
||||
- **🧩 后台任务页**:主会话 agent 拓扑、点击直达执行记录;同页显示后台任务(类型徽标 + 退出码、实时输出 peek、可强制终止)
|
||||
- **🪟 底部面板**:第二个独立工作台,只挤占中间输出区;首次展开自动开终端(可关)
|
||||
- **📱 移动端**:窄屏(<768px)自动合并为全宽抽屉,底部面板标签页并入右侧栏;点文件/外链自动展开
|
||||
- **🔧 分栏工作台**:拖 Tab 拆分/合并(可跨面板)、分隔线调比例;右上角按钮一键折叠/展开面板
|
||||
- **🔁 会话隔离**:布局 / Tab / 面板状态按会话持久化,陈旧状态自动净化;「产出文件」在侧边栏打开
|
||||
- **⚙️ 声明式设置**:设置页「侧边卡片」按注册表渲染开关网格,逐项独立开/关;二级设置(自动展开、终端工具、沙箱等)经齿轮弹窗编辑
|
||||
- **🔌 服务化(基座)**:暴露 `ctx.betterSidebar`,其他插件可注册 tab 与文件预览器(内置 7 tab + 9 viewer 同走一服务);v0.12.1 起支持角标/生命周期回调/状态订阅/定向打开/插件自有设置(见 [AGENTS.md](./AGENTS.md) 与 [外部插件接入指南](./docs/external-plugin-guide.md))
|
||||
- **➕ 添加插件**:设置页「侧边卡片」两个网格(侧边栏内容 / 文件预览)末尾的虚线卡片分别打开 Tab / 预览插件弹窗:声明扩展点、「在 GitHub 上浏览更多插件」按钮、推荐插件目录(「跳转」直达仓库、「复制」安装命令到剪贴板);弹窗不打开终端、无失败路径,不阻塞启动与侧边栏
|
||||
- **🌏 多语言**:界面文案跟随 DSH 语言(zh/en)实时切换,无需刷新
|
||||
- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大)/ HTML / PDF / Office 内联预览
|
||||
- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整)
|
||||
- **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具
|
||||
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原
|
||||
- **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止)
|
||||
- **🪟 双工作台**:右侧栏 + 底部面板;拖 Tab 拆分 / 合并分栏(可跨面板),移动端自动合并全宽抽屉
|
||||
- **🔁 会话隔离**:布局 / Tab / 面板按会话持久化,陈旧状态自动净化
|
||||
- **⚙️ 声明式设置**:设置页「侧边卡片」逐项独立开关,二级设置经齿轮弹窗
|
||||
- **⚡ 按需加载**:启动只拉 ~325KB 核心,终端 / 编辑器 / Mermaid 图表等重依赖用到才按需拉取([设计文档](docs/plans/2026-08-12-lazy-chunks-design.md))
|
||||
- **🌏 多语言**:界面文案跟随 DSH 语言(zh / en)实时切换
|
||||
|
||||
> 🔌 **核心理念**:服务优先——内置的 7 tab + 6 viewer 与第三方插件通过同一套 `ctx.betterSidebar` API 注册,能力完全对等;官方不再内置、可由生态提供的功能,交由生态插件实现。接入文档见下方「🔌 服务化」与 [外部插件接入指南](./docs/external-plugin-guide.md)。
|
||||
|
||||
## 🆕 最近更新
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/user-attachments/assets/d2aea86b-a776-4f01-a6b8-b26b27314336"><img width="33%" alt="侧边栏" src="https://github.com/user-attachments/assets/d2aea86b-a776-4f01-a6b8-b26b27314336" /></a>
|
||||
<a href="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0"><img width="33%" 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="33%" alt="添加插件截图" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a>
|
||||
</div>
|
||||
|
||||
### v0.14.0
|
||||
|
||||
> ⚠️ **本版适配 DSH 0.1.0-rc.8**:全部 `@deepseek-ai/*` peer / devDependencies 升至 `^0.1.0-rc.8`(含传递依赖,lockfile 零 rc.7 残留),`cordis` 同步 `^4.0.0-rc.8`,CI 挂载冒烟钉版 `@deepseek-ai/dsh@0.1.0-rc.8`。**rc.7 及更早的 DSH 环境将无法解析本版依赖,请先升级 DSH**。自 v0.13.1 以来的全部更改:
|
||||
|
||||
**✨ 新功能**
|
||||
|
||||
- 🖼️ **统一面板宿主注入重构**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):面板/开关簇迁入 `[data-dsh-panel-host]` 固定含块层(`fixed inset-0 z-40`),免疫桌面套壳中间层 transform 对 fixed 含块的劫持;挂载自检(页面级 transform → `data-dsh-panel-host-degraded` 降级同步,按未修正几何判定、祖先变换消失才退出);推挤锚点改 `#root [data-dsh-frame] > [data-pane="conversation"]` + `#root` calc 宽度防桌面壳加性溢出;chunk 激活重验证(HEAD+ETag 保留未变 chunk,5s 超时兜底 fail-open);桌面信号自动探测(win32 advanced 标题栏兼容 32px 避让,手动 pref 可覆盖);`visualViewport` 键盘 inset + `env(safe-area-inset-*)` 移动端适配
|
||||
- 📂 **文件打开方式默认独立**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):`editorExplorer` 默认从「合并」改为「独立」——新会话树点击 / 打开文件按路径**新开**文件 tab,无路径窗口即纯资源管理器;合并模式保留为可选手动开启
|
||||
- 🖥️ **终端 shell / shellArgs 设置页可配**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):终端卡齿轮二级页面新增「Shell 路径」「Shell 参数」两行配置(此前只能通过 `cordis.patch.yml` 配置)——设置页写入后对**之后打开的** UI 终端与模型终端(`terminal_create`)即时生效;留空保持 yaml → `$SHELL` / 登录 shell / `powershell.exe` 的既有解析顺序
|
||||
- 🏷️ **设置页版本徽标**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):侧边卡片设置页顶部新增 `DSH-better-sidebar v0.14.0` 身份徽标(版本与服务实例同步,由测试守护)
|
||||
- 🔍 **添加插件目录搜索 / 分组 / 独立滚动**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):为插件生态增长做准备——目录列表顶部加实时搜索(按名称 / id / 描述过滤),条目支持可选 `category` 分组渲染,列表独立滚动(弹窗不再随条目数无限增长)
|
||||
|
||||
**🐛 修复**
|
||||
|
||||
- 🔧 **适配 DSH 0.1.0-rc.8**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):13 个 `@deepseek-ai/*` peer / devDependencies 升至 `^0.1.0-rc.8`(含传递链,lockfile 零 rc.7 残留),`cordis` 同步 `^4.0.0-rc.8`;移除随 rc.8 消失的 `dsh-client-web-react` / `dsh-client-schema-form`(壳模块表不再提供、插件零引用);CI 挂载冒烟钉版 `@deepseek-ai/dsh@0.1.0-rc.8`;pnpm 11.8 supply-chain 校验适配
|
||||
- 🧩 **rc.8 模块系统迁移**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):rc.8 不再暴露 `window.__DSH_MODULES__` 页面全局(改由 `ctx.modules` 服务提供),懒加载 chunk 的外部依赖解析全面失效——client 注入 `modules` 服务 + 插件自有全局共享给 chunk 副本(终端 / 编辑器 / Mermaid 恢复正常按需加载)
|
||||
- 🧩 **chunk 重验证屏障健壮性**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):HEAD 重验证加 5s 超时兜底(路由挂起时 fail-open 重取,屏障不再可能无限期阻塞懒加载);`resetChunks` 清挂起的重验证屏障
|
||||
- 🖱️ **拖拽健壮性**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):快速释放(浏览器合并 / 丢失 pointermove 突发)时提交最后已知拖动位置而非回退;`pointercancel` / 捕获丢失中断同样保留拖动结果;提交后立即重测中心列(消除底栏宽度中间帧抖动);HMR 重激活后中心列重定位兜底(`<html>` 样式观察 + 底栏打开重测),修复热更新后底栏空白 / 输入框位移
|
||||
|
||||
### v0.13.1
|
||||
|
||||
**✨ 新功能**
|
||||
|
||||
- 📊 **Markdown 预览安全渲染 Mermaid 图表**([#164](https://github.com/omdsh-dev/DSH-better-sidebar/pull/164)):预览的 md 含 mermaid fence 时按需下发 `client-mermaid.js` chunk(~7MB,无 mermaid 文件零加载);纵深防御渲染——`securityLevel: 'strict'` + `htmlLabels: false`(节点文字走真实 SVG `<text>`)+ SVG 注入前二次清洗(删 `foreignObject`/`script`/外来 HTML 元素、剥 `@*`/`on*`/`href` 属性);点击图表在弹窗中放大(滚轮以鼠标为中心缩放、拖拽平移、工具栏与快捷键),深浅色跟随重渲、解析失败回退原码
|
||||
- 🖥️ **终端 shell 与 shellArgs 可配置**([#125](https://github.com/omdsh-dev/DSH-better-sidebar/pull/125)):`cordis.patch.yml` 的 `better-sidebar.config` 可指定 `shell` / `shellArgs`(`shellArgs` 非空时完全替换默认参数;未配置维持自动解析 `$SHELL` / 登录 shell / `powershell.exe` 原行为),UI 终端与 agent 终端(`terminal_create`)同时生效;终端 tab 标题改用 shell 名(bash / zsh / powershell),内部标识改 UUID,同 shell 可开多个终端
|
||||
|
||||
**🐛 修复**
|
||||
|
||||
- 🔗 **聚合双挂载自动退让**([#200](https://github.com/omdsh-dev/DSH-better-sidebar/pull/200)):聚合包(如 dsh-web-ui-all)以独立条目 id 挂载同包时,`cordis.patch.yml` 的守卫表达式自动禁用自身 `better-sidebar` 行,不再重复注册 `/sidebar/api` 导致 `duplicate prefix route` 整个插件树启动失败(`dsh web` 崩溃);独立安装行为不变
|
||||
- 🔧 **peer 依赖对齐 DSH 0.1.0-rc.7**([#207](https://github.com/omdsh-dev/DSH-better-sidebar/pull/207),修复 [#206](https://github.com/omdsh-dev/DSH-better-sidebar/issues/206)):全部 `@deepseek-ai/*` peer / devDependencies 从 `^0.1.0-rc.6` 升至 `^0.1.0-rc.7`,CI 挂载冒烟同步钉版——消除主框架升至 rc.7 后 rc.6 / rc.7 混用依赖树导致的 `agent-presets: refusing to compose an unscoped context`(选模型 / 发消息报错)
|
||||
|
||||
### v0.13.0
|
||||
|
||||
**✨ 新功能**
|
||||
|
||||
- 📁 **文件窗口与资源管理器二合一**([#151](https://github.com/omdsh-dev/DSH-better-sidebar/pull/151)):新 `editorExplorer` 设置(编辑器卡齿轮)——文件 tab 增加路径输入框头部 + 可开关的右侧停靠文件树(每 tab 记忆展开/宽度,左缘拖拽调宽 160~480px,全局文件名搜索走 host `fs.search` 路由,预算封顶并跳过 `.git` / 符号链接目录);独立模式(默认)树点击 / 输入框 Enter **按路径新开**文件 tab,合并模式**原地切换**当前 tab;新会话默认 seed 空文件窗口(`Files`)替代 explorer tab,无路径窗口在独立模式为纯资源管理器、合并模式为带 chrome 的空文件窗口;树右键提供「在新 Tab 中打开」「在侧边打开」(split)
|
||||
- 🎛️ **声明式设置 select 行**([#151](https://github.com/omdsh-dev/DSH-better-sidebar/pull/151)):设置项新增 `type: 'select'`(`options` 支持 value/title/desc/icon,`multi` 多选存数组);带图标的选项渲染大图标选项卡、收起态同样显示图标;`editorExplorer` 改为图标化下拉(合并 / 独立);能力清单新增 `settingSelect`
|
||||
- 🔀 **与 dsh-web-ui 家族右侧面板互斥**([#181](https://github.com/omdsh-dev/DSH-better-sidebar/pull/181)):读取 `aionui-panel` 设置命名空间的提供方选择——当选择「使用 aionui-panel」时,整个 better-sidebar(右侧栏 / 底部面板 / 浮动入口 / 各类接管)不再挂载;选择 DSH-better-sidebar(或未安装 aionui)时正常。设置页保存后实时生效(settings-document 推送),无需刷新
|
||||
|
||||
### v0.12.3
|
||||
|
||||
**✨ 新功能**
|
||||
|
||||
- 🎨 **皮肤兼容(令牌驱动)**:全面消费 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 偶发失败修复
|
||||
|
||||
## 🚀 安装
|
||||
|
||||
**前置**:已装好 DSH(`dsh web` 能正常运行),Node.js ≥ 20、pnpm ≥ 10。
|
||||
|
||||
**macOS / Linux**(Windows 装了 Git Bash 或 WSL 也可):
|
||||
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
**Windows(PowerShell 5.1+ / pwsh)**:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1 | iex
|
||||
dsh plugin --profile web add dsh-better-sidebar@latest
|
||||
```
|
||||
|
||||
装完**硬刷新浏览器**(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.1 --restart
|
||||
|
||||
# Windows PowerShell
|
||||
& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.12.1 -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.1)
|
||||
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.1` 发布,通过 `dsh.bundle.patch`(随包的 `cordis.patch.yml`)由官方 CLI 自动挂载,**不修改 DSH 源码**。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>更新</b></summary>
|
||||
|
||||
```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>
|
||||
|
||||
@@ -148,12 +125,13 @@ dsh plugin --profile web add dsh-better-sidebar
|
||||
|
||||
| 现象 | 原因与解决 |
|
||||
|---|---|
|
||||
| 报 `Ignored build scripts` | pnpm 11 拦截构建脚本。跑 `pnpm approve-builds --all`(一键脚本已自动处理)。 |
|
||||
| 报 `minimum release age` / 版本不足 24h | 装的版本发布不足 24 小时。等 24h 或重跑一次(pnpm 会自动补 `minimumReleaseAgeExclude`);一键脚本已自动处理。 |
|
||||
| 报 `Ignored build scripts` | pnpm 11 拦截构建脚本。在 profile 目录(`~/.dsh/profiles/web`)跑 `pnpm approve-builds --all`。 |
|
||||
| 报 `minimum release age` / 版本不足 24h | 装的版本发布不足 24 小时。等 24h 或重跑一次(pnpm 会自动补 `minimumReleaseAgeExclude`)。 |
|
||||
| 报「找不到 profile 目录」 | 先跑一次 `dsh web`,让它初始化 `~/.dsh/profiles/web`。 |
|
||||
| 页面出现**两个侧边栏** | 双挂载:`~/.dsh/profiles/web/cordis.patch.yml` 还留着旧的手动挂载行,删掉那段 `- insert: ... better-sidebar ...`(一键脚本会自动清)。 |
|
||||
| 页面出现**两个侧边栏** | 双挂载。旧的手动挂载行:`~/.dsh/profiles/web/cordis.patch.yml` 还留着 `- insert: ... better-sidebar ...`,删掉那段(同 id 重复挂载 loader 会直接报 `duplicate loader entry id`)。聚合包(如 `@linxin666/dsh-web-ui-all`)以**不同 id** 挂载本包时,0.13.x 起插件自身 bundle patch 会自动退让(检测到已有启用中的同包名挂载就不挂自己),无需手动处理;若仍双挂载,先确认聚合包的 bundle 顺序在 `dsh-better-sidebar` 之前。 |
|
||||
| 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>
|
||||
|
||||
@@ -166,15 +144,20 @@ dsh plugin --profile web add dsh-better-sidebar
|
||||
1. git clone https://github.com/omdsh-dev/DSH-better-sidebar.git ~/Code/DSH-better-sidebar
|
||||
cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build
|
||||
2. ~/.dsh/profiles/web/package.json 的 dependencies 写 "dsh-better-sidebar": "link:<克隆目录绝对路径>"
|
||||
3. ~/.dsh/profiles/web/cordis.patch.yml 追加挂载行:
|
||||
3. ~/.dsh/profiles/web/cordis.patch.yml 追加挂载行(需要指定终端 shell 时,在行内加 `config.shell`;`config.shellArgs` 可带参启动,非空时替换默认的 `-l`。不填则自动解析 `$SHELL` / 登录 shell / powershell.exe):
|
||||
- insert:
|
||||
- id: better-sidebar
|
||||
name: 'dsh-better-sidebar'
|
||||
config:
|
||||
shell: /bin/zsh
|
||||
shellArgs:
|
||||
- --noprofile
|
||||
- --no-rc
|
||||
4. 在 ~/.dsh/profiles/web 执行 pnpm install
|
||||
5. 硬刷新浏览器(Cmd/Ctrl+Shift+R)即可看到效果(client 改动无需重启 DSH;host 半改动才需重启)
|
||||
```
|
||||
|
||||
更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.12.1"` 再 `pnpm install`。
|
||||
更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.13.0"` 再 `pnpm install`。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -208,7 +191,7 @@ dsh registry enable dsh-external/dsh-better-sidebar
|
||||
|
||||
## 🔌 服务化:注册 tab 与文件预览器
|
||||
|
||||
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 7 tab + 9 viewer 也走同一服务,吃自己的狗粮):
|
||||
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 7 tab + 6 viewer 亦通过同一服务注册):
|
||||
|
||||
```ts
|
||||
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
|
||||
@@ -235,7 +218,7 @@ v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、
|
||||
## 🛠️ 开发与构建
|
||||
|
||||
```sh
|
||||
pnpm install # @deepseek-ai/* 已发布到 npm(^0.1.0-rc.6),直接解析、无需令牌
|
||||
pnpm install # @deepseek-ai/* 已发布到 npm(^0.1.0-rc.8),直接解析、无需令牌
|
||||
pnpm typecheck # tsc --noEmit
|
||||
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
|
||||
pnpm test # vitest(含 manifest 一致性守卫,需先 build)
|
||||
@@ -268,3 +251,4 @@ Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元
|
||||
- [dsh-tianshu-tui](https://github.com/huiliyi37/dsh-tianshu-tui):DeepSeek Harness 交互式终端 UI 插件(渲染核心由自研 harness agent Tianshu-Tui 演进而来),在官方基础上增加 TDD 与证据门等工作流
|
||||
- [dsh-TUI](https://github.com/ccch1mneyyy/dsh-TUI):Claude Code 风格全屏交互终端插件——像素鲸鱼顶栏、实时工作状态行、思考流式展开、双击 Esc 回滚、上下文进度条 + TPS 仪表,npm 一键安装
|
||||
- [dshfind 插件超市](https://dshfind.com/zh/plugins):三方插件市场——GitHub topic `dsh-plugin` 下的公开仓库清单,每日同步 star、贡献者与增长数据
|
||||
- [DeepSeek Harness Desktop](https://github.com/anywhere-labs/deepseek-harness-desktop):为 DeepSeek Harness 生态打造的现代化桌面端——无需配置 Node.js 或执行命令即可启动和管理本地 Harness 服务;[官网](https://www.dshdesktop.cn)
|
||||
|
||||
+137
-122
@@ -1,145 +1,153 @@
|
||||
# dsh-better-sidebar
|
||||
|
||||
<!-- Hero (HTML layout) -->
|
||||
<!-- Hero -->
|
||||
<div align="center">
|
||||
<b style="font-size: 1.15em;">One plugin, one complete workbench</b><br /><br />
|
||||
<code>File management</code> <code>Edit & preview</code> <code>Embedded browser</code> <code>Real terminal</code> <code>Git panel</code> <code>Background tasks</code><br /><br />
|
||||
<b>Dual workbench: right sidebar + bottom panel</b>, all in one plugin.<br />
|
||||
<small>Tabs are freely draggable; third-party plugins can register new tab pages and file viewers</small>
|
||||
<b style="font-size: 1.15em;">A service-oriented sidebar framework, and a complete workbench out of the box</b><br /><br />
|
||||
<a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg" /></a>
|
||||
<a href="https://dshfind.com/en/plugins/omdsh-dev/DSH-better-sidebar?ref=badge"><img alt="dshfind" src="https://dshfind.com/api/badge/omdsh-dev/DSH-better-sidebar?lang=en" /></a><br /><br />
|
||||
<img alt="File management" src="https://img.shields.io/badge/-File%20management-4d6bfe" /> <img alt="Edit & preview" src="https://img.shields.io/badge/-Edit%20%26%20preview-4d6bfe" /> <img alt="Embedded browser" src="https://img.shields.io/badge/-Embedded%20browser-4d6bfe" /> <img alt="Real terminal" src="https://img.shields.io/badge/-Real%20terminal-4d6bfe" /> <img alt="Git panel" src="https://img.shields.io/badge/-Git%20panel-4d6bfe" /> <img alt="Background tasks" src="https://img.shields.io/badge/-Background%20tasks-4d6bfe" /> <img alt="Plugin integration" src="https://img.shields.io/badge/-Plugin%20integration-4d6bfe" /><br /><br />
|
||||
<b>A dual workbench (right sidebar + bottom panel)</b> that opens its <code>ctx.betterSidebar</code> service to every plugin —<br />
|
||||
register new sidebar pages and file viewers via <code>registerTab</code> / <code>registerFileViewer</code>.
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
🌏 <a href="./README.md">中文</a> · <a href="./README_EN.md"><b>English</b></a>
|
||||
</div>
|
||||
|
||||
https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86
|
||||
|
||||
<img width="2630" height="1794" alt="6c4293e1bec2e935031bf0e986d6ec65" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
|
||||
|
||||
## 🆕 Recent Updates
|
||||
|
||||
<small>v0.12.1</small>
|
||||
|
||||
| Feature | Description | Screenshot |
|
||||
|---|---|---|
|
||||
| 🔌 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 | |
|
||||
<div align="center">
|
||||
<video src="https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86" muted autoplay loop playsinline controls width="100%"></video>
|
||||
<img alt="dsh-better-sidebar workbench" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
|
||||
</div>
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **🗂️ File Explorer**: lazy-loading directory tree (root = session cwd), click to open, `@file` reference, right-click to copy path
|
||||
- **📝 Edit & Preview**: CodeMirror 6 editing (Ctrl/Cmd+S atomic save, drafts survive tab switches); inline preview for images / Markdown / HTML / PDF / Word / Excel / PPT (HTML in a sandboxed iframe)
|
||||
- **⚡ Client-side Lazy Loading**: only ~325KB core at startup; heavy deps (terminal / editor) load on demand (see `docs/plans/2026-08-12-lazy-chunks-design.md`)
|
||||
- **🌐 Browser**: multiple embedded web tabs, back/forward/refresh; pages run in a sandboxed iframe (no access to UI data or local files, rejects local addresses), temporarily unlockable (red warning); sites refusing embedding show a reason panel; external links open in the sidebar by default
|
||||
- **💻 Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model; custom font (family + 9–32px size, applied live)
|
||||
- **🌿 Git Panel**: real diff + VSCode-style diff tabs, history, right-click to stage/commit/revert etc.
|
||||
- **🧩 Background Tasks**: main-session agent topology with click-through to execution records; background tasks on the same page (type badges + exit codes, live-output peek, force-kill)
|
||||
- **🪟 Bottom Panel**: an independent second workbench that only squeezes the center output area; auto-opens a terminal on first expand (toggleable)
|
||||
- **📱 Mobile**: narrow viewports (<768px) merge into a full-width drawer with the bottom panel's tabs folded into the sidebar; files/links auto-expand the panel
|
||||
- **🔧 Split-pane Workbench**: drag tabs to split/merge panes (cross-panel supported), divider to adjust ratios; one-click collapse/expand both panels from the top-right buttons
|
||||
- **🔁 Session Isolation**: layout / tabs / panel states persisted per session, stale state auto-purged; "produced files" open in the sidebar
|
||||
- **⚙️ Declarative Settings**: the "Side Cards" settings section renders a registry-driven toggle grid, each item independently switchable; secondary settings (auto-expand, terminal tools, sandbox, etc.) edited in a native dialog via the gear button
|
||||
- **🔌 Service API**: exposes `ctx.betterSidebar` — other plugins can register tabs and file viewers (the 7 built-in tabs + 9 viewers share the same service, see [AGENTS.md](./AGENTS.md) and the [external plugin guide](./docs/external-plugin-guide.md))
|
||||
- **➕ Add Plugins**: dashed cards at the end of both settings grids ("Sidebar content" / "File viewers") open tab / previewer plugin modals: the extension point declaration, a "Browse more plugins on GitHub" BUTTON, and the recommended plugin catalog ("Open" jumps to the repo, "Copy" puts the install command on the clipboard); no terminal is opened, nothing can fail — never blocking startup or the sidebar
|
||||
- **🌏 i18n**: UI text follows DSH's language setting (zh/en) with live switching, no refresh needed
|
||||
- **🗂️ 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 (incl. Mermaid diagrams, strict-mode safe rendering + click-to-zoom) / 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)
|
||||
- **💻 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
|
||||
- **🧩 Background Tasks**: agent topology + background tasks (exit codes / live output / force-kill)
|
||||
- **🪟 Dual Workbench**: right sidebar + bottom panel; drag tabs to split / merge panes (cross-panel), mobile auto-merges into a full-width drawer
|
||||
- **🔁 Session Isolation**: layout / tabs / panels persisted per session, stale state auto-purged
|
||||
- **⚙️ Declarative Settings**: per-item toggles in the "Side Cards" settings section, secondary settings via the gear dialog
|
||||
- **⚡ On-demand Loading**: only ~325KB core at startup; heavy deps (terminal / editor / mermaid diagrams) load on demand ([design](docs/plans/2026-08-12-lazy-chunks-design.md))
|
||||
- **🌏 i18n**: UI text follows DSH's language (zh / en) with live switching
|
||||
|
||||
> 🔌 **Core principle**: service-first — the 7 built-in tabs + 6 viewers register through the same `ctx.betterSidebar` API as third-party plugins, with fully equal capabilities; anything the ecosystem can provide better is delegated to ecosystem plugins. See the "🔌 Service" section below and the [external plugin guide](./docs/external-plugin-guide.md).
|
||||
|
||||
## 🆕 Recent Updates
|
||||
|
||||
<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>
|
||||
|
||||
### v0.14.0
|
||||
|
||||
> ⚠️ **This release adapts to DSH 0.1.0-rc.8**: every `@deepseek-ai/*` peer/devDependency is raised to `^0.1.0-rc.8` (transitives included — zero rc.7 leftovers in the lockfile), `cordis` to `^4.0.0-rc.8`, and the CI mount lane pins `@deepseek-ai/dsh@0.1.0-rc.8`. **DSH environments on rc.7 or earlier can no longer resolve this release's dependencies — upgrade DSH first.** All changes since v0.13.1:
|
||||
|
||||
**✨ New features**
|
||||
|
||||
- 🖼️ **Unified panel-host injection refactor** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): panels/toggle clusters moved into a `[data-dsh-panel-host]` fixed containing block (`fixed inset-0 z-40`), immune to desktop-shell intermediate transforms hijacking `fixed`; mount self-check (page-level transform → `data-dsh-panel-host-degraded` degraded sync, judged on uncorrected geometry, exits only when the ancestor transform is gone); push anchor switched to `#root [data-dsh-frame] > [data-pane="conversation"]` + `#root` calc width against desktop-shell additive overflow; chunk revalidation on activation (HEAD+ETag keeps unchanged chunks, 5s timeout fails open); desktop signal auto-detection (win32 advanced title-bar compat 32px avoidance, manual pref overrides); `visualViewport` keyboard inset + `env(safe-area-inset-*)` mobile adaptation
|
||||
- 📂 **Separate file windows by default** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): `editorExplorer` now defaults to **separate** — tree clicks / file opens create a new tab per path and the path-less window is a pure file manager; merged mode stays available as an opt-in
|
||||
- 🖥️ **Terminal shell / shellArgs configurable from the settings page** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): the terminal card's gear popup gains "Shell path" and "Shell arguments" rows (previously yaml-only via `cordis.patch.yml`) — saved values take effect immediately for terminals opened afterwards (UI terminals and model `terminal_create` alike); empty keeps the existing yaml → `$SHELL` / login shell / `powershell.exe` resolution order
|
||||
- 🏷️ **Version badge on the settings page** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): the side-card settings section now opens with a `DSH-better-sidebar v0.14.0` identity badge (version synced with the service instance, test-guarded)
|
||||
- 🔍 **Add-plugin catalog: search / grouping / independent scroll** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): built for a growing plugin ecosystem — a live search box (filters by name / id / description), optional `category` grouping for entries, and an independently scrolling list (the modal no longer grows unbounded with catalog size)
|
||||
|
||||
**🐛 Fixes**
|
||||
|
||||
- 🔧 **Adapted to DSH 0.1.0-rc.8** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): all 13 `@deepseek-ai/*` peer/devDependencies raised to `^0.1.0-rc.8` (transitives included; zero rc.7 leftovers in the lockfile), `cordis` to `^4.0.0-rc.8`; `dsh-client-web-react` / `dsh-client-schema-form` removed (no rc.8 publish, no longer in the shell module table, zero plugin references); CI mount lane pins `@deepseek-ai/dsh@0.1.0-rc.8`; pnpm 11.8 supply-chain verification adapted
|
||||
- 🧩 **rc.8 module-system migration** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): rc.8 no longer exposes the `window.__DSH_MODULES__` page global (it moved to the `ctx.modules` service), which broke every lazy chunk's externals resolution — the client now injects the `modules` service and shares it with chunk-bundle copies through a plugin-owned global (terminal / editor / Mermaid on-demand loading restored)
|
||||
- 🧩 **Chunk revalidation barrier hardening** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): HEAD revalidation gains a 5s timeout (fails open on a stuck route so the barrier can never wedge lazy loads); `resetChunks` clears a pending revalidation barrier
|
||||
- 🖱️ **Drag robustness** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): fast releases (browsers merge/lose pointermove bursts) commit the last known dragged position instead of rolling back; `pointercancel` / lost-capture interruptions keep the drag result too; the center column is re-measured right after commit (no mid-frame bottom-panel width jump); HMR re-activation re-locates the center column via an `<html>` style observer plus a retry when the bottom panel opens (fixes the blank bottom panel / shifted input bar after a hot reload)
|
||||
|
||||
### v0.13.1### v0.13.1
|
||||
|
||||
**✨ New features**
|
||||
|
||||
- 📊 **Safe Mermaid rendering in the Markdown preview** ([#164](https://github.com/omdsh-dev/DSH-better-sidebar/pull/164)): when a previewed md file contains mermaid fences, a `client-mermaid.js` chunk (~7MB) is served on demand (zero load without mermaid); defense-in-depth rendering — `securityLevel: 'strict'` + `htmlLabels: false` (node labels use real SVG `<text>`) + a second sanitize pass before SVG injection (foreignObject/script/foreign HTML elements removed, `@*`/`on*`/`href` attributes stripped); click a diagram to zoom in a modal overlay (wheel zoom centered on the cursor, drag pan, toolbar & shortcuts), re-renders with light/dark theme, falls back to the raw code block on parse failure
|
||||
- 🖥️ **Configurable terminal shell & shellArgs** ([#125](https://github.com/omdsh-dev/DSH-better-sidebar/pull/125)): `cordis.patch.yml` `better-sidebar.config` can set `shell` / `shellArgs` (a non-empty `shellArgs` fully replaces the defaults; unset keeps the previous auto-resolution of `$SHELL` / login shell / `powershell.exe`), applied to both UI terminals and agent terminals (`terminal_create`); terminal tab titles now show the shell name (bash/zsh/powershell) and internal tab ids use UUIDs so the same shell can open multiple terminals
|
||||
|
||||
**🐛 Fixes**
|
||||
|
||||
- 🔗 **Aggregate double-mount auto-yield** ([#200](https://github.com/omdsh-dev/DSH-better-sidebar/pull/200)): when an aggregate package (e.g. dsh-web-ui-all) mounts the same package under its own entry id, the guard expression in `cordis.patch.yml` disables the plugin's own `better-sidebar` row so `/sidebar/api` is no longer registered twice (`duplicate prefix route` crashing the whole plugin tree / `dsh web`); standalone installs behave as before
|
||||
- 🔧 **Peer dependencies aligned to DSH 0.1.0-rc.7** ([#207](https://github.com/omdsh-dev/DSH-better-sidebar/pull/207), fixes [#206](https://github.com/omdsh-dev/DSH-better-sidebar/issues/206)): all `@deepseek-ai/*` peer/devDependencies raised from `^0.1.0-rc.6` to `^0.1.0-rc.7`, and the CI mount lane pins the same version — eliminating the mixed rc.6/rc.7 dependency tree (after the main framework moved to rc.7) that surfaced as `agent-presets: refusing to compose an unscoped context` when picking a model / sending a message
|
||||
|
||||
### v0.13.0
|
||||
|
||||
**✨ New features**
|
||||
|
||||
- 📁 **Files window merged with the explorer** ([#151](https://github.com/omdsh-dev/DSH-better-sidebar/pull/151)): new `editorExplorer` setting (editor card gear) — file tabs gain a path-input header plus a toggleable right-docked file tree (per-tab open/width memory, drag-resize 160–480px from the left edge, global filename search via the host `fs.search` route with a hard budget, skipping `.git` and symlink dirs); in separate mode (default) tree clicks / Enter in the path input open each file **in its own new tab**, merged mode switches the current tab **in place**; fresh sessions seed an empty Files window instead of the explorer tab, and a path-less window is a bare file manager in separate mode / a chrome'd empty file window in merged mode; the tree context menu offers "Open in new tab" and "Open to the side" (split)
|
||||
- 🎛️ **Select rows for declarative settings** ([#151](https://github.com/omdsh-dev/DSH-better-sidebar/pull/151)): settings rows gain `type: 'select'` (`options` with value/title/desc/icon, `multi` stores the picked values as an array); options with icons render big-icon option cards and keep the icon in the closed anchor; `editorExplorer` became an iconed select (merged vs separate); the capability list gained `settingSelect`
|
||||
- 🔀 **Mutual exclusion with the dsh-web-ui family right panel** ([#181](https://github.com/omdsh-dev/DSH-better-sidebar/pull/181)): 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
|
||||
|
||||
**📝 Other**
|
||||
|
||||
- Install / update commands unified to `dsh-better-sidebar@latest` (bilingual README sync)
|
||||
|
||||
### v0.12.3
|
||||
|
||||
**✨ 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
|
||||
|
||||
**Prerequisites**: DSH installed (`dsh web` boots), Node.js ≥ 20, pnpm ≥ 10.
|
||||
|
||||
**macOS / Linux** (also works in Git Bash / WSL on Windows):
|
||||
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
**Windows (PowerShell 5.1+ / pwsh)**:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1 | iex
|
||||
dsh plugin --profile web add dsh-better-sidebar@latest
|
||||
```
|
||||
|
||||
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.1 --restart
|
||||
|
||||
# Windows PowerShell
|
||||
& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.12.1 -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.1)
|
||||
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.1` and mounts via `dsh.bundle.patch` (the shipped `cordis.patch.yml`), so the DSH source is never modified.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Updating</b></summary>
|
||||
|
||||
```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.1"`) 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>
|
||||
|
||||
@@ -148,12 +156,13 @@ or re-run the one-click script; or bump the version in `~/.dsh/profiles/web/pack
|
||||
|
||||
| Symptom | Cause & fix |
|
||||
|---|---|
|
||||
| `Ignored build scripts` | pnpm 11 blocked build scripts. Run `pnpm approve-builds --all` (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`); 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`). |
|
||||
| "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. Old hand-written line: `~/.dsh/profiles/web/cordis.patch.yml` still has `- insert: ... better-sidebar ...` — delete it (a same-id duplicate mount makes the loader fail loudly with `duplicate loader entry id`). When an aggregate bundle (e.g. `@linxin666/dsh-web-ui-all`) mounts this package under a **different** id, the plugin's own bundle patch backs off automatically since 0.13.x (it detects an already-enabled mount of the same package name and does not mount itself) — no manual fix needed; if it still double-mounts, make sure the aggregate bundle precedes `dsh-better-sidebar` in `dsh.profile.bundles`. |
|
||||
| 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>
|
||||
|
||||
@@ -166,15 +175,20 @@ To debug local changes or track the dev branch, point the dependency at a local
|
||||
1. git clone https://github.com/omdsh-dev/DSH-better-sidebar.git ~/Code/DSH-better-sidebar
|
||||
cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build
|
||||
2. In ~/.dsh/profiles/web/package.json dependencies write "dsh-better-sidebar": "link:<absolute path of the clone>"
|
||||
3. Append this mount line to ~/.dsh/profiles/web/cordis.patch.yml:
|
||||
3. Append this mount line to ~/.dsh/profiles/web/cordis.patch.yml (to pick the terminal shell, add `config.shell`; `config.shellArgs` starts it with explicit args — when non-empty they replace the default `-l`. When omitted the host resolves `$SHELL` / the login shell / powershell.exe):
|
||||
- insert:
|
||||
- id: better-sidebar
|
||||
name: 'dsh-better-sidebar'
|
||||
config:
|
||||
shell: /bin/zsh
|
||||
shellArgs:
|
||||
- --noprofile
|
||||
- --no-rc
|
||||
4. Run pnpm install in ~/.dsh/profiles/web
|
||||
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.1"` 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>
|
||||
|
||||
@@ -208,7 +222,7 @@ Update: `git pull && pnpm install && pnpm build` → `node scripts/package-regis
|
||||
|
||||
## 🔌 Service: register tabs & file viewers
|
||||
|
||||
Since v0.4.0 the plugin exposes the `ctx.betterSidebar` service — other plugins can register sidebar pages and file viewers (the 7 built-in tabs + 9 viewers go through the same service, eating our own dog food):
|
||||
Since v0.4.0 the plugin exposes the `ctx.betterSidebar` service — other plugins can register sidebar pages and file viewers (the 7 built-in tabs + 6 viewers register through the same service):
|
||||
|
||||
```ts
|
||||
import type {} from 'dsh-better-sidebar' // triggers the ctx.betterSidebar type merge
|
||||
@@ -235,7 +249,7 @@ The dashed cards at the end of the "Sidebar content" / "File viewers" grids in t
|
||||
## 🛠️ Development & Build
|
||||
|
||||
```sh
|
||||
pnpm install # @deepseek-ai/* resolved from npm (^0.1.0-rc.6, published) — no token needed
|
||||
pnpm install # @deepseek-ai/* resolved from npm (^0.1.0-rc.8, published) — no token needed
|
||||
pnpm typecheck # tsc --noEmit
|
||||
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
|
||||
pnpm test # vitest (includes manifest consistency guard; build first)
|
||||
@@ -268,3 +282,4 @@ Windows / Linux / macOS (macOS validated daily; the rest covered by unit tests);
|
||||
- [dsh-tianshu-tui](https://github.com/huiliyi37/dsh-tianshu-tui): an interactive terminal UI plugin for DeepSeek Harness (its rendering core evolved from the self-developed harness agent Tianshu-Tui), adding TDD and evidence-gate workflows on top of the official harness
|
||||
- [dsh-TUI](https://github.com/ccch1mneyyy/dsh-TUI): a Claude Code-style fullscreen interactive TUI plugin — pixel-whale top bar, live working-status row, streaming thought expansion, double-Esc rollback, context progress bar + TPS meter; one-command npm install
|
||||
- [dshfind Plugin Market](https://dshfind.com/zh/plugins): a third-party plugin marketplace — a listing of public repos under the GitHub topic `dsh-plugin`, with stars, contributors and growth data synced daily
|
||||
- [DeepSeek Harness Desktop](https://github.com/anywhere-labs/deepseek-harness-desktop): a modern desktop client for the DeepSeek Harness ecosystem — start and manage a local Harness service without configuring Node.js or running commands; [official site](https://www.dshdesktop.cn)
|
||||
|
||||
@@ -14,6 +14,36 @@
|
||||
# If the profile still carries the old manual mount line in its own
|
||||
# cordis.patch.yml, remove it before switching to the bundle channel to
|
||||
# avoid double-mounting (two Node halves, two sidebars).
|
||||
#
|
||||
# To choose the terminal shell, add `config.shell` to the inserted plugin
|
||||
# row (or to the profile's manual cordis.patch.yml mount line). When omitted,
|
||||
# the host keeps resolving $SHELL / the login shell / powershell.exe.
|
||||
# `config.shellArgs` optionally supplies explicit startup arguments; when
|
||||
# non-empty they replace the automatic POSIX `-l` login flag:
|
||||
#
|
||||
# - insert:
|
||||
# - id: better-sidebar
|
||||
# name: 'dsh-better-sidebar'
|
||||
# config:
|
||||
# shell: /bin/zsh
|
||||
# shellArgs:
|
||||
# - --noprofile
|
||||
# - --no-rc
|
||||
#
|
||||
# Aggregate double-mount guard: aggregate bundles (e.g. @linxin666/dsh-web-ui-all)
|
||||
# may already mount this package under their own entry id (e.g.
|
||||
# `web-ui-better-sidebar`). Two mounts register /sidebar/api twice and fail the
|
||||
# whole plugin tree at boot ("duplicate prefix route"). The `!!js` disabled
|
||||
# expression backs THIS row off when another *enabled* entry already mounts
|
||||
# `dsh-better-sidebar`; the aggregate instance then owns the sidebar. The
|
||||
# loader evaluates it when this row is processed (entry-list order, only rows
|
||||
# before this one are visible), so the aggregate bundle must precede this one
|
||||
# in `dsh.profile.bundles` — the standard install order, since `dsh plugin add`
|
||||
# appends new bundles last. The reverse order (this bundle before the
|
||||
# aggregate) is a known limitation: the guard cannot see rows that come after
|
||||
# it, and no runtime singleton guard is added. Same-id manual duplicates still
|
||||
# fail loudly at the loader as before.
|
||||
- insert:
|
||||
- id: better-sidebar
|
||||
name: 'dsh-better-sidebar'
|
||||
disabled: !!js "[...ctx.loader.entries()].some((e) => e.options.name === 'dsh-better-sidebar' && e.options.id !== 'better-sidebar' && !e.disabled)"
|
||||
|
||||
@@ -40,7 +40,7 @@ import type {} from 'dsh-better-sidebar' // 触发 declare module 'cordis' 类
|
||||
{
|
||||
"name": "my-plugin",
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"cordis": "^4.0.0-rc.8",
|
||||
"dsh-better-sidebar": "workspace:*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -480,7 +480,7 @@ const { value } = await res.json() // 错误时 { ok: false, error: { code, me
|
||||
| 方法 | 说明 |
|
||||
|---|---|
|
||||
| `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.write` | 原子写文件 |
|
||||
| `git.status` / `git.diff` / `git.log` 等 | 全套 Git 只读 + 写操作 |
|
||||
@@ -690,7 +690,7 @@ ctx.effect(() =>
|
||||
"./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }
|
||||
},
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"cordis": "^4.0.0-rc.8",
|
||||
"dsh-better-sidebar": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"react": "^18.2.0"
|
||||
@@ -783,4 +783,6 @@ better-sidebar 的内置 tab 和 viewer 就是参考实现("吃狗粮"):
|
||||
- **类型处理**:未 value-import `dsh-better-sidebar`,构建零耦合;与 §2 的 `import type {}` 方案可互换;
|
||||
- **实测**:v0.3.0 起,真实 web profile 验证通过。
|
||||
|
||||
通过 `ctx.betterSidebar` 的三方插件[dsh-sidebar-qa](https://github.com/ChenRuoT/dsh-sidebar-qa) —— 基于 better-sidebar 的划选提问。tab分页: 对话划选 → 右侧面板提问 → 同工作区独立追问会话(❓追问·主题):快速无思考模型压缩主对话上下文后与引文一起注入,不打断主对话;追问可嵌套、可继续、可归档
|
||||
|
||||
更多插件接入后欢迎在此登记(一句话 + 链接)。
|
||||
|
||||
@@ -260,7 +260,9 @@ if (officeKind === 'docx' || officeKind === '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 合计)
|
||||
|
||||
**不放 `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 调整
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ URL 采用**路径编码**(无 query)——WHATSG URL 解析对纯路径相
|
||||
- 设计初期考虑过 srcdoc + `<base>` 注入以实现"预览反映未保存草稿",因 srcdoc 无沙箱时继承父源的致命风险与 base 注入的脆弱性,改采 route-src(预览 = 已保存文件);相对资源经路径编码 URL 天然解析,无需内容改写。
|
||||
- `normalizeBrowserUrl` 的 scheme 判别:朴素正则会把 `example.com:8080` 误判为 scheme,最终采用「`://` 完整 URL + 危险 scheme 黑名单 + 解析后协议兜底」三段式。
|
||||
- `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 内)
|
||||
|
||||
|
||||
@@ -267,7 +267,7 @@ return <viewer.component {...props} />
|
||||
```jsonc
|
||||
{
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"cordis": "^4.0.0-rc.8",
|
||||
"dsh-better-sidebar": "workspace:*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
|
||||
@@ -46,6 +46,7 @@ chunk 脚本**不经过** `window.__ModuleLoader__` / `ClientModuleSystem.import
|
||||
| `pptx` | `src/client/chunks/pptx.tsx` | `PptxView` + pptx-renderer | lib/client-pptx.js(~2.4MB) | 打开 .pptx |
|
||||
| `terminal` | `src/client/chunks/terminal.tsx` | `TerminalView` + xterm + addon-fit + xterm.css | lib/client-terminal.js(~450KB) | 打开终端 tab |
|
||||
| `editor` | `src/client/chunks/editor.tsx` | `TextEditor` + @codemirror/* + lang.ts + cm-themes | lib/client-editor.js(~1.6MB) | 打开 code/md/html 文件 |
|
||||
| `mermaid` | `src/client/chunks/mermaid.tsx` | `MermaidBlocks` + mermaid + d3/dagre/cytoscape | lib/client-mermaid.js(~7MB) | 预览含 mermaid fence 的 md |
|
||||
| 核心 | `src/client/index.tsx` | 其余全部 | lib/client.js(~325KB) | 启动 |
|
||||
|
||||
关键拆分:**`office-view.tsx` 拆为 `docx-view.tsx` + `xlsx-view.tsx` + `office-shared.tsx`**(共享 `LoadState`/`BinaryFallback`)。若不拆,docx chunk 会经共享文件静态拖入 `xlsx-to-univer` → Univer 14.5MB。原路径 `office-view.tsx` 保留为 re-export 垫片,兼容 `./src/*` 深导入。
|
||||
|
||||
@@ -46,6 +46,8 @@
|
||||
- 插件侧不可消除:不改 DSH 源码、不 patch node-pty 依赖是仓库硬约束(见 AGENTS.md §0),且 `IPty.kill()` 是 node-pty 的唯一 kill 通道;
|
||||
- 建议:保持现状(无功能影响),上游如提供无控制台规避选项再跟进;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. 验证
|
||||
|
||||
- 全量测试:`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 不依赖具体颜色)。
|
||||
@@ -0,0 +1,89 @@
|
||||
# 外链点击路由到插件 tab(urlTarget)+ 浏览器 HTTP/HTTPS 二级设置
|
||||
|
||||
**日期**:2026-08-15
|
||||
**状态**:已实施(本文档含实施偏差记录)
|
||||
**目标版本**:v0.13.0(API 兼容层,版本号未随本变更 bump,见 §7)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
基于 [PR #74](https://github.com/omdsh-dev/DSH-better-sidebar/pull/74)(聊天区外链开关颗粒度讨论)向前兼容地拓展服务化 API:
|
||||
|
||||
1. **插件声明「点击打开的类型」**:`TabDescriptor` 新增可选 `urlTarget?: (url: URL) => boolean`——聊天区外链被拦截时,路由到第一个声明的、命中的、未被设置禁用的插件 tab 类型(`openTab({ type, url, title: hostname })`,URL 即全部载荷),内置浏览器 tab 退居隐式兜底。
|
||||
2. **浏览器链接设置改为二级菜单**(浏览器卡片齿轮弹窗内):
|
||||
- 侧边打开 HTTP 网页(默认开)
|
||||
- 侧边打开 HTTPS 网页(默认关)
|
||||
- `browserInterceptLinks` 保留为**总闸**(默认开,零迁移:旧文档显式 false 语义完整保留)。
|
||||
|
||||
## 2. 非目标
|
||||
|
||||
- 不实施 PR #74 的「外链开关提升到常规区」(用户确认:二级设置留在浏览器卡片齿轮弹窗,常规区不加行)。
|
||||
- 不做 per-host / per-domain 路由规则(只有 per-protocol 开关 + 插件谓词)。
|
||||
- 不改 + 菜单、不改手动打开的浏览器 tab、不改同源/修饰键点击放行规则。
|
||||
- 不改 host 半逻辑(仅 PrefsSchema 加字段);不修改 DSH 源码。
|
||||
- 不做版本号 bump(发布时决定;向前兼容机制 = `SIDEBAR_FEATURES` 追加 `'urlTarget'`)。
|
||||
|
||||
## 3. 现状回顾
|
||||
|
||||
- `link-intercept.ts` 的 `registerLinkInterception` 无参 `takeoverEnabled()` 只做布尔门;`index.tsx` 硬编码 `openTab({ type: 'browser', url, title: hostname })`。
|
||||
- `browserInterceptLinks`(默认 true)与 `browserNoSandbox` 并列于浏览器卡片的 `settings.toggles`;拦截 gating = 该 pref && `tabsEnabled['browser']`。
|
||||
- `openTab` seed 已有 `url` 字段(新建 tab 的 path 预填),插件 tab 无需新渲染管道。
|
||||
|
||||
## 4. 设计
|
||||
|
||||
### 4.1 Prefs(`prefs-shared.ts` / `config.ts` / `client/prefs.ts`)
|
||||
|
||||
```ts
|
||||
interface SidebarPrefs {
|
||||
// …
|
||||
browserInterceptLinks: boolean // 总闸,默认 true(保留,零迁移)
|
||||
browserInterceptHttp: boolean // HTTP 外链侧边栏接管,默认 true
|
||||
browserInterceptHttps: boolean // HTTPS 外链侧边栏接管,默认 false
|
||||
}
|
||||
```
|
||||
|
||||
三处同步维护(类型 + schemastery 默认 + parsePrefs 校验),旧文档无需迁移:总闸保留使显式关闭过的用户升级后仍不拦截。
|
||||
|
||||
### 4.2 服务 API(`service.ts`)
|
||||
|
||||
```ts
|
||||
interface TabDescriptor {
|
||||
// …
|
||||
urlTarget?: (url: URL) => boolean
|
||||
}
|
||||
|
||||
/** 注册顺序先到先得;谓词抛错吞掉(console.error)继续;返回 undefined 由调用方回退 browser。 */
|
||||
export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDescriptor | undefined
|
||||
```
|
||||
|
||||
- 匹配函数为**模块级纯函数**(不进 `BetterSidebarService` 接口,KISS:不扩公开面);启用态过滤由调用方(`index.tsx`)按 `tabsEnabled` 过滤后传入。
|
||||
- 内置 browser 不声明 `urlTarget`,永远不可能遮蔽插件声明;插件多 URL 并存需用 `createTab` 铸造 per-URL id(browser 同款模式),否则 id 安全网聚焦既有 tab 不覆写 path。
|
||||
- `SIDEBAR_FEATURES` 追加 `'urlTarget'`(消费者按能力 gate)。
|
||||
|
||||
### 4.3 拦截管线(`link-intercept.ts` / `index.tsx`)
|
||||
|
||||
- `shouldInterceptLink` 签名不变(协议/同源纯判定);`takeoverEnabled` 改为 `(url: URL) => boolean`,onClick 顺序:纯判定 → gate(持 URL)→ preventDefault → open。
|
||||
- `index.tsx` gate:总闸 `browserInterceptLinks` → 协议开关(https 读 `browserInterceptHttps`,否则读 `browserInterceptHttp`)→ 目标可开(插件命中即目标,否则 browser 的 `tabsEnabled`)。open 时再次解析目标并 `openTab({ type, url, title: hostname })`。
|
||||
|
||||
### 4.4 设置 UI(`builtins/tabs.tsx` / `locales.ts`)
|
||||
|
||||
浏览器卡片 `settings.toggles` 收敛为四行:`browserNoSandbox` / `browserInterceptLinks`(总闸)/ `browserInterceptHttp` / `browserInterceptHttps`;新增 zh/en 文案(HTTP 默认开、HTTPS 默认关及理由)。
|
||||
|
||||
## 5. 行为变化
|
||||
|
||||
- 默认设置下:HTTP 外链 → 侧边栏(不变);**HTTPS 外链 → 系统浏览器新窗口(变化,符合「默认关闭」)**。
|
||||
- 声明 `urlTarget` 的插件 tab 认领被拦截的链接(优先于浏览器兜底);插件被禁用/谓词抛错时跳过。
|
||||
- 总闸关闭 → 一律不拦截(旧文档语义保留)。
|
||||
|
||||
## 6. 测试
|
||||
|
||||
- `tests/builtins.spec.ts`:浏览器 toggles 断言收敛为四行 + 各行 title/desc 存在。
|
||||
- `tests/service.spec.ts`:`matchUrlTarget`(无声明 → undefined / 注册顺序 / 抛错吞掉 / browser 永不命中)。
|
||||
- `tests/link-intercept.spec.ts`(新增 jsdom):`takeoverEnabled(url)` 收到解析后的 URL、gate 决定是否接管、同源/非 http(s) 不 consult gate、修饰键绕过。
|
||||
- `tests/unit.spec.ts`:parsePrefs 新字段默认值(http true / https false / 非法回退);总闸与协议开关相互独立。
|
||||
- `tests/plugin-shape.spec.ts` / `tests/smoke.spec.ts`:全默认字面量补两个字段。
|
||||
|
||||
## 7. 实施偏差记录
|
||||
|
||||
- 计划曾考虑把 `matchUrlTarget` 作为 `BetterSidebarService` 接口方法(镜像 `matchFileViewer`);按 KISS 收敛为**模块级导出纯函数**,启用态过滤由调用方完成(匹配函数不感知 prefs),公开 API 面只剩 `urlTarget` 字段 + `'urlTarget'` feature 字符串。
|
||||
- 版本号未 bump(保持 0.12.1):`SIDEBAR_SERVICE_VERSION` 与 `package.json` 的配对断言继续成立;`'urlTarget'` feature 已入 `SIDEBAR_FEATURES`,发布时再决定版本。
|
||||
- PR #74 的 diff(常规区移动 + 其测试改动)被本方案取代,未合入。
|
||||
@@ -0,0 +1,82 @@
|
||||
# Markdown 预览 Mermaid 安全渲染设计
|
||||
|
||||
> 日期:2026-08-17
|
||||
> 状态:已实现
|
||||
> 范围:`dsh-better-sidebar` 插件,Markdown 预览 + 新 `mermaid` 懒加载 chunk
|
||||
> 基线:社区 PR #99(`feat/mermaid-md-preview`)的架构与安全姿势;借鉴 PR #75(`feat/mermaid-rendering`)的点击放大交互
|
||||
|
||||
## 1. 背景与问题
|
||||
|
||||
Markdown 预览(`TextEditor.tsx` 的 preview 模式)直接把全文交给 DSH 的 `MarkdownText`(`@deepseek-ai/dsh-client-ui-primitives`)渲染。该渲染器的 fenced code 一律走 `CodeBlock`(Shiki 高亮 + 复制按钮),且**不支持自定义 fence 渲染器**——```` ```mermaid ```` 块只会显示成一段无高亮的代码,不会渲染成图(DSH 宿主内 `mermaid` 字样出现 0 次,Shiki 别名表也无此语言)。
|
||||
|
||||
社区出现过两个 mermaid PR,安全姿势差异极大:
|
||||
|
||||
| | PR #75 | PR #99 |
|
||||
|---|---|---|
|
||||
| 加载 | 运行时 jsdelivr CDN(第三方供应链、离线不可用、无版本锁) | 自有懒加载 chunk(mermaid 11.16.1 本地打包,`/sidebar/bundle` 同源下发) |
|
||||
| 安全 | `securityLevel: 'loose'`(标签可注入原始 HTML)+ 无清洗 + `innerHTML` 直插 | `securityLevel: 'strict'` + `htmlLabels: false` + SVG 二次清洗 + 不应用 `bindFunctions` |
|
||||
| 测试/文档 | 无 | 单测 + e2e + 设计文档 |
|
||||
| 亮点 | 点击放大弹窗(滚轮缩放/拖拽/快捷键) | 主题跟随、错误回退、CodeBlock 同款 chrome |
|
||||
|
||||
**结论:以 #99 为基线**。md 文件来自工作区(可能来自克隆仓库/下载),不可信;`loose` + 无清洗在 DSH GUI origin 下就是 XSS(可读会话数据)。**仅借鉴 #75 的点击放大交互**,在 #99 的已清洗 SVG 之上用 React 重写(克隆节点无事件面,弹窗不新增攻击面)。本地图片引用重写(#99 顺带功能)不在本 PR 范围,另行独立 PR。
|
||||
|
||||
## 2. 方案
|
||||
|
||||
### 2.1 检测:纯函数按 fence 切块
|
||||
|
||||
新增 `src/client/mermaid-blocks.ts`(`splitMermaidBlocks`,纯函数、无依赖),职责是**检测**预览是否需要 mermaid chunk:
|
||||
|
||||
- 逐行扫描,命中 info string 为 `mermaid` 的围栏(大小写不敏感,支持 `mermaid{...}` 属性后缀,缩进 ≤3 空格,CommonMark 语义)即"提出"为 `{ kind: 'mermaid', code }` 块;
|
||||
- 其余行原样保留在 `{ kind: 'markdown', text }` 块中(含非 mermaid 围栏);
|
||||
- 未闭合的 mermaid fence 吞掉文件剩余部分(与 CommonMark 对开 fence 的恢复一致);
|
||||
- **CommonMark fence 规则完整**:开 fence 为 3+ 反引号**或波浪号**;闭 fence 必须同字符且长度 ≥ 开 fence;反引号 fence 的 info string 含反引号时该行不是开 fence。
|
||||
|
||||
`TextEditor` 预览时先 `splitMermaidBlocks`,**无 mermaid 块则走原 `MarkdownText` 直渲路径(零行为变化)**;有则渲染 `MermaidMarkdown`(mermaid chunk 内)。
|
||||
|
||||
### 2.2 渲染架构:单次解析 + 占位替换(CR 第 3 轮 P1 修正)
|
||||
|
||||
早期方案把文档切成多个独立 `MarkdownText` 片段,会破坏跨 fence 的 Markdown 语义(引用式链接定义在 fence 之后、脚注、有序列表连续性)。当前实现:
|
||||
|
||||
- **整篇文档只走一次 `MarkdownText`**(`MermaidMarkdown`),跨 fence 语义完整;
|
||||
- 布局阶段(`useLayoutEffect`)扫描容器内 `.md-code-block`,识别 mermaid 块(双通道:shiki 路径的 `code.language-mermaid` 类,或 plain 路径的 banner infostring 文本 === `mermaid`——mermaid 无 shiki 语法必走 plain,语言只显示在 infostring 里),用 `createRoot` 把 `MermaidDiagram` 挂进替换后的子节点;**React 管理的 `.md-code-block` 宿主节点保留在树中**(仅替换其 children,CSS `display: contents` 抑制代码块自身盒子),React 协调不会丢失宿主;
|
||||
- 块内容变化(编辑后重新预览)按源码 diff 重渲;块不再是 mermaid fence 时还原原 children(普通代码块恢复显示);fence 消失时卸载对应 root;
|
||||
- DSH `MarkdownText` 无代码块渲染扩展入口(且仓库硬约束禁止改 DSH),这是约束内的最小侵入方案。
|
||||
|
||||
### 2.2 懒加载 chunk:`mermaid`
|
||||
|
||||
mermaid 及其图依赖(d3 / dagre-d3-es / cytoscape 等)打包进新的 `lib/client-mermaid.js`(~7MB),复用既有 chunk 机制(tsdown `CHUNKS` + `/sidebar/bundle` 路由 + `globalThis.__dshChunks__` 注册表 + `lazyChunkComponent` 懒包装),仅当预览的 md 文件实际含 mermaid fence 时才下载/执行。chunk 名在 4 处同步:`tsdown.config.ts` `CHUNKS`、`src/bundle-route.ts` `CHUNK_NAMES`、`src/client/chunk-loader.ts` `ChunkName`、`package.json` `files`(及各 spec 的硬编码清单)。构建侧对 mermaid chunk 注入 `mermaidChunkAliases`:把裸 `uuid` 说明符解析到浏览器入口(`uuid/dist/index.js`,Web Crypto 实现),绕过其 node 入口的 `node:crypto` 触发客户端纯度门。
|
||||
|
||||
### 2.3 渲染与安全(纵深防御)
|
||||
|
||||
- `mermaid.render(id, code)` 客户端渲染 → SVG 字符串注入;每个渲染调用用单调递增 id(文档内唯一);
|
||||
- `mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', htmlLabels: false, theme })`——strict 下标签被转义、`click` 指令失效;`htmlLabels: false` 强制节点文字走真实 SVG `<text>`(默认的 `foreignObject` HTML 标签通道会被清洗器整体删除,导致文字丢失),同时把 HTML 标签注入面关死;`bindFunctions` **不**应用(静态图,无点击交互);订阅 `subscribeColorScheme`,深浅色切换自动按新主题重渲;
|
||||
- **SVG 注入前二次清洗**(`src/client/mermaid-sanitize.ts`,独立纯函数、可单测):`DOMParser`(`image/svg+xml`)解析,删除 `foreignObject` 与 `script`/外来 HTML 元素(`img`/`iframe`/`object`/`embed`/`video`/`audio`/`input`/`button`/`form`/`link`/`meta`/`base`),剥离 `@*`/`on*` 属性,**移除全部 `href`/`xlink:href`**(静态图不需要链接,杜绝 javascript:/data: 乃至普通外链把整个 GUI 导航走);仅接受根元素为 `<svg>` 的文档;解析失败(parsererror/非 SVG 根)返回空串,调用方显示错误回退,**原始字符串永不过清洗器**;
|
||||
- 渲染失败(语法错误/清洗拒绝)展示错误条 + 原始代码块(便于对照修改),错误全文挂 tooltip;空 fence 不渲染;
|
||||
- 块头保留与 `CodeBlock` 一致的 chrome:info string「mermaid」+ 复制按钮(`writeClipboard`,沿用 `copy`/`copied` 字典)。块体带稳定锚点 `data-mermaid-diagram`(e2e 断言用)。
|
||||
|
||||
### 2.4 点击放大弹窗(借鉴 PR #75,React 重写)
|
||||
|
||||
点击 `[data-mermaid-diagram]` 内 SVG → 克隆节点(**已清洗**,无事件属性)→ portalled fixed overlay(`data-mermaid-modal`):
|
||||
|
||||
- 滚轮缩放(以鼠标位置为不动点,被动标志关闭防止页面滚动);
|
||||
- 拖拽平移(mousedown/mousemove/mouseup on window);
|
||||
- 工具栏按钮:− / + / ⟳ / ✕(i18n title);
|
||||
- 键盘:`+`/`=` 放大、`-` 缩小、`0` 重置、`Esc` 关闭;
|
||||
- 背景点击关闭;卸载时移除全部监听;
|
||||
- 样式全部走 CSS Module + DSH 令牌(遮罩 `--dsw-alias-bg-mask-1` + `blur(2px)`,与 DSH `Modal` 一致;z-index 1000 落在 DSH 浮层栈)。
|
||||
|
||||
## 3. 验证
|
||||
|
||||
- 单测:`tests/mermaid-blocks.spec.ts`(14 例:fence 识别大小写/属性/缩进、4 反引号/波浪号、同字符且不短于开 fence 的闭 fence、非 mermaid 围栏不动、交错顺序、开 fence 恢复、空文件);`tests/mermaid-sanitize.spec.ts`(10 例 XSS:foreignObject/script/外来元素整节点剥离(含混合大小写元素名与属性名)、`on*`/`@*` 剥离、href/xlink:href 全删、畸形 XML → 空串、非 SVG 根 → 空串、良性图保真);`tests/mermaid-markdown.spec.tsx`(3 例架构回归:mermaid 块被交换为图、**跨 fence 引用式链接在单次解析下解析**、非 mermaid 围栏不动);
|
||||
- 产物契约:`chunk-artifact.spec.ts`(mermaid chunk 执行 + registry 槽位 + 平台 externals require,`addEventListener` stub)/ `manifest-consistency.spec.ts` / `bundle-route.spec.ts` 的 chunk 清单同步加入 `mermaid`;
|
||||
- 挂载冒烟:`tests/e2e/mount.e2e.ts` 新增 seed `diagram.md`,打开后强制 `client-mermaid.js` 往返、断言 `[data-mermaid-diagram] svg` 含节点文字(真实 `<text>`)、**跨 fence 引用式链接解析为真实锚点(单次解析证明)**、点击弹窗出现且 Esc 关闭、预览模式编辑器隐藏;缺 chunk、文字丢失、语义破坏、弹窗失效任一即红;
|
||||
- 现有 `markdown-copy-labels.spec.tsx` 不回归(无 mermaid 文件走原路径)。
|
||||
|
||||
## 4. 风险与取舍
|
||||
|
||||
- 编辑器 chunk 之外新增 ~7MB 懒 chunk(mermaid + d3/dagre/cytoscape/katex 图依赖);仅预览含 mermaid fence 的 md 时下载,启动与普通文本打开路径零成本;
|
||||
- `splitMermaidBlocks` 对"mermaid 围栏嵌在其他代码块内"的极边缘情况会误提(只影响是否走 mermaid 路径,不影响渲染正确性)——预览级工具接受该取舍(与常见 md 渲染器行为一致);
|
||||
- 占位替换依赖 DSH `MarkdownText` 的 DOM 输出结构(`.md-code-block` + `language-mermaid`):宿主节点保留在 React 树中、仅换 children,协调安全;若未来 DSH 渲染器结构变化,`data-mermaid-processed` 扫描点需要同步(有 e2e 兜底);
|
||||
- 清洗器删除全部 `href`/`xlink:href`:`<use href>` 类图标引用可能不显示(纯装饰性损失),换取确定性的无导航/无脚本面;
|
||||
- mermaid 官方核心包持续演化,锁 `^11.16.1`,升级靠 Renovate 常规流程;
|
||||
- 回滚:删除 chunk 三处声明 + `files` 条目 + `TextEditor` 分支即可回到纯代码块展示。
|
||||
@@ -0,0 +1,260 @@
|
||||
# 侧边栏面板注入方案设计:统一面板宿主(插件侧) + 官方槽位路线(上游侧)
|
||||
|
||||
**日期**:2026-08-19
|
||||
**状态**:评审中(设计文档先行,实现待批准)
|
||||
**作者**:DSH 工程模式会话
|
||||
**当前版本**:v0.13.0(main)
|
||||
**目标版本**:v0.13.x(不 bump 大版本)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
1. 解决侧边栏面板的**注入方法**问题:找到一个同时兼容「纯浏览器 / Electron 套壳(DSH Desktop)/ 各类 Tauri/WebView 套壳 / 移动 webview」且**性能良好**的统一注入方案。
|
||||
2. 消灭现有症状级补丁 PR 簇(#226 desktop 布局推挤、#135 dsh-web-mobile、#82/#108/#111/#138/#153 桌面标题栏)背后的结构性根因:**插件选择器深入宿主 DOM 形状(nth-child 锚点)+ 桌面壳依赖手动设置(titleBarCompat)+ 几何写路径分散(拖拽/状态两条)+ 懒加载缓存每次激活清空(重注入重执行)**。
|
||||
3. 探索并文档化「官方槽位路线」:参考 DSH 内置插件 `ui-sidebar`(官方左侧边栏本身就是以插件方式占用 `sidebar` 槽),评估外部插件接管官方面板槽的可行性与代价,作为中期北极星;上游未落地前以插件侧方案为主线。
|
||||
|
||||
## 2. 非目标(Out of Scope)
|
||||
|
||||
- **不修改 DSH 官方源码**(仓库硬约束,`~/.dsh/source/current` 零写入);官方槽位路线只产出 issue/设计建议给上游。
|
||||
- 不做 shadow DOM / iframe 全隔离注入(令牌穿透、事件与 portal 代价高,仅在宿主样式污染实测出现时作为兜底启用)。
|
||||
- 不重构 tab/viewer 扩展点本身(`ctx.betterSidebar` 服务契约不变)。
|
||||
- 不承诺禁用官方 `ui-sidebar` 的行为由插件自动完成——那是 **profile 层用户决策**,插件只文档化。
|
||||
- 不处理 #69(browser 探测,独立域)。
|
||||
|
||||
## 3. 已验证事实(证据先行,2026-08-19 核对)
|
||||
|
||||
### 3.1 当前注入实现(本仓库)
|
||||
|
||||
| 事实 | 证据 |
|
||||
|---|---|
|
||||
| 注入 = body 直下 `div[data-dsh-better-sidebar]` + **第二个 React root**(非 portal) | `src/client/index.tsx:120-141` |
|
||||
| 宿主推挤 = html 上 `--dsh-sidebar-width/height` + `#root{margin-right}` + **`#root > [data-slot=root] > div > div:nth-child(2)` 锚点** | `src/client/layout.css:22-32` |
|
||||
| 拖拽:pointer 事件 + rAF 单帧写面板尺寸与 html 变量(无强制 reflow 读) | `src/client/Sidebar.tsx:494-518` |
|
||||
| 懒加载 chunk:每次激活 `resetChunks()` 清内存缓存 → 下次懒打开**重注入+重执行**脚本;HTTP 层已有 ETag 304(**不重下载**) | `src/client/index.tsx:120` 注释、`chunk-loader.ts` |
|
||||
| 会话切换:只换 snapshot,面板 DOM 不重建(保持) | `Sidebar.tsx` store 驱动 |
|
||||
| **已具备(实施勿重做)**:拖拽 rAF 批处理(`scheduleDrag` 单帧一次 `applyDrag`,无强制 reflow 读);窄屏不推挤宿主(narrow 时宽/高变量写 0);`measureCenter` ResizeObserver 已 rAF 节流;header 推挤已用弱锚点 `[data-slot="conversation.session.header"]` | `Sidebar.tsx:368-424,487-533,563-585`、`layout.css:45` |
|
||||
|
||||
### 3.2 DSH 官方架构(`~/.dsh/source/current`,只读)
|
||||
|
||||
| 事实 | 证据 |
|
||||
|---|---|
|
||||
| 外壳 = `#root` 单挂载点 + `__DSH_BOOT__` 引导;真实 UI 树全挂在 `root` 槽 | `apps/web/index.html:10-12`、`packages/client/web/src/app.tsx:38-43` |
|
||||
| `ui-layout` 声明 `root` 槽的 children:`sidebar`/`conversation`/`details` 三个 **single 独占槽**;single 槽重复声明**抛错** | `ui-layout/src/client/index.ts:80-96`、`ui-slots/src/index.ts:747` |
|
||||
| **官方左侧边栏 = 内置插件 `ui-sidebar`**:注册 `name:'sidebar'`,渲染在 AppFrame 的 grid 列内;几何经 layout store → `gridTemplateColumns` 内联传导(非 CSS 变量);**官方不持久化宽度/折叠**;折叠按钮在 SidebarRoot 内自渲染并调 `ctx.layout.toggleSidebar()`;拖拽把手由 AppFrame 免费提供 | `ui-sidebar/src/client/index.ts:39`、`ui-layout/src/client/AppFrame.tsx:164-196` |
|
||||
| `details` 槽(右侧面板)被 `ui-conversation` 的 DetailsPanel 占用;禁 ui-conversation = 会话列整体空置,代价极高 | `ui-conversation/src/client/apply.ts:445-458` |
|
||||
| 官方浮层(Menu/Modal/Toast/HoverCard)全部 `createPortal(document.body)`——**body 级 portal 是官方姿势** | `ui-primitives/Menu.tsx:299`、`Modal.tsx:55-85` |
|
||||
| 槽位注入消费者经 `slots.inject` 注入;槽未声明时**静默跳过**(禁用 ui-sidebar 不会炸树,但接管者需自实现会话/设置入口) | `runtime/src/client/slots.ts:130-192` |
|
||||
| profile 可禁用内置插件行:`cordis.patch.yml` `- id: <plugin-id>` + `disabled: true`(皮肤 10 款禁用先例) | `~/.dsh/profiles/web/cordis.patch.yml` |
|
||||
| 性能参考:拖拽 rAF 合并最新 delta 单帧应用 + `[data-dragging]` 禁过渡 + 纯函数让步结算 + 「关闭=宽 0 保活不卸载」 | `AppFrame.tsx:59-62`、`AppFrame.module.css:8-17`、`columns.ts:62-77` |
|
||||
|
||||
### 3.4 实证核对(真实 dsh web 页面,Playwright 探针,2026-08-19)
|
||||
|
||||
对 http://127.0.0.1:3080/(本机 profile,better-sidebar 已挂载)实测 DOM 与推挤行为:
|
||||
|
||||
| 事实 | 实测结果 |
|
||||
|---|---|
|
||||
| 外壳 DOM 链 | `#root > div[data-slot="root"]`(**display:contents**)`> div[data-dsh-frame]`(grid,CSS module 哈希类名) |
|
||||
| frame 子项 | **5 个**:`[data-pane=sidebar]` / `[data-pane=conversation]` / `[data-pane=details]` / `[data-shell-overlay]`(浮层)/ 拖拽 handle——`nth-child(2)` 当前仍命中 centerCol,但**位置脆弱性实锤**(浮层/handle 已混入子项序列) |
|
||||
| 稳定锚点 | centerCol 带 `data-pane="conversation"`;frame 带 `data-dsh-frame` → 弱锚点 = `#root [data-dsh-frame] > [data-pane="conversation"]` |
|
||||
| ~~`[data-slot="conversation"]` 锚点候选~~ | **作废**:该元素 `display: contents`(无盒,margin 无效,rect 0×0) |
|
||||
| 推挤无溢出(web) | 设 `--dsh-sidebar-width:300px` 后:`#root` margin-right 生效(过渡中 92px),`scrollWidth == clientWidth == 1440`,**无水平溢出** → `width: calc(100% - var)` 改动在 web 无副作用;桌面壳溢出修复(#226 目标)保留并标注「web 已验证无害、Desktop 待实测」 |
|
||||
| 当前 host 本体 | `[data-dsh-better-sidebar]` 为 `position: static` 的普通块(fixed 在**内层面板**上)→ 新增 `data-dsh-panel-host` 固定含块层是宿主内新元素,外部契约不变 |
|
||||
| 页面错误 | 0 个 pageerror(探针期间) |
|
||||
|
||||
### 3.3 桌面套壳(本机 DSH Desktop.app,Electron)
|
||||
|
||||
| 事实 | 证据 |
|
||||
|---|---|
|
||||
| 渲染 URL 注入信号:`?dsh-desktop-mode=<compatibility\|advanced>&dsh-desktop-platform=<…>` | `app.asar.unpacked/lib/src-B5373ech.js` |
|
||||
| win32:`titleBarStyle:"hidden"` + `titleBarOverlay({height:32})`(右上角 32px 为窗口控制钮) | `electron-runtime-C_i38SKA.js:338-348` |
|
||||
| darwin:`titleBarStyle:"hiddenInset"` + `trafficLightPosition(16,16)`(左上红绿灯区 ~78px) | 同文件 `:325-333` |
|
||||
| 安全基线:`contextIsolation/sandbox:true`、`nodeIntegration:false`;preload 仅暴露 `__DSH_DESKTOP_FILE_PATH__` | `preload.cjs:5-9` |
|
||||
| 社区 Tauri 套壳(kyorakuyk/dsh-desktop、Lotus-c/DSH-Desktop)用**原生系统标题栏**,fixed 语义与纯浏览器一致 | 子代理调研(来源 URL 见 §10) |
|
||||
| 套壳最大注入风险:祖先 `transform/will-change` 劫持 fixed 包含块、标题栏 overlay 遮顶、drag 区吞事件、移动键盘盖 `bottom:0` | 子代理调研 |
|
||||
|
||||
## 4. 方案总览:双路线
|
||||
|
||||
```
|
||||
路线 A(主线,本仓库可控)┐
|
||||
统一面板宿主:body 固定含块包裹层 ├─ 现在 → v0.13.x(已实施,分支 feat/injection-unified-host)
|
||||
+ 桌面信号自动适配 + 几何写路径统一 │
|
||||
路线 B(已关闭)┘
|
||||
ui-sidebar 模式:占用官方槽位(grid 内渲染) ─ 2026-08-19 用户决策关闭:无法向官方提 issue,
|
||||
纯插件侧自己优化注入方式(调研结论保留于 §6)
|
||||
```
|
||||
|
||||
- **路线 A 已实施**:不依赖 DSH 新能力,纯插件侧,直接吸收桌面/移动/性能问题。
|
||||
- **路线 B 已关闭**(2026-08-19 用户决策):不提交上游 issue;§6 的槽位可行性调研保留为后续参考,但不再推进。
|
||||
|
||||
## 5. 路线 A 详细设计:统一面板宿主
|
||||
|
||||
### 5.1 挂载层(兼容性根因修复)
|
||||
|
||||
**A1.1 固定含块包裹层**:保留 body 级锚点(官方 portal 姿势),但结构升级为:
|
||||
|
||||
```
|
||||
document.body
|
||||
└─ div[data-dsh-better-sidebar] ← 唯一锚点(属性不变,兼容既有契约/皮肤)
|
||||
└─ div[data-dsh-panel-host] ← 新增:position:fixed; inset:0; pointer-events:none; z-index:40
|
||||
├─ 右侧面板(absolute; right:0; pointer-events:auto)
|
||||
└─ 底部面板(absolute; bottom:0; pointer-events:auto)
|
||||
```
|
||||
|
||||
- 面板在**自己的固定含块**内 absolute 定位 → 宿主中间层(套壳注入的 wrapper)transform 不再劫持面板 fixed 语义。
|
||||
- 折叠按钮簇 z-index 45 不变(skin 契约 §8 同步)。
|
||||
- **挂载自检(一次)**:挂载后对比 `getBoundingClientRect()` 与 `window.innerWidth/Height`;若 html/body 本身被套壳 transform/偏移(罕见),自动降级为「absolute + 每帧 rAF 同步宿主 rect」模式并 console.warn 一次。
|
||||
- **MutationObserver 守卫(默认开,可关)**:body childList 变化检测锚点被宿主清空/挪位时自动重挂(SPA 导航/套壳重排兜底);观察器廉价(仅 childList、无 subtree)。
|
||||
|
||||
**A1.2 桌面信号自动探测**(替代手动 titleBarCompat):
|
||||
|
||||
- 信号源:URL `dsh-desktop-mode` / `dsh-desktop-platform`(Electron 官方注入)+ `window.__DSH_DESKTOP_FILE_PATH__` 存在性(preload 标记)+ UA 兜底。
|
||||
- 适配表:
|
||||
|
||||
| 探测结果 | 自动适配 |
|
||||
|---|---|
|
||||
| win32 + advanced | 右上角保留 32px 标题栏 overlay 区:折叠按钮簇/设置按钮下移,面板顶部内边距 +32px |
|
||||
| darwin | 左上角保留 ~78px 红绿灯区:左侧贴边控件(若存在)避让 |
|
||||
| compatibility / 无信号 | 原生 frame,不避让(现状行为) |
|
||||
| 移动 webview | 窄屏 overlay 抽屉 + `visualViewport` 键盘监听 + `env(safe-area-inset-*)` 内边距 |
|
||||
|
||||
- 保留现有手动开关(titleBarCompat)作为覆盖位,但默认值改为「自动」。
|
||||
|
||||
**A1.3 移动端**:窄屏(断点沿用 `breakpoints.ts`)面板从「推挤宿主」切「overlay 抽屉」:不写 `#root` margin、面板 transform 滑入滑出;`visualViewport` 变化(键盘弹起)时底部面板上移。
|
||||
|
||||
### 5.2 几何层(性能)
|
||||
|
||||
**A2.1 单一几何写入器**:现状有两条写路径——拖拽的 `applyDrag`(Sidebar.tsx:487-505)与打开态 effect(:563-585),都写同一组变量但互不感知。统一为 `writeGeometry({width,height})` 单函数,两条路径共用(拖拽仍走 `scheduleDrag` 的 rAF 单帧批写);折叠/会话恢复等非拖拽变更也收敛到它。**不改变对外契约**:`--dsh-sidebar-width/height` 变量与 `#root` margin 推挤机制不变。
|
||||
|
||||
**A2.2 拖拽**(现状已达标,微调即可):
|
||||
|
||||
- pointer capture + 最新 delta + 单帧 rAF 应用(**已具备**,保留);
|
||||
- 拖拽期 `body[data-dsh-sidebar-dragging]` 禁过渡(**已具备**,`layout.css:49`);
|
||||
- 新增:拖拽期面板根挂 `will-change: transform`;面板根常驻 `contain: layout style`(CSS 一处,隔离内部布局回流)。
|
||||
|
||||
**A2.3 chunk 缓存精确化**:HTTP 层已 304 不重下载;缺口只在「激活后内存缓存清空 → 重注入+重执行」。改为:缓存键 = chunk 名 + 脚本内容哈希;激活时若哈希未变则保留内存缓存(跳过重注入),仅 HMR 核心 bundle 变化(版本变化)才清。保留失败路径(失败清缓存重试)。
|
||||
|
||||
**A2.4 保活策略(保持现状并文档化)**:面板 DOM 跨会话不重建;tab 内容按 `visible` prop 懒挂载/卸载;树/终端等重组件不随会话切换重挂。
|
||||
|
||||
### 5.3 契约层(可维护性)
|
||||
|
||||
- **锚点弱化**:`layout.css:31` 的 `#root > [data-slot=root] > div > div:nth-child(2)` 替换为 `#root [data-dsh-frame] > [data-pane="conversation"]`(稳定 data 属性锚定,实证见 §3.4;`[data-slot="conversation"]` 为 display:contents 不可用);推挤逻辑收敛进几何写入器(一处实现)。
|
||||
- **推挤可关闭**:`SidebarPrefs` 新增(或复用既有)「宿主推挤」开关,默认开;桌面套壳/移动端自动切 overlay 时关闭推挤(修 #226 根因:不再依赖 `#root{width:100%}` 之外的宿主形状)。
|
||||
- **契约文档化**:挂载点(`data-dsh-better-sidebar` / `data-dsh-panel-host`)、z-index 栈、CSS 变量(`--dsh-sidebar-width/height` 保持对外)、桌面信号(URL/全局标记)、推挤锚点策略 —— 同步 AGENTS.md §8 与本文档。
|
||||
- **服务契约不变**:`ctx.betterSidebar` API 零变化;#135 的 service 读取问题(dsh-web-mobile 下 `ctx.betterSidebar` 直接读抛错)由「注入层与服务的解耦」顺带收敛(面板渲染不再假设服务存在即可读)。
|
||||
|
||||
### 5.4 性能清单(验收口径 + 实测基线)
|
||||
|
||||
基线(2026-08-19 真实页面探针,本机 3080,拖拽 12 步 ≈192ms):**40 帧、帧均 8.36ms / p95 10.2ms、1 帧超 16ms;62 次 `setProperty`(5 种几何属性:panel width / bottom height / bottom right / 两个 html 变量)**——即每帧 5 次写同一组元素,浏览器合并为单次样式重算。
|
||||
|
||||
| 指标 | 现状 | 目标(v0.13.1 实施后) |
|
||||
|---|---|---|
|
||||
| 拖拽期宿主 layout 次数 | 每帧 1 次批写(`applyDrag` rAF),帧均 8.36ms | 保持帧率 ≥ 基线;面板 `contain: layout style` 隔离内部重排;拖拽期 `will-change` 提升合成层 |
|
||||
| 几何写路径 | 拖拽(`applyDrag`)与打开态(effect)两条路径写同一组变量 | 统一 `writeGeometry` 单写入器(已实施) |
|
||||
| 懒加载 chunk 二次打开 | 不重下载(304)但重注入+重执行 | ETag 未变 → 内存缓存命中,0 脚本执行(已实施) |
|
||||
| 会话切换 | 面板 DOM 不重建(已满足) | 保持 + 重组件懒挂载 |
|
||||
| 面板显隐动画 | transform+width/margin 过渡 | transform 优先(overlay 模式),推挤模式过渡保留(`#root` width/margin 锁步过渡) |
|
||||
| 观察器 | ResizeObserver 已 rAF 节流 | 保持;挂载自检/MutationObserver 守卫均为一次性廉价检查 |
|
||||
|
||||
## 6. 路线 B 详细设计:官方槽位迁移(ui-sidebar 模式)
|
||||
|
||||
### 6.1 可行性结论(已验证)
|
||||
|
||||
- **`sidebar` 槽:可接管**。条件:profile 层 `cordis.patch.yml` 禁用 `ui-sidebar`(`disabled: true`),`ui-layout` 必须保留;接管者需自实现 `sidebar.workspaces`/`sidebar.settings` 子槽内容或留空(`slots.inject` 静默跳过,不炸树);折叠按钮自渲染 + `ctx.layout.toggleSidebar()`;宽度/折叠持久化**自做**(官方不持久化)。
|
||||
- **`details` 槽:不可行(当前)**。唯一占用者 ui-conversation 的 DetailsPanel 与其会话 UI 同包,禁用即空置会话列;需上游把 DetailsPanel 拆为可替换槽位或提供优先级机制。
|
||||
- **几何传导**:槽位渲染在 AppFrame grid 列内,官方拖拽把手/折叠/让步结算免费生效;session scope 槽的 sessionId 由框架注入为标准 prop。
|
||||
|
||||
### 6.2 上游 issue 建议(本仓库只出建议,不改源码)
|
||||
|
||||
1. 将 `details` 槽的占用者改为**可替换**(如 single 槽支持「替换注册」或 DetailsPanel 拆独立包),使外部插件能接管右侧面板。
|
||||
2. 新增(或声明)`bottom-panel` 槽,供底部面板类插件使用。
|
||||
3. ui-sidebar 宽度/折叠持久化下沉到 layout store(官方侧目前不持久化)。
|
||||
|
||||
**issue 草案(PR3 提交时使用,已备好)**:
|
||||
|
||||
> **Title**: External plugins cannot take over the `details` panel slot
|
||||
> **Repo**: deepseek-ai/dsh(或官方插件仓库)
|
||||
> **Body**:
|
||||
> - Context: `ui-layout` declares `root` → `{sidebar, conversation, details}` as single-exclusive child slots. `ui-conversation`'s DetailsPanel is the sole occupant of `details`; an external panel plugin (e.g. dsh-better-sidebar) cannot register into it without disabling `ui-conversation` (which empties the conversation column). Meanwhile `ui-sidebar` (the official left sidebar) is itself a plugin and CAN be replaced from the profile layer (`cordis.patch.yml` `disabled: true`), which proves the pattern.
|
||||
> - Ask: make the `details` occupant replaceable — either (a) allow single-slot "replacement registration" with explicit opt-out by the default occupant, (b) extract DetailsPanel into a standalone optional plugin so profiles can disable it without losing the conversation column, or (c) declare a new `bottom-panel` slot for bottom panel plugins.
|
||||
> - Reference implementation to mirror: `ui-sidebar/src/client/index.ts:39` (register `name:'sidebar'`), `ui-layout/src/client/AppFrame.tsx:164-196` (grid geometry + drag handles come free), `runtime/src/client/slots.ts:130-192` (`slots.inject` consumers skip silently when a slot is undeclared).
|
||||
|
||||
### 6.3 迁移路径(上游落地后)
|
||||
|
||||
1. better-sidebar 增加「槽位渲染后端」:注册 `details`/`bottom-panel` 槽,`Sidebar` 树渲染进 grid(同一套 store/组件,仅挂载点不同)。
|
||||
2. body 浮层收敛为纯浮层:菜单、弹窗、右键、拖出浮窗(与官方 ui-primitives portal 同姿势)。
|
||||
3. 保留路线 A 的固定含块层作为浮层宿主;推挤/几何写入器退役(grid 接管)。
|
||||
|
||||
### 6.4 收益与代价
|
||||
|
||||
| 收益 | 代价 |
|
||||
|---|---|
|
||||
| 零 body 注入、零宿主 DOM 依赖 | 契约绑定上游 SlotMap,DSH 升级需跟随 |
|
||||
| 官方几何/拖拽/折叠/让步结算免费 | 官方不持久化宽度,需自做持久化 |
|
||||
| 任何套壳天然一致(grid 与套壳无关) | 上游排期不可控(issue 周期) |
|
||||
| 拖拽性能与官方同源 | 迁移期间双后端并存(维护成本) |
|
||||
|
||||
## 7. 兼容矩阵(注入策略 × 场景)
|
||||
|
||||
| 场景 | body 固定含块(路线 A) | 官方槽位(路线 B) | shadow DOM | iframe |
|
||||
|---|---|---|---|---|
|
||||
| 纯浏览器 | ✅ 推荐 | ✅ | ✅(令牌穿透) | ⚠️ 令牌/交互断裂 |
|
||||
| Electron 套壳(DSH Desktop) | ✅ + 信号自动避让 | ✅ | ✅ | ⚠️ |
|
||||
| Tauri 套壳(原生标题栏) | ✅ | ✅ | ✅ | ⚠️ |
|
||||
| 移动 webview | ✅ + overlay 抽屉/visualViewport | 需上游槽位 | ✅ | ⚠️ |
|
||||
| 页面内嵌 iframe | ⚠️ 需宿主放行 | ❌ | ✅ | ✅(天然隔离) |
|
||||
| 祖先 transform 劫持 | ✅ 自检降级 | ✅ 天然免疫 | ✅ | ✅ |
|
||||
| 性能(拖拽/显隐) | rAF 批写 + contain | 官方 grid | 中 | 低 |
|
||||
|
||||
## 8. 实施拆分与验证
|
||||
|
||||
### 8.1 拆分(文件级改动清单)
|
||||
|
||||
**实施方式(2026-08-19 定稿)**:单分支 `feat/injection-unified-host`、按步骤顺序提交(每步 CI 绿);原 PR1/PR2 拆分为内部步骤,合并为**一个 PR** 交付(改动彼此耦合,且路线 B 关闭后无外部依赖)。各步骤:
|
||||
|
||||
1. **挂载层**(`src/client/index.tsx`、`src/client/Sidebar.tsx`、`src/client/sidebar.module.css`):`Sidebar` 渲染根包 `data-dsh-panel-host`(`position:fixed; inset:0; pointer-events:none; z-index:40`);`.panel/.toggleCluster/.bottomPanel` 由 fixed 改 absolute(层 inset:0 即视口坐标)+ `pointer-events:auto`;挂载后一次性几何自检(偏差 >8px → `data-dsh-panel-host-degraded` + rAF 视口同步 + warn 一次);`MutationObserver`(body childList)守卫锚点被清空时重挂。✅ 已实施(commit 9a4c160)
|
||||
2. **几何层**(`Sidebar.tsx`、`sidebar.module.css`):`writeGeometry` 统一拖拽/打开态两条推挤写路径;面板 `contain: layout style`;拖拽期(`body[data-dsh-sidebar-dragging]`)`will-change: transform`。✅ 已实施(commit 553e650)
|
||||
3. **chunk 缓存**(`chunk-loader.ts`、`index.tsx`):`revalidateChunksOnReactivate()`——激活时 HEAD 每个已加载 chunk 对比 ETag,未变保留已解析 exports(跳过重注入/重执行),变更/不可达 fail-open 重取;测试夹具与孤儿缓存不跨激活存活。✅ 已实施(commit 0264625)
|
||||
4. **锚点与防溢出**(`layout.css`):底部推挤锚点 `nth-child(2)` → `#root [data-dsh-frame] > [data-pane="conversation"]`(§3.4 实证);`#root` 加 `width: calc(100% - var(...))` 与 margin 锁步过渡(实测 1140+300=1440 无溢出;#226 桌面溢出根因吸收)。✅ 已实施(commit 9c9515d)
|
||||
5. **桌面/移动适配**(新增 `desktop-env.ts`、`Sidebar.tsx`、CSS):解析 `dsh-desktop-mode/platform` + `__DSH_DESKTOP_FILE_PATH__`;win32 advanced 自动 `titleBarCompat`(32px overlay,手动 pref 覆盖);`visualViewport` 键盘 inset(底部面板/窄屏抽屉上移);`env(safe-area-inset-*)`。✅ 已实施(commit a1906c5,单测 7 例)
|
||||
6. **测试与文档**:`tests/desktop-env.spec.ts`(7 例)、chunk-loader 重验证 4 例、`mount.e2e.ts` 增 `data-dsh-panel-host` 断言 + 桌面参数场景、AGENTS.md §8 契约同步、本文档更新。🔄 进行中
|
||||
7. **交付**:CI 全绿(含 plugin-mount)→ `gh pr create`。⏳ 待执行
|
||||
|
||||
### 8.2 验证
|
||||
|
||||
- **单元**:几何写入器 rAF 批处理(fake timers 断言单帧单写);桌面信号解析(URL param × platform × 全局标记);挂载自检降级路径;chunk 缓存命中/失效。
|
||||
- **e2e**:`tests/e2e/mount.e2e.ts` 深扫保持不变并新增断言:`data-dsh-panel-host` 存在、锚点属性不变;新增 desktop-mode URL 参数场景(模拟 win32 advanced → 顶栏避让类生效)。
|
||||
- **skin 契约**:`tests/theme.spec.ts` 令牌读取不变(§8 契约同步守护)。
|
||||
- **CI**:全绿门禁(build/typecheck/test/consumer-types/plugin-mount);#180 的 plugin-mount flake 与本次改动无关,重跑确认。
|
||||
- **度量**:拖拽期 PerformanceObserver 记录 layout 次数;懒加载二次打开网络计数为 0。
|
||||
|
||||
## 9. 风险与回滚
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|---|---|
|
||||
| 固定含块改变既有 fixed 行为(罕见宿主) | 挂载自检 + 降级模式;锚点属性/CSS 变量不变,可一键回退旧实现 |
|
||||
| 几何写入器集中化引入回归 | PR1 内保留旧写入路径开关(feature-flag),e2e 深扫覆盖 |
|
||||
| 桌面自动探测误判(套壳改 URL 格式) | 信号解析白名单 + 手动覆盖位保留 |
|
||||
| 路线 B 上游不落地 | 路线 A 完整自洽,不阻塞 |
|
||||
| 禁用 ui-sidebar 属 profile 用户决策 | 插件仅文档化,不自动执行 |
|
||||
|
||||
## 10. 参考来源
|
||||
|
||||
- DSH 源码:`~/.dsh/source/current`(只读):`ui-layout/src/client/{index.ts,AppFrame.tsx,columns.ts,stores.ts,AppFrame.module.css}`、`ui-slots/src/index.ts`、`ui-sidebar/src/client/index.ts`、`ui-conversation/src/client/apply.ts`、`ui-primitives/Menu.tsx`、`runtime/src/client/slots.ts`
|
||||
- DSH Desktop 运行时(本机 .app,只读):`lib/src-B5373ech.js`、`lib/electron-runtime-C_i38SKA.js`、`lib/preload.cjs`
|
||||
- 社区套壳:kyorakuyk/dsh-desktop(https://github.com/kyorakuyk/dsh-desktop)、Lotus-c/DSH-Desktop(https://github.com/Lotus-c/DSH-Desktop)
|
||||
- 本仓库:`src/client/index.tsx`、`src/client/Sidebar.tsx`、`src/client/layout.css`、`docs/plans/2026-08-15-skin-compat-design.md`
|
||||
- 相关 PR:#226、#135、#82、#108、#111、#138、#153(症状级补丁,本方案吸收其目标);#208/#90(issue)
|
||||
|
||||
## 11. 决策记录与实施偏差记录
|
||||
|
||||
- 2026-08-19(第一轮):方案定稿——路线 A(统一面板宿主)为主线、路线 B(官方槽位)并行提 issue;先出设计文档,实现拆 PR1/PR2 待批准。
|
||||
- 2026-08-19(第二轮,实施就绪打磨):对照源码核实现状——拖拽 rAF 批处理/窄屏不推挤/观察器节流/header 弱锚点**已具备**(§3.1 已标注「实施勿重做」);chunk 缺口精确为「激活后内存缓存清空重注入」(HTTP 已 304);§8.1 落到文件级改动清单;§6.2 备好上游 issue 草案。
|
||||
- 2026-08-19(第三轮,实证核对):真实 dsh web 页面 Playwright 探针(§3.4)——frame 子项已含 overlay/handle(nth-child 脆弱性实锤);`data-pane="conversation"` 为新弱锚点(`[data-slot=conversation]` display:contents 作废);推挤在 web 无溢出(calc 改动无害);host 本体 static(fixed 在内层面板,含块层为新元素)。
|
||||
- 2026-08-19(第四轮,用户决策):**路线 B 关闭**——无法向官方提 issue,纯插件侧自己优化注入方式;实现合并为单 PR(分支 `feat/injection-unified-host`),按 §8.1 步骤顺序落地。
|
||||
- **实施偏差记录**:
|
||||
- 挂载自检降级实现为「`data-dsh-panel-host-degraded` 属性 + rAF 视口同步」(非独立 absolute 模式,见 §5.1 注释)。
|
||||
- chunk 缓存实现为 **ETag HEAD 重验证**而非内容哈希(bundle 路由已发 ETag,HEAD 更廉价);`resetChunks()` 保留(测试隔离),激活路径改用 `revalidateChunksOnReactivate()`。
|
||||
- `#root` 防溢出采用 `width: calc(100% - var)` + width/margin **锁步过渡**(避免动画断裂),并实测 1140+300=1440。
|
||||
- 桌面避让复用既有 `titleBarCompat/titleBarStripPx` 通道(未新增 `--dsh-desktop-top-inset` 变量);darwin 红绿灯区仅预留(当前无左侧贴边控件)。
|
||||
- 测试环境为 Node + 手写 shim(非 jsdom):`browser-globals.ts` 补 `location`;desktop-env 剥除 `location.search` 前导 `?`(真实 bug,顺带修复)。
|
||||
- 本地 Node 24 跑 `test:mount` 需 `node --expose-internals` 启动 dsh CLI(HMR 服务要求,CI Node 22 无此问题)——本地用 DSH_CMD 包装,不改仓库脚本。
|
||||
- **CR #232 修复(2026-08-19)**:① 降级同步改为按**未修正几何**判定(跟踪自身补偿量,祖先 transform 消失才退出循环);② chunk 重验证成为 `loadChunk` 屏障(重验证挂起期间不提供缓存,杜绝 HMR 竞态陈旧 exports);③ 键盘 inset 公式补 `visualViewport.offsetTop` 并监听 `scroll`。
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "dsh-external/dsh-better-sidebar",
|
||||
"version": "0.12.1",
|
||||
"version": "0.14.0",
|
||||
"main": "./lib/index.js",
|
||||
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
|
||||
"engines": {
|
||||
|
||||
+39
-38
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dsh-better-sidebar",
|
||||
"version": "0.12.1",
|
||||
"version": "0.14.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.",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
@@ -48,7 +48,8 @@
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-slots",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-modules"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -60,6 +61,7 @@
|
||||
"lib/client-registry.js",
|
||||
"lib/client-terminal.js",
|
||||
"lib/client-editor.js",
|
||||
"lib/client-mermaid.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"src",
|
||||
"scripts/install.sh",
|
||||
@@ -77,27 +79,26 @@
|
||||
"watch": "tsdown --watch",
|
||||
"prepare": "tsdown",
|
||||
"test": "vitest run",
|
||||
"test:mount": "bash scripts/e2e-mount.sh",
|
||||
"check:consumer-types": "bash scripts/check-consumer-types.sh"
|
||||
},
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "^4.0.1",
|
||||
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-schema-form": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/dsh-agent": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-session": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-tools": "^0.1.0-rc.8",
|
||||
"cordis": "^4.0.0-rc.8",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
@@ -128,36 +129,36 @@
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/view": "^6.43.8",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"clsx": "^2.1.1",
|
||||
"mermaid": "^11.16.1",
|
||||
"node-pty": "^1.1.0",
|
||||
"rxjs": "^7.8.2",
|
||||
"schemastery": "^3.18.0",
|
||||
"ws": "^8.18.0",
|
||||
"xterm": "^5.3.0"
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-schema-form": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-agent": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-client-locale": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-client-runtime": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-host-webserver": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-invariants": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-llm": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-session": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-settings": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-tools": "0.1.0-rc.8",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.1",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"cordis": "^4.0.0-rc.8",
|
||||
"jsdom": "^29.1.1",
|
||||
"lightningcss": "^1.32.0",
|
||||
"react": "^18.2.0",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Playwright config for the headless-render mount lane (tests/e2e).
|
||||
*
|
||||
* This lane does NOT spawn the server itself: `scripts/e2e-mount.sh` boots a
|
||||
* real `dsh web` instance (with the npm-packed plugin mounted through the
|
||||
* official `dsh plugin add` channel) and injects the base URL through
|
||||
* `DSH_E2E_URL`. The spec's job is to render that URL in a headless Chromium
|
||||
* and prove the plugin mounts without crashing the shell.
|
||||
*
|
||||
* Specs are named `*.e2e.ts` (not `*.spec.ts`) so vitest's default include
|
||||
* never collects them; vitest.config.ts excludes the directory as well.
|
||||
*/
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
// The lane's specs are named *.e2e.ts (so vitest's default include never
|
||||
// collects them); tell Playwright to pick exactly that shape.
|
||||
testMatch: '**/*.e2e.ts',
|
||||
// A deterministic gate: no retries, one worker, one browser. The whole lane
|
||||
// is a crash probe against one server instance, so serial execution keeps
|
||||
// the assertions meaningful (a crash would trip the very next check).
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
fullyParallel: false,
|
||||
timeout: 120_000,
|
||||
reporter: [
|
||||
['list'],
|
||||
// Local debugging artifact; CI uploads the report dir on failure.
|
||||
['html', { open: 'never' }],
|
||||
],
|
||||
use: {
|
||||
browserName: 'chromium',
|
||||
headless: true,
|
||||
viewport: { width: 1440, height: 900 },
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
})
|
||||
Generated
+2071
-1080
File diff suppressed because it is too large
Load Diff
+1
-58
@@ -9,6 +9,7 @@ allowBuilds:
|
||||
protobufjs: true
|
||||
|
||||
minimumReleaseAgeExclude:
|
||||
- '@deepseek-ai/*'
|
||||
- '@deepseek-ai/cordis-plugin-include@1.0.5-rc.1'
|
||||
- '@deepseek-ai/cordis-plugin-loader@1.0.1-rc.1'
|
||||
- '@deepseek-ai/cordis@4.0.1-rc.1'
|
||||
@@ -69,62 +70,4 @@ minimumReleaseAgeExclude:
|
||||
- '@deepseek-ai/cordis-plugin-loader@1.0.2'
|
||||
- '@deepseek-ai/cordis@4.0.1'
|
||||
- '@deepseek-ai/cosmokit@1.8.2'
|
||||
- '@deepseek-ai/dsh-agent-default-model@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-agent-presets@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-agent@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-api-gateway@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-api-remotes@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-atomic-write@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-attachment@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-brand@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-connection@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-locale@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-runtime@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-schema-form@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-ui-attachment@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-ui-conversation@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-ui-input-trigger@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-ui-primitives@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-ui-settings@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-ui-slots@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-web-react@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-code-runtime@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-commands@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-compaction@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-cordis-host-runner@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-credentials@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-goal@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-home-paths@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-host-apiproxy@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-host-directory-picker@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-host-plugin-inventory@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-host-webserver@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-invariants@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-jobs@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-llm-retry@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-llm@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-message-feedback@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-native-command@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-scope@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-session-persistence@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-session-projection-cache@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-session-projection@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-session-query@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-session-stats@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-session-title@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-session@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-settings@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-skill@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-storage-domain@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-storage@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-subagent@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-system-prompt@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-timeout@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-token-meter@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-tools@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-typert-protocol@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-typert-registry@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-user-approval@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-user-questions@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-workspace@0.1.0-rc.6'
|
||||
- '@deepseek-ai/schemastery@3.18.1'
|
||||
|
||||
Executable
+173
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# dsh-better-sidebar 聚合双挂载冒烟(CI + 本地):
|
||||
#
|
||||
# 场景:一个聚合 bundle(tests/fixtures/aggregate-better-sidebar)先以独立
|
||||
# 条目 id 挂载 dsh-better-sidebar,插件自身的 bundle patch 后到 —— 旧行为
|
||||
# 两个实例都注册 /sidebar/api,整个插件树启动失败(duplicate prefix
|
||||
# route);新行为插件行检测到已有启用中的同包名挂载后自动 disabled,由
|
||||
# 聚合行接管 sidebar。
|
||||
#
|
||||
# 流程:
|
||||
# 1. 打包 fixture 聚合包;
|
||||
# 2. 用官方 CLI 把 fixture 先装进全新 scratch profile,再把插件 tarball
|
||||
# 装进同一 profile(复刻真实安装顺序:聚合先行、dsh plugin add 后到);
|
||||
# 3. 启动真实 `dsh web`(keyless,--port 0);
|
||||
# 4. 断言:日志出现启动 URL、无 "duplicate prefix route"、/sidebar/api
|
||||
# 路由存活(POST 未知方法应返回 404 JSON 而非崩溃)。
|
||||
#
|
||||
# 用法:
|
||||
# bash scripts/e2e-aggregate-mount.sh
|
||||
#
|
||||
# 环境变量(均可省略):
|
||||
# DSH_CMD dsh 命令;缺省 `npx -y --package @deepseek-ai/dsh dsh`
|
||||
# (与 pm2 启动器同源,避免依赖可能失效的 PATH dsh)
|
||||
# TARBALL 插件 tarball;缺省仓库根 dsh-better-sidebar-*.tgz(须已 pack)
|
||||
# PORT 固定端口(默认 0 = OS 分配,从日志解析 URL)
|
||||
# DSH_HOME_BASE scratch 根目录(默认系统临时目录)。脚本始终在其下新建
|
||||
# 本调用拥有的独立子目录,只写入/删除该子目录;调用方提供
|
||||
# 的目录本身(可能是真实 ~/.dsh)绝不写入或删除。
|
||||
# KEEP_HOME 非空时保留 scratch 目录(调试用)
|
||||
#
|
||||
# 退出码 = 0 通过;非 0 失败。服务器与 scratch 目录由 trap 兜底清理。
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
FIXTURE_DIR="$ROOT/tests/fixtures/aggregate-better-sidebar"
|
||||
|
||||
DSH_CMD="${DSH_CMD:-npx -y --package @deepseek-ai/dsh dsh}"
|
||||
TARBALL="${TARBALL:-}"
|
||||
PORT="${PORT:-0}"
|
||||
KEEP_HOME="${KEEP_HOME:-}"
|
||||
|
||||
say() { printf '\033[32m[e2e-aggregate-mount]\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[33m[e2e-aggregate-mount]\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[31m[e2e-aggregate-mount]\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
command -v node >/dev/null 2>&1 || die "未找到 node(DSH 运行需要 Node.js >= 20)"
|
||||
command -v pnpm >/dev/null 2>&1 || die "未找到 pnpm(dsh plugin 转发给 pnpm)"
|
||||
command -v npm >/dev/null 2>&1 || die "未找到 npm(打包 fixture 需要)"
|
||||
command -v curl >/dev/null 2>&1 || die "未找到 curl"
|
||||
|
||||
if [ -z "$TARBALL" ]; then
|
||||
TARBALL="$(ls "$ROOT"/dsh-better-sidebar-*.tgz 2>/dev/null | head -1 || true)"
|
||||
fi
|
||||
[ -n "$TARBALL" ] && [ -f "$TARBALL" ] || die "未找到插件 tarball:先在本仓库跑 pnpm build && npm pack,或用 TARBALL 指定"
|
||||
|
||||
# ── scratch home ────────────────────────────────────────────────────────────
|
||||
# 始终在本调用拥有的全新目录里运行:调用方给了 DSH_HOME_BASE(可能是真实
|
||||
# ~/.dsh)时,只在其下新建子目录并只删除该子目录;缺省时直接用系统临时
|
||||
# 目录。调用方提供的目录本身绝不写入或删除。
|
||||
DSH_HOME_BASE="${DSH_HOME_BASE:-}"
|
||||
if [ -n "$DSH_HOME_BASE" ]; then
|
||||
SCRATCH="$(mktemp -d "$DSH_HOME_BASE/dsh-e2e-agg.XXXXXX")"
|
||||
else
|
||||
SCRATCH="$(mktemp -d /tmp/dsh-e2e-agg.XXXXXX)"
|
||||
fi
|
||||
export DSH_HOME="$SCRATCH"
|
||||
LOG_DIR="$DSH_HOME/logs"; mkdir -p "$LOG_DIR"
|
||||
OUT_LOG="$LOG_DIR/dsh-web.out.log"; ERR_LOG="$LOG_DIR/dsh-web.err.log"
|
||||
SERVER_PID=""
|
||||
TMP_DIR=""
|
||||
cleanup() {
|
||||
if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
fi
|
||||
if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then
|
||||
rm -rf "$TMP_DIR"
|
||||
fi
|
||||
if [ -z "$KEEP_HOME" ] && [ -d "$SCRATCH" ]; then
|
||||
rm -rf "$SCRATCH"
|
||||
else
|
||||
warn "KEEP_HOME 已设置,保留 $SCRATCH"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# ── 打包 fixture 聚合包 ─────────────────────────────────────────────────────
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
( cd "$FIXTURE_DIR" && npm pack --silent --pack-destination "$TMP_DIR" >/dev/null )
|
||||
FIXTURE_TGZ="$(ls "$TMP_DIR"/*.tgz 2>/dev/null | head -1 || true)"
|
||||
[ -n "$FIXTURE_TGZ" ] || die "fixture 打包失败"
|
||||
|
||||
# ── 引导 scratch profile(web 模板)────────────────────────────────────────
|
||||
# 先写 pnpm-workspace.yaml 的 allowBuilds / minimumReleaseAgeExclude,避免
|
||||
# pnpm 11 strict-dep-builds 拦截 node-pty/protobufjs——同 e2e-mount.sh。
|
||||
PROFILE_DIR="$DSH_HOME/profiles/web"
|
||||
mkdir -p "$PROFILE_DIR"
|
||||
cat > "$PROFILE_DIR/package.json" <<EOF
|
||||
{
|
||||
"name": "dsh-profile-web",
|
||||
"private": true,
|
||||
"dependencies": {},
|
||||
"dsh": {
|
||||
"profile": {
|
||||
"bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"]
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
printf '[]\n' > "$PROFILE_DIR/cordis.patch.yml"
|
||||
cat > "$PROFILE_DIR/pnpm-workspace.yaml" <<'EOF'
|
||||
packages:
|
||||
- .
|
||||
|
||||
nodeLinker: hoisted
|
||||
autoInstallPeers: false
|
||||
|
||||
allowBuilds:
|
||||
node-pty: true
|
||||
protobufjs: true
|
||||
|
||||
minimumReleaseAgeExclude:
|
||||
- dsh-better-sidebar
|
||||
EOF
|
||||
|
||||
# ── 组装 profile:聚合先行,插件后到 ──────────────────────────────────────
|
||||
say "安装 fixture 聚合包(先行)…"
|
||||
$DSH_CMD plugin --profile web add "file:$FIXTURE_TGZ"
|
||||
say "安装插件 tarball(后到)…"
|
||||
$DSH_CMD plugin --profile web add "file:$TARBALL"
|
||||
|
||||
# ── 启动真实 dsh web ───────────────────────────────────────────────────────
|
||||
say "启动 dsh web(--port ${PORT},日志 ${LOG_DIR})…"
|
||||
$DSH_CMD web --port "$PORT" >"$OUT_LOG" 2>"$ERR_LOG" &
|
||||
SERVER_PID=$!
|
||||
|
||||
# 等待启动 URL 或进程退出(最多 120s)
|
||||
URL=""
|
||||
for _ in $(seq 1 120); do
|
||||
if ! kill -0 "$SERVER_PID" 2>/dev/null; then break; fi
|
||||
URL="$(grep -oE 'http://127\.0\.0\.1:[0-9]+' "$OUT_LOG" | tail -1 || true)"
|
||||
[ -n "$URL" ] && break
|
||||
sleep 1
|
||||
done
|
||||
if [ -z "$URL" ]; then
|
||||
warn "out log 尾部:$(tail -5 "$OUT_LOG" 2>/dev/null || true)"
|
||||
warn "err log 尾部:$(tail -5 "$ERR_LOG" 2>/dev/null || true)"
|
||||
die "dsh web 未在 120s 内启动"
|
||||
fi
|
||||
say "已启动:$URL"
|
||||
|
||||
# ── 断言 ────────────────────────────────────────────────────────────────────
|
||||
if grep -q "duplicate prefix route" "$ERR_LOG" "$OUT_LOG" 2>/dev/null; then
|
||||
die "检测到 duplicate prefix route(双挂载未退让)"
|
||||
fi
|
||||
|
||||
# 真实方法探活:terminal.deps 是插件自己的 handler(writeOk → HTTP 200 +
|
||||
# {"ok":true,...})。成功响应证明至少一个实例真正注册了 /sidebar/api 路由
|
||||
# ——generic missing-route 404 无法冒充(P2: Probe a real sidebar API method)。
|
||||
DEPS="$(curl -s -X POST "$URL/sidebar/api/terminal.deps" 2>/dev/null || true)"
|
||||
if ! printf '%s' "$DEPS" | grep -q '"ok":true'; then
|
||||
die "/sidebar/api/terminal.deps 未返回 ok:true(响应:$(printf '%s' "$DEPS" | head -c 200))"
|
||||
fi
|
||||
say "/sidebar/api/terminal.deps → ok:true(真实 handler 存活)"
|
||||
|
||||
STATUS="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$URL/sidebar/api/__e2e_unknown__" 2>/dev/null || true)"
|
||||
say "/sidebar/api POST 未知方法 → HTTP ${STATUS}(期望 404)"
|
||||
[ "$STATUS" = "404" ] || die "/sidebar/api 未知方法未按预期返回 404(HTTP ${STATUS})"
|
||||
|
||||
say "通过:聚合双挂载场景下插件自动退让,侧边栏由聚合行接管。"
|
||||
Executable
+167
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# dsh-better-sidebar 挂载冒烟编排(CI + 本地):
|
||||
#
|
||||
# 1. 用官方 CLI 把 npm 打包产物(tarball)真实挂载进一个全新 scratch
|
||||
# profile(`dsh plugin --profile web add file:<tarball>`,触发
|
||||
# dsh.profile.bundles 协调,与用户安装路径一致);
|
||||
# 2. 启动真实 `dsh web`(keyless,--port 0 取 OS 分配端口);
|
||||
# 3. 运行 tests/e2e 无头渲染 lane(Playwright Chromium):断言外壳与
|
||||
# 插件挂载、无崩溃标记,并驱动内置 tab 深扫。
|
||||
#
|
||||
# 用法:
|
||||
# bash scripts/e2e-mount.sh [--grep <playwright-filter>]
|
||||
#
|
||||
# 环境变量(均可省略):
|
||||
# DSH_CMD dsh 命令;缺省 PATH 上的 `dsh`,回退 npx 拉官方包
|
||||
# TARBALL 插件 tarball;缺省仓库根 dsh-better-sidebar-*.tgz(须已 pack)
|
||||
# PORT 固定端口(默认 0 = OS 分配,从日志解析 URL)
|
||||
# DSH_HOME_BASE 覆盖 scratch 根目录(默认系统临时目录)。脚本始终在其下
|
||||
# 新建本调用拥有的独立子目录,只写入/删除该子目录;调用方
|
||||
# 提供的目录本身(可能是真实 ~/.dsh)绝不写入或删除。
|
||||
# KEEP_HOME 非空时保留 scratch home(调试用)
|
||||
#
|
||||
# 退出码 = playwright 的退出码;服务器与 scratch 目录由 trap 兜底清理。
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
DSH_CMD="${DSH_CMD:-dsh}"
|
||||
PORT="${PORT:-0}"
|
||||
TARBALL="${TARBALL:-}"
|
||||
GREP_FILTER=""
|
||||
if [ "${1:-}" = "--grep" ]; then GREP_FILTER="${2:?--grep 需要参数}"; fi
|
||||
|
||||
say() { printf '\033[32m[e2e-mount]\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[33m[e2e-mount]\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[31m[e2e-mount]\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
command -v node >/dev/null 2>&1 || die "未找到 node(DSH 运行需要 Node.js >= 20)"
|
||||
command -v pnpm >/dev/null 2>&1 || die "未找到 pnpm(dsh plugin 转发给 pnpm)"
|
||||
|
||||
# dsh CLI 解析:PATH 上的 dsh 优先,否则 npx 拉官方包(同 scripts/install.sh)
|
||||
if ! command -v "$DSH_CMD" >/dev/null 2>&1; then
|
||||
if command -v npx >/dev/null 2>&1; then
|
||||
say "PATH 上无 $DSH_CMD,回退 npx -y --package @deepseek-ai/dsh"
|
||||
DSH_CMD="npx -y --package @deepseek-ai/dsh dsh"
|
||||
else
|
||||
die "未找到 $DSH_CMD 或 npx;请先安装 DSH CLI(npm i -g @deepseek-ai/dsh)或用 DSH_CMD 指定"
|
||||
fi
|
||||
fi
|
||||
|
||||
# tarball 解析
|
||||
if [ -z "$TARBALL" ]; then
|
||||
TARBALL="$(ls "$ROOT"/dsh-better-sidebar-*.tgz 2>/dev/null | head -1 || true)"
|
||||
fi
|
||||
[ -n "$TARBALL" ] && [ -f "$TARBALL" ] || die "找不到 tarball(TARBALL 或 \$ROOT/dsh-better-sidebar-*.tgz)——先运行 pnpm build && pnpm pack"
|
||||
TARBALL="$(cd "$(dirname "$TARBALL")" && pwd)/$(basename "$TARBALL")"
|
||||
say "tarball: $TARBALL"
|
||||
|
||||
# scratch home(每次全新,绝不触碰真实 ~/.dsh):调用方给了 DSH_HOME_BASE
|
||||
# 时,只在其下新建本调用拥有的子目录并只删除该子目录;缺省时直接用系统
|
||||
# 临时目录。
|
||||
if [ -n "${DSH_HOME_BASE:-}" ]; then
|
||||
SCRATCH="$(mktemp -d "$DSH_HOME_BASE/dsh-e2e-mount.XXXXXX")"
|
||||
else
|
||||
SCRATCH="$(mktemp -d /tmp/dsh-e2e-mount.XXXXXX)"
|
||||
fi
|
||||
export DSH_HOME="$SCRATCH/home"
|
||||
WORKSPACE_DIR="$SCRATCH/workspace"
|
||||
LOG_DIR="$SCRATCH"
|
||||
WEB_LOG="$LOG_DIR/web.log"
|
||||
mkdir -p "$DSH_HOME/profiles/web" "$WORKSPACE_DIR"
|
||||
say "scratch home: ${DSH_HOME}(DSH_HOME=${DSH_HOME})"
|
||||
|
||||
SERVER_PID=""
|
||||
cleanup() {
|
||||
local code=$?
|
||||
if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
fi
|
||||
if [ -z "${KEEP_HOME:-}" ]; then
|
||||
rm -rf "$SCRATCH"
|
||||
else
|
||||
warn "KEEP_HOME 已设置,保留 $SCRATCH"
|
||||
fi
|
||||
exit "$code"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# 步骤 1:引导 scratch profile(web 模板,镜像 dsh initProfile;先写
|
||||
# pnpm-workspace.yaml 的 allowBuilds / minimumReleaseAgeExclude,避免 pnpm 11
|
||||
# strict-dep-builds 拦截 node-pty/protobufjs 或拒绝 <24h 新版本——同 install.sh)
|
||||
PROFILE_DIR="$DSH_HOME/profiles/web"
|
||||
cat > "$PROFILE_DIR/package.json" <<EOF
|
||||
{
|
||||
"name": "dsh-profile-web",
|
||||
"private": true,
|
||||
"dependencies": {},
|
||||
"dsh": {
|
||||
"profile": {
|
||||
"bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"]
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
printf '[]\n' > "$PROFILE_DIR/cordis.patch.yml"
|
||||
cat > "$PROFILE_DIR/pnpm-workspace.yaml" <<'EOF'
|
||||
packages:
|
||||
- .
|
||||
|
||||
nodeLinker: hoisted
|
||||
autoInstallPeers: false
|
||||
|
||||
allowBuilds:
|
||||
node-pty: true
|
||||
protobufjs: true
|
||||
|
||||
minimumReleaseAgeExclude:
|
||||
- dsh-better-sidebar
|
||||
EOF
|
||||
|
||||
# 步骤 2:官方 CLI 安装 tarball + bundle 协调(真实挂载路径)
|
||||
say "执行 dsh plugin --profile web add file:$TARBALL ..."
|
||||
$DSH_CMD plugin --profile web add "file:$TARBALL"
|
||||
|
||||
# 步骤 3:校验挂载生效(dsh.profile.bundles 含 dsh-better-sidebar)
|
||||
if ! node -e '
|
||||
const fs = require("fs");
|
||||
const p = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
||||
const bundles = p.dsh?.profile?.bundles ?? [];
|
||||
process.exit(bundles.includes("dsh-better-sidebar") ? 0 : 1);
|
||||
' "$PROFILE_DIR/package.json"; then
|
||||
warn "dsh-better-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册"
|
||||
cat "$PROFILE_DIR/package.json"
|
||||
exit 1
|
||||
fi
|
||||
say "挂载已注册:dsh.profile.bundles 包含 dsh-better-sidebar"
|
||||
|
||||
# 步骤 4:启动 dsh web(--port 0 = OS 分配,避免端口冲突;keyless 可起)
|
||||
say "启动 dsh web(port=${PORT})..."
|
||||
$DSH_CMD web --port "$PORT" > "$WEB_LOG" 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
URL=""
|
||||
for _ in $(seq 1 120); do
|
||||
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
echo "=== dsh web 提前退出,日志尾部 ===" >&2
|
||||
tail -30 "$WEB_LOG" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
if URL="$(grep -oE 'dsh web: http://127\.0\.0\.1:[0-9]+' "$WEB_LOG" | head -1 | awk '{print $3}')" && [ -n "$URL" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
[ -n "$URL" ] || { echo "=== 120s 内未等到 dsh web 就绪,日志尾部 ===" >&2; tail -40 "$WEB_LOG" >&2 || true; exit 1; }
|
||||
say "dsh web 就绪:${URL}(pid ${SERVER_PID})"
|
||||
|
||||
# 步骤 5:运行无头渲染 lane
|
||||
say "运行 Playwright 无头渲染 lane..."
|
||||
DSH_E2E_URL="$URL" DSH_E2E_WORKSPACE="$WORKSPACE_DIR" \
|
||||
pnpm exec playwright test ${GREP_FILTER:+--grep "$GREP_FILTER"}
|
||||
|
||||
say "通过:插件挂载到真实 DSH 后无头渲染未崩溃"
|
||||
+87
-49
@@ -1,4 +1,4 @@
|
||||
# =============================================================================
|
||||
## =============================================================================
|
||||
# dsh-better-sidebar 一键安装脚本(官方 CLI 方式,Windows PowerShell 5.1+ / pwsh)
|
||||
#
|
||||
# 通过 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
|
||||
# # 本地保存后运行
|
||||
# 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(自动解析为最新)。
|
||||
# -Repair 修复模式:不重装插件,只确保 profile 的 pnpm-workspace.yaml
|
||||
# 放行 node-pty 构建脚本,然后重跑 pnpm install + pnpm rebuild
|
||||
# node-pty。
|
||||
# -Profile 目标 profile 名(缺省 web);安装与修复模式均适用。
|
||||
# -Restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅提示)。会断开当前页面会话。
|
||||
# -DryRun 只打印将要执行的操作,不写任何文件。
|
||||
#
|
||||
@@ -39,7 +45,9 @@
|
||||
param(
|
||||
[string]$Version = '',
|
||||
[switch]$Restart,
|
||||
[switch]$DryRun
|
||||
[switch]$DryRun,
|
||||
[switch]$Repair,
|
||||
[string]$Profile = 'web'
|
||||
)
|
||||
|
||||
$PKG = 'dsh-better-sidebar'
|
||||
@@ -53,7 +61,7 @@ if ($env:DSH_HOME) {
|
||||
} else {
|
||||
$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'
|
||||
$PATCH_YML = Join-Path $PROFILE_DIR 'cordis.patch.yml'
|
||||
|
||||
@@ -88,35 +96,10 @@ function Get-DshCli {
|
||||
return $null
|
||||
}
|
||||
|
||||
# 前置校验
|
||||
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(请先初始化 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 = @'
|
||||
# 步骤 1(安装与修复共用):预写 workspace 设置(幂等),保证 pnpm 不拦截
|
||||
# node-pty/protobufjs 构建脚本、放行本插件新版本
|
||||
function Ensure-WorkspaceSettings {
|
||||
$wsScript = @'
|
||||
const fs = require("fs");
|
||||
const p = process.argv[2];
|
||||
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);
|
||||
console.log(t === before ? "unchanged" : "updated");
|
||||
'@
|
||||
# PowerShell 5.1 把含内嵌双引号的多行 JS 作为参数传给 `node -e` 时,引号会被
|
||||
# Windows 命令行解析吞掉,导致 JS 语法错误(Expected ',', got ':')。
|
||||
# 改用临时文件方式,兼容 PS 5.1 与 pwsh 7。
|
||||
$wsJs = Join-Path $env:TEMP ("dshbs-ws-" + [guid]::NewGuid().ToString("N") + ".js")
|
||||
Set-Content -LiteralPath $wsJs -Value $wsScript -Encoding UTF8
|
||||
$wsOut = node $wsJs "$WS_YML" 2>&1
|
||||
$wsCode = $LASTEXITCODE
|
||||
Remove-Item -LiteralPath $wsJs -Force -ErrorAction SilentlyContinue
|
||||
$wsResult = (($wsOut | Out-String)).Trim()
|
||||
if ($wsCode -ne 0) { Die "处理 $WS_YML 失败(node 退出码 $wsCode):$wsResult" }
|
||||
if ($wsResult -eq 'updated') {
|
||||
Say "已确保 $WS_YML:allowBuilds(node-pty/protobufjs: true)+ minimumReleaseAgeExclude($PKG)"
|
||||
} else {
|
||||
Say 'workspace 设置已就绪,跳过'
|
||||
# PowerShell 5.1 把含内嵌双引号的多行 JS 作为参数传给 `node -e` 时,引号会被
|
||||
# Windows 命令行解析吞掉,导致 JS 语法错误(Expected ',', got ':')。
|
||||
# 改用临时文件方式,兼容 PS 5.1 与 pwsh 7。
|
||||
$wsJs = Join-Path $env:TEMP ("dshbs-ws-" + [guid]::NewGuid().ToString("N") + ".js")
|
||||
Set-Content -LiteralPath $wsJs -Value $wsScript -Encoding UTF8
|
||||
$wsOut = node $wsJs "$WS_YML" 2>&1
|
||||
$wsCode = $LASTEXITCODE
|
||||
Remove-Item -LiteralPath $wsJs -Force -ErrorAction SilentlyContinue
|
||||
$wsResult = (($wsOut | Out-String)).Trim()
|
||||
if ($wsCode -ne 0) { Die "处理 $WS_YML 失败(node 退出码 $wsCode):$wsResult" }
|
||||
if ($wsResult -eq 'updated') {
|
||||
Say "已确保 $WS_YML:allowBuilds(node-pty/protobufjs: true)+ minimumReleaseAgeExclude($PKG)"
|
||||
} else {
|
||||
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 自动注册(含挂载)
|
||||
if ($CLI -eq 'dsh') {
|
||||
$cliArgs = @('plugin', '--profile', 'web', 'add', "$PKG@$SPEC")
|
||||
$cliArgs = @('plugin', '--profile', $Profile, 'add', "$PKG@$SPEC")
|
||||
} 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
|
||||
$addCode = $LASTEXITCODE
|
||||
$addOut | ForEach-Object { $_ }
|
||||
|
||||
+85
-44
@@ -12,9 +12,14 @@
|
||||
#
|
||||
# 用法:
|
||||
# bash scripts/install.sh [版本] [--restart] [--dry-run]
|
||||
# bash scripts/install.sh --repair [--profile <名>] [--dry-run]
|
||||
#
|
||||
# 版本 npm 版本号/范围,缺省为 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 时仅打印提示)。
|
||||
# 注意:重启会断开当前 DSH 页面会话,默认不自动重启。
|
||||
# --dry-run 只打印将要执行的操作,不写任何文件。
|
||||
@@ -42,11 +47,15 @@ set -euo pipefail
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = "-h" ] || [ "$arg" = "--help" ]; then
|
||||
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
|
||||
--repair 修复模式:确保 profile 放行 node-pty 构建脚本并重装 node-pty(终端提示依赖加载失败时用)
|
||||
--profile 目标 profile 名(缺省 web)
|
||||
--restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅提示)
|
||||
--dry-run 只打印将要执行的操作,不写任何文件
|
||||
|
||||
@@ -57,30 +66,73 @@ EOF
|
||||
done
|
||||
|
||||
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}"
|
||||
PKG="dsh-better-sidebar"
|
||||
DSH_CMD="${DSH_CMD:-dsh}"
|
||||
|
||||
RESTART=false
|
||||
DRY_RUN=false
|
||||
REPAIR=false
|
||||
VERSION_SPEC=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
PROFILE_NAME="web"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--restart) RESTART=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) : ;; # 已在上面处理
|
||||
-*) echo "未知参数: ${arg}(用 -h 查看用法)" >&2; exit 2 ;;
|
||||
*) VERSION_SPEC="$arg" ;;
|
||||
-*) echo "未知参数: ${1}(用 -h 查看用法)" >&2; exit 2 ;;
|
||||
*) VERSION_SPEC="$1" ;;
|
||||
esac
|
||||
shift
|
||||
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' "$*"; }
|
||||
warn() { printf '\033[33m[warn]\033[0m %s\n' "$*" >&2; }
|
||||
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)
|
||||
resolve_spec() {
|
||||
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。"
|
||||
|
||||
[ -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")"
|
||||
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
|
||||
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] 步骤 4:幂等移除 $PATCH_YML 里旧的 better-sidebar 手动挂载行(避免双挂载)"
|
||||
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
|
||||
|
||||
# 步骤 1:预写 workspace 设置(幂等),保证 pnpm 不拦截构建、不放行旧版本策略
|
||||
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 设置已就绪,跳过"
|
||||
ensure_workspace_settings
|
||||
|
||||
# 步骤 2:官方 CLI 安装 + bundle 自动注册(含挂载)
|
||||
say "执行 $CLI plugin --profile web add $PKG@$SPEC ..."
|
||||
if ! $CLI plugin --profile web add "$PKG@$SPEC" 2>&1 | tail -n +1; then
|
||||
say "执行 $CLI plugin --profile $PROFILE_NAME add $PKG@$SPEC ..."
|
||||
if ! $CLI plugin --profile "$PROFILE_NAME" add "$PKG@$SPEC" 2>&1 | tail -n +1; then
|
||||
warn "dsh plugin add 失败。已预写 allowBuilds 与 minimumReleaseAgeExclude,仍失败的可能原因:"
|
||||
warn " - 网络/登录问题:npm registry 不可达或需要登录。"
|
||||
warn " - 依赖安装冲突:可手动重试 cd $PROFILE_DIR && pnpm install。"
|
||||
|
||||
+13
-5
@@ -12,8 +12,9 @@
|
||||
* Both registries share the same shell resolver and spawn-helper fix.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import * as nodePty from 'node-pty'
|
||||
import { ensureSpawnHelper } from './pty-manager.ts'
|
||||
import type { IPty } from 'node-pty'
|
||||
import { ensureSpawnHelper, shellSpawnArgs } from './pty-manager.ts'
|
||||
import { loadRequiredNodePty, type NodePtyModule } from './pty-deps.ts'
|
||||
import { SidebarError } from './wire.ts'
|
||||
|
||||
/** 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. */
|
||||
cwd: string
|
||||
/** The live pty process. */
|
||||
pty: nodePty.IPty
|
||||
pty: IPty
|
||||
/** Output accumulated since spawn (bounded; head dropped when over the limit). */
|
||||
transcript: string
|
||||
/** Whether the top-level process exited (transcript stays replayable). */
|
||||
@@ -189,7 +190,12 @@ export class AgentPtyRegistry {
|
||||
private readonly sessions = new Map<string, AgentTerminalHandle>()
|
||||
private readonly changeListeners = new Set<() => void>()
|
||||
|
||||
constructor(private readonly shell: string) {
|
||||
constructor(
|
||||
private readonly shell: string,
|
||||
private readonly shellArgs: string[] = [],
|
||||
/** The loaded node-pty module (injected so a broken install degrades instead of crashing the plugin). */
|
||||
private readonly nodePty: NodePtyModule = loadRequiredNodePty(),
|
||||
) {
|
||||
ensureSpawnHelper()
|
||||
}
|
||||
|
||||
@@ -208,10 +214,12 @@ export class AgentPtyRegistry {
|
||||
cwd: string,
|
||||
cols = 80,
|
||||
rows = 24,
|
||||
shell?: string,
|
||||
shellArgs?: string[],
|
||||
): string {
|
||||
const uuid = randomUUID()
|
||||
const dims = clampDims(cols, rows)
|
||||
const pty = nodePty.spawn(this.shell, [], {
|
||||
const pty = this.nodePty.spawn(shell ?? this.shell, shellSpawnArgs(shellArgs ?? this.shellArgs), {
|
||||
name: 'xterm-256color',
|
||||
cols: dims.cols,
|
||||
rows: dims.rows,
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import type { Context, SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts'
|
||||
|
||||
/** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */
|
||||
export const CHUNK_NAMES = ['terminal', 'editor'] as const
|
||||
export const CHUNK_NAMES = ['terminal', 'editor', 'mermaid'] as const
|
||||
export type ChunkName = (typeof CHUNK_NAMES)[number]
|
||||
|
||||
/** Directory of this host-half module (lib/ — the chunk scripts live next to it). */
|
||||
|
||||
@@ -90,7 +90,7 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
|
||||
title={t('refresh')}
|
||||
onClick={refresh}
|
||||
>
|
||||
<IconRefreshOutline16 />
|
||||
<IconRefreshOutline16 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{loading && <div className={css.gitPlaceholder}>{t('loading')}</div>}
|
||||
|
||||
+341
-23
@@ -1,22 +1,43 @@
|
||||
/**
|
||||
* The editor tab host: resolves a file's previewer through the sidebar
|
||||
* registry (`matchFileViewer`), fetches bytes per the matched viewer's
|
||||
* fetch strategy, and renders its component — or the shared download pane
|
||||
* when nothing can render the file. The header shows the file title; the
|
||||
* editable code/markdown viewers render their own toolbar below it.
|
||||
* The editor tab host: the single FILES WINDOW. It resolves a file's
|
||||
* previewer through the sidebar registry (`matchFileViewer`), fetches bytes
|
||||
* per the matched viewer's fetch strategy, and renders its component — or
|
||||
* the shared download pane when nothing can render the file. A tab without
|
||||
* 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
|
||||
* 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 clsx from 'clsx'
|
||||
import { IconCheckOutline16, IconFolderOpen16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { api, mediaUrl, type SessionScope } from './api.ts'
|
||||
import { BinaryDownload } from './binary-download.tsx'
|
||||
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 type { FileViewerDescriptor } from './service.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
import { relativeTo } from './paths.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'
|
||||
|
||||
type EditorLoad =
|
||||
@@ -25,11 +46,156 @@ type EditorLoad =
|
||||
| { status: 'ready'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown }
|
||||
| { status: 'binary' }
|
||||
|
||||
export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: SessionScope; path: string; title: string }) {
|
||||
const { ctx, store, scope, path, title } = props
|
||||
/** The docked tree panel's width bounds (drag-resize clamps into them). */
|
||||
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' })
|
||||
|
||||
// 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(() => {
|
||||
// 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
|
||||
// Aborts the matched viewer's `load` when the editor tears down (tab
|
||||
// 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))
|
||||
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 (
|
||||
<div className={css.editor}>
|
||||
<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>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* working directory. Levels load on expansion (one API call per directory),
|
||||
* directories sort first, hidden entries render dimmed, and the expansion
|
||||
* set lives in the per-session state. Clicking a file opens an editor tab.
|
||||
* The controlled file tree behind the files window's tree panel (TreePanel
|
||||
* wraps it with the search box): a lazy VSCode-style tree rooted at the
|
||||
* session's working directory. Levels load on expansion (one API call per
|
||||
* 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
|
||||
* 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
|
||||
* "copied" label replacing the button after a successful write); file rows
|
||||
* also offer a download action (the host serves raw bytes, binary-safe).
|
||||
* opens a context menu: file rows offer the caller's open escapes
|
||||
* (new tab / to the side, only when the callbacks exist) and a download
|
||||
* 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 clsx from 'clsx'
|
||||
import {
|
||||
IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16, IconFolderClose16, IconFolderOpen16,
|
||||
IconRefreshOutline16, Menu, writeClipboard,
|
||||
IconLinkOutline16, Menu, writeClipboard,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { api, downloadUrl, type FsEntry } from './api.ts'
|
||||
import { relativeTo } from './paths.ts'
|
||||
@@ -27,7 +32,7 @@ interface LevelData {
|
||||
}
|
||||
|
||||
/** 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 at = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
|
||||
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. */
|
||||
const COPIED_MS = 1200
|
||||
|
||||
export function ExplorerView(props: {
|
||||
export function FileTree(props: {
|
||||
sessionId: string
|
||||
cwd: string | undefined
|
||||
expanded: string[]
|
||||
onToggle: (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. */
|
||||
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 dataRef = useRef(data)
|
||||
const [refreshTick, setRefreshTick] = useState(0)
|
||||
/** The row whose path was just copied ("copied" label replaces its button). */
|
||||
const [copiedPath, setCopiedPath] = useState<string | null>(null)
|
||||
/** 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])
|
||||
|
||||
// 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(() => {
|
||||
// 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
|
||||
if (root === undefined) return
|
||||
loadDir(root)
|
||||
@@ -163,6 +183,7 @@ export function ExplorerView(props: {
|
||||
>
|
||||
{isOpen ? <IconFolderOpen16 size={14} /> : <IconFolderClose16 size={14} />}
|
||||
<span className={css.explorerName}>{entry.name}</span>
|
||||
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
|
||||
{rowActions(entry)}
|
||||
</div>
|
||||
{isOpen && renderLevel(entry.path, depth + 1)}
|
||||
@@ -174,9 +195,9 @@ export function ExplorerView(props: {
|
||||
key={entry.path}
|
||||
role="button"
|
||||
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 }}
|
||||
title={entry.path}
|
||||
title={entry.broken ? `${entry.path} — ${t('brokenSymlink')}` : entry.path}
|
||||
onClick={() => { onOpenFile(entry.path) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
@@ -188,6 +209,7 @@ export function ExplorerView(props: {
|
||||
>
|
||||
<IconCodeOutline16 size={14} />
|
||||
<span className={css.explorerName}>{entry.name}</span>
|
||||
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
|
||||
{rowActions(entry)}
|
||||
</div>
|
||||
)
|
||||
@@ -195,64 +217,53 @@ export function ExplorerView(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.explorer}>
|
||||
<div className={css.explorerHeader}>
|
||||
<span className={css.explorerRoot} title={root}>{root === undefined ? t('noSession') : baseName(root)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('refresh')}
|
||||
title={t('refresh')}
|
||||
onClick={() => {
|
||||
dataRef.current = {}
|
||||
setData({})
|
||||
setRefreshTick(tick => tick + 1)
|
||||
}}
|
||||
>
|
||||
<IconRefreshOutline16 />
|
||||
</button>
|
||||
</div>
|
||||
<div className={css.explorerBody}>
|
||||
{root === undefined ? (
|
||||
<div className={css.explorerEmpty}>{t('noSession')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={css.explorerRow}
|
||||
style={{ paddingLeft: 6 }}
|
||||
onContextMenu={(event) => { openRowMenu(event, root, true) }}
|
||||
>
|
||||
<IconFolderOpen16 size={14} />
|
||||
<span className={css.explorerName}>{baseName(root)}</span>
|
||||
{copiedPath === root
|
||||
? <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>
|
||||
<div className={css.explorerBody}>
|
||||
{root === undefined ? (
|
||||
<div className={css.explorerEmpty}>{t('noSession')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={css.explorerRow}
|
||||
style={{ paddingLeft: 6 }}
|
||||
onContextMenu={(event) => { openRowMenu(event, root, true) }}
|
||||
>
|
||||
<IconFolderOpen16 size={14} />
|
||||
<span className={css.explorerName}>{baseName(root)}</span>
|
||||
{copiedPath === root
|
||||
? <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)}
|
||||
</>
|
||||
)}
|
||||
{/*
|
||||
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
|
||||
open={rowMenu !== null}
|
||||
onClose={() => { setRowMenu(null) }}
|
||||
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).
|
||||
...(rowMenu?.isDir === false
|
||||
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={14} /> }]
|
||||
@@ -264,6 +275,14 @@ export function ExplorerView(props: {
|
||||
const target = rowMenu
|
||||
if (target === null) return
|
||||
setRowMenu(null)
|
||||
if (id === 'open-new-tab') {
|
||||
onOpenFileNewTab?.(target.path)
|
||||
return
|
||||
}
|
||||
if (id === 'open-side') {
|
||||
onOpenFileSide?.(target.path)
|
||||
return
|
||||
}
|
||||
if (id === 'download') {
|
||||
downloadFile(target.path)
|
||||
return
|
||||
@@ -297,7 +297,7 @@ export function GitView(props: {
|
||||
title={t('refresh')}
|
||||
onClick={() => { void refresh() }}
|
||||
>
|
||||
<IconRefreshOutline16 />
|
||||
<IconRefreshOutline16 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -24,12 +24,11 @@
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
/* Tall inventories (7 tab cards + 9 viewer cards + general rows) must
|
||||
never be clipped by the settings shell: fill the shell's options area
|
||||
and scroll inside the section when the content outgrows the panel. */
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
max-width: 760px;
|
||||
/* The DSH settings shell's `.options` area owns vertical scrolling. Keep
|
||||
this page a plain content column like Plugins → Plugin list so its
|
||||
scrollbar stays at the shell edge instead of being inset by the options
|
||||
area's right padding. */
|
||||
}
|
||||
|
||||
/* One-line section intro (the DSH section heading+intro recipe). */
|
||||
@@ -126,7 +125,8 @@
|
||||
}
|
||||
|
||||
.cardMain:focus-visible,
|
||||
.cardGear:focus-visible {
|
||||
.cardGear:focus-visible,
|
||||
.rowGear:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-l4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -266,6 +266,29 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* The gear icon button on a general settings ROW (the position-compat
|
||||
row's "customize the strip height" entry): the same 16px icon-button
|
||||
recipe as the card gear, sitting in the row's control area. */
|
||||
.rowGear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.rowGear:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Settings-row recipe (the DSH General section rows): title/desc left,
|
||||
control right, hairline separators (stripped on the last row). */
|
||||
.row {
|
||||
@@ -380,6 +403,59 @@
|
||||
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 {
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
@@ -393,10 +469,13 @@
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -405,16 +484,16 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 2px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
min-width: 0;
|
||||
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 {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.popupRows > :last-child {
|
||||
border-bottom: none;
|
||||
border-color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
/* The popup's Done button: the settings primary-button recipe
|
||||
@@ -634,3 +713,83 @@
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The managing plugin's identity badge: name + version pill, token-driven
|
||||
(labels via --dsw-alias-label-*, surface via --dsw-alias-bg-layer-2). */
|
||||
.versionBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-self: flex-start;
|
||||
padding: 4px 12px 4px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.versionBadgeName {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.versionBadgeTag {
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-alias-accent-soft, var(--dsw-alias-border-l2));
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Catalog search input: a compact text field above the list (token-driven).
|
||||
The entries list below it scrolls INDEPENDENTLY — with many plugins the
|
||||
modal stays navigable instead of growing unbounded. */
|
||||
.pluginSearch {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
appearance: none;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
}
|
||||
|
||||
.pluginSearch::placeholder {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.pluginSearch:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-l4);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* The entries area scrolls within the modal once the catalog grows past
|
||||
the viewport (max-height + own scrollbar keeps the modal shell fixed). */
|
||||
.pluginEntries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: 46vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
/* One catalog group: optional category heading above its stacked entries. */
|
||||
.pluginGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pluginGroupHeading {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
padding: 2px 2px 0;
|
||||
}
|
||||
|
||||
@@ -41,9 +41,11 @@
|
||||
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
IconCheckOutline16,
|
||||
IconChevronDownOutline14,
|
||||
IconPlusOutline16,
|
||||
IconSettingsOutline16,
|
||||
Input,
|
||||
Menu,
|
||||
Modal,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import clsx from 'clsx'
|
||||
@@ -52,6 +54,8 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
clampWidthPercent,
|
||||
TITLE_BAR_STRIP_MAX,
|
||||
TITLE_BAR_STRIP_MIN,
|
||||
WIDTH_PERCENT_MAX,
|
||||
WIDTH_PERCENT_MIN,
|
||||
type SidebarPrefs,
|
||||
@@ -207,6 +211,10 @@ export function FeatureSettingsRows(props: {
|
||||
* display (clamped for numbers, the current pref when the input is
|
||||
* invalid). Optional: rows with no handler keep their draft. */
|
||||
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
|
||||
* 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)
|
||||
@@ -214,12 +222,23 @@ export function FeatureSettingsRows(props: {
|
||||
* the latter collides with the inherited Object.prototype.valueOf.) */
|
||||
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])
|
||||
return (
|
||||
<div className={css.popupRows}>
|
||||
{toggles.map(toggle => {
|
||||
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') {
|
||||
return (
|
||||
<div key={toggle.key} className={css.popupRow}>
|
||||
@@ -300,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):
|
||||
* - `settings.render` (custom panel) when declared — rendered with the
|
||||
@@ -316,12 +428,14 @@ export function SettingsBody(props: {
|
||||
service: BetterSidebarService
|
||||
onToggle: (toggle: SidebarSettingToggle, next: boolean) => void
|
||||
onCommit: (toggle: SidebarSettingToggle, raw: string) => string
|
||||
onSelectValue: (toggle: SidebarSettingToggle, next: unknown) => void
|
||||
onPluginToggle: (toggle: SidebarSettingToggle, next: boolean) => void
|
||||
onPluginCommit: (toggle: SidebarSettingToggle, raw: string) => string
|
||||
onPluginSelectValue: (toggle: SidebarSettingToggle, next: unknown) => void
|
||||
onPluginWrite: (key: string, value: unknown) => 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
|
||||
if (render !== undefined) {
|
||||
return (
|
||||
@@ -354,6 +468,7 @@ export function SettingsBody(props: {
|
||||
prefs={prefs}
|
||||
onToggle={onToggle}
|
||||
onCommit={onCommit}
|
||||
onSelectValue={onSelectValue}
|
||||
/>
|
||||
)}
|
||||
{pluginToggles.length > 0 && (
|
||||
@@ -362,6 +477,7 @@ export function SettingsBody(props: {
|
||||
prefs={prefs}
|
||||
onToggle={onPluginToggle}
|
||||
onCommit={onPluginCommit}
|
||||
onSelectValue={onPluginSelectValue}
|
||||
valueSource={(key) => pluginBlob[key]}
|
||||
/>
|
||||
)}
|
||||
@@ -380,6 +496,8 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// Which feature's secondary settings popup is open (null = closed).
|
||||
const [settingsFor, setSettingsFor] = useState<TabDescriptor | FileViewerDescriptor | null>(null)
|
||||
// Whether the position-compat strip popup (the gear on the 常规 row) is open.
|
||||
const [stripSettingsOpen, setStripSettingsOpen] = useState(false)
|
||||
// Whether the "add plugin" modal (a dashed card at the end of the
|
||||
// 侧边栏内容 / 文件预览 grids) is open, and for which extension point
|
||||
// (null = closed).
|
||||
@@ -489,6 +607,12 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
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
|
||||
* and clamped to the toggle's declared min/max (an unparsable input falls
|
||||
@@ -612,6 +736,15 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
<div className={css.section}>
|
||||
<p className={css.intro}>{t('settingsIntro')}</p>
|
||||
|
||||
{/* The managing plugin's own identity: name + version badge, so the
|
||||
section is attributable at a glance (the version is the service
|
||||
instance's, kept in lockstep with package.json by
|
||||
tests/service.spec.ts). */}
|
||||
<div className={css.versionBadge}>
|
||||
<span className={css.versionBadgeName}>DSH-better-sidebar</span>
|
||||
<span className={css.versionBadgeTag}>v{service.version}</span>
|
||||
</div>
|
||||
|
||||
{/* 常规: the DSH settings-row recipe — title/desc left, control right. */}
|
||||
<div className={css.group}>
|
||||
<div className={css.groupHeading}>{t('settingsGeneralTitle')}</div>
|
||||
@@ -660,6 +793,36 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
onChange={(next) => { applyPref({ interceptOpenPath: next }) }}
|
||||
/>
|
||||
</div>
|
||||
<div className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsTitleBarTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsTitleBarDesc')}</span>
|
||||
</span>
|
||||
<span className={css.control}>
|
||||
{/*
|
||||
The position-compat row's gear (same popup pattern as the
|
||||
feature cards): opens a Modal with the strip-height number row.
|
||||
Hidden while the mode is off — its related setting is dormant
|
||||
then (the feature-card convention).
|
||||
*/}
|
||||
{prefs.titleBarCompat && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.rowGear}
|
||||
aria-label={`${t('settingsTitleBarTitle')} ${t('settingsPopup')}`}
|
||||
title={t('settingsPopup')}
|
||||
onClick={() => { setStripSettingsOpen(true) }}
|
||||
>
|
||||
<IconSettingsOutline16 size={14} />
|
||||
</button>
|
||||
)}
|
||||
<Switch
|
||||
label={t('settingsTitleBarTitle')}
|
||||
checked={prefs.titleBarCompat}
|
||||
onChange={(next) => { applyPref({ titleBarCompat: next }) }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 侧边栏内容: one small card per registered tab type in a responsive
|
||||
@@ -773,8 +936,10 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
prefs={prefs}
|
||||
onToggle={onToggleSetting}
|
||||
onCommit={onCommitSetting}
|
||||
onSelectValue={onSelectSetting}
|
||||
onPluginToggle={(toggle, next) => { onPluginToggle(settingsFor.id, toggle, next) }}
|
||||
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) }}
|
||||
onClose={() => { setSettingsFor(null) }}
|
||||
store={store}
|
||||
@@ -783,6 +948,41 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* The position-compat strip popup (opened by the gear on the 常规
|
||||
row): one number row for the reserved strip height in px. Same
|
||||
Modal chrome and row machinery as the feature popups — mounted
|
||||
only while open (the Modal SSR rule above). */}
|
||||
{stripSettingsOpen && (
|
||||
<Modal
|
||||
open
|
||||
onClose={() => { setStripSettingsOpen(false) }}
|
||||
title={t('settingsTitleBarTitle')}
|
||||
description={t('settingsPopupDesc', { feature: t('settingsTitleBarTitle') })}
|
||||
closeLabel={t('close')}
|
||||
className={css.popupDialog}
|
||||
footer={(
|
||||
<button type="button" className={css.done} onClick={() => { setStripSettingsOpen(false) }}>
|
||||
{t('settingsDone')}
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<FeatureSettingsRows
|
||||
toggles={[{
|
||||
key: 'titleBarStripPx',
|
||||
type: 'number',
|
||||
title: () => t('settingsTitleBarStripTitle'),
|
||||
desc: () => t('settingsTitleBarStripDesc'),
|
||||
min: TITLE_BAR_STRIP_MIN,
|
||||
max: TITLE_BAR_STRIP_MAX,
|
||||
unit: 'px',
|
||||
}]}
|
||||
prefs={prefs}
|
||||
onToggle={onToggleSetting}
|
||||
onCommit={onCommitSetting}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* The "add plugin" modal (opened by the dashed cards above): declares
|
||||
the extension point of the clicked kind, opens the GitHub topic,
|
||||
and lists the matching recommended plugin catalog with per-entry
|
||||
|
||||
+316
-83
@@ -1,16 +1,19 @@
|
||||
/**
|
||||
* The sidebar shell: fixed-position panels portalled onto document.body
|
||||
* (the core AppFrame owns the left sidebar / center / details columns and
|
||||
* has no right-side hole for plugins). The right panel hosts the original
|
||||
* workbench; the bottom panel hosts a second, independent workbench. The
|
||||
* bottom panel squeezes ONLY the center column (the agent output area): it
|
||||
* spans from the app shell's own left sidebar to the right panel's left
|
||||
* edge, so neither sidebar gives up any position (the right panel keeps its
|
||||
* full height). A persistent two-button cluster at the top-right corner
|
||||
* toggles each panel; the right panel's width drags from its left edge, the
|
||||
* bottom panel's height from its top edge, and the shared corner drags both
|
||||
* at once. The whole layout lives in the per-session store, so switching
|
||||
* conversations swaps the sidebar.
|
||||
* The sidebar shell: panels mounted inside the unified panel host — a
|
||||
* fixed, viewport-sized containing block ([data-dsh-panel-host]) appended
|
||||
* to document.body — instead of individual fixed-position elements, so a
|
||||
* desktop shell's intermediate wrapper transforms can never hijack the
|
||||
* panels' fixed containing block (the core AppFrame owns the left sidebar /
|
||||
* center / details columns and has no right-side hole for plugins). The
|
||||
* right panel hosts the original workbench; the bottom panel hosts a
|
||||
* second, independent workbench. The bottom panel squeezes ONLY the center
|
||||
* column (the agent output area): it spans from the app shell's own left
|
||||
* sidebar to the right panel's left edge, so neither sidebar gives up any
|
||||
* position (the right panel keeps its full height). A persistent two-button
|
||||
* cluster at the top-right corner toggles each panel; the right panel's
|
||||
* width drags from its left edge, the bottom panel's height from its top
|
||||
* edge, and the shared corner drags both at once. The whole layout lives in
|
||||
* the per-session store, so switching conversations swaps the sidebar.
|
||||
*
|
||||
* The shell binds the workbench actions to the store and dispatches tab
|
||||
* content to the views. New tabs come from the + menu (explorer / git /
|
||||
@@ -40,6 +43,7 @@ import {
|
||||
import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
|
||||
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
|
||||
import { useNarrowViewport } from './breakpoints.ts'
|
||||
import { parseDesktopEnv } from './desktop-env.ts'
|
||||
import type { NewTabOption } from './TabBar.tsx'
|
||||
import type { TabDragPayload } from './TabBar.tsx'
|
||||
import { relativeTo } from './paths.ts'
|
||||
@@ -137,6 +141,37 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// keep living in the right tree.
|
||||
const narrow = useNarrowViewport()
|
||||
|
||||
// On-screen keyboard / visual-viewport inset (mobile, split-screen, …):
|
||||
// when the visual viewport shrinks below the layout viewport, bottom-
|
||||
// anchored panels would hide under the keyboard. Track the inset and
|
||||
// offset the bottom-anchored surfaces by it. The obscured bottom strip is
|
||||
// innerHeight − (vv.height + vv.offsetTop): offsetTop is nonzero while
|
||||
// the visual viewport is scrolled/zoomed under browser chrome, so
|
||||
// omitting it would over-lift the panels (CR #232 P2). offsetTop changes
|
||||
// through the viewport's scroll event too, so both events are listened.
|
||||
// Guarded: browsers without visualViewport (older WebViews, jsdom) stay
|
||||
// at 0. rAF-throttled, same pattern as useNarrowViewport.
|
||||
const [keyboardInset, setKeyboardInset] = useState(0)
|
||||
useEffect(() => {
|
||||
const vv = window.visualViewport
|
||||
if (vv === null || vv === undefined) return
|
||||
let frame: number | null = null
|
||||
const measure = (): void => {
|
||||
frame = null
|
||||
const inset = Math.max(0, window.innerHeight - (vv.height + vv.offsetTop))
|
||||
setKeyboardInset(inset > 1 ? Math.round(inset) : 0)
|
||||
}
|
||||
const onResize = (): void => { if (frame === null) frame = requestAnimationFrame(measure) }
|
||||
vv.addEventListener('resize', onResize)
|
||||
vv.addEventListener('scroll', onResize)
|
||||
measure()
|
||||
return () => {
|
||||
vv.removeEventListener('resize', onResize)
|
||||
vv.removeEventListener('scroll', onResize)
|
||||
if (frame !== null) cancelAnimationFrame(frame)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Current conversation (the sessions list feed).
|
||||
const sessionList = useSyncExternalStore(
|
||||
useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]),
|
||||
@@ -167,6 +202,40 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
return () => { document.body.removeAttribute('data-dsh-sidebar-collapsed') }
|
||||
}, [collapsed])
|
||||
|
||||
// Position compatibility mode (titleBarCompat pref): Windows frameless
|
||||
// windows draw the native title bar (minimize/maximize/close) at the
|
||||
// window's top-right corner, OVER the web content. When enabled, the body
|
||||
// attribute lets sidebar.module.css drop the toggle cluster below the
|
||||
// strip and push the right panel's content below it. Auto-enabled in the
|
||||
// win32 advanced desktop shell (the shell stamps the URL — see
|
||||
// desktop-env.ts — and reserves a 32px overlay for the window controls);
|
||||
// the manual pref and its strip height still win when the user sets them,
|
||||
// and the manual switch only ever ADDS compat. The strip height rides a
|
||||
// CSS variable so the rules stay declarative; the attribute rides the
|
||||
// snapshot's prefs, so flipping the setting re-renders and re-applies
|
||||
// immediately; the cleanup removes both on unmount/boundary swap so a
|
||||
// crashed sidebar never leaves them behind.
|
||||
const desktopEnv = parseDesktopEnv()
|
||||
const autoTitleBarCompat = desktopEnv.win32OverlayTop > 0
|
||||
const titleBarCompat = snapshot.prefs.titleBarCompat || autoTitleBarCompat
|
||||
const titleBarStrip = snapshot.prefs.titleBarCompat
|
||||
? snapshot.prefs.titleBarStripPx
|
||||
: desktopEnv.win32OverlayTop
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
if (titleBarCompat) {
|
||||
document.body.setAttribute('data-dsh-title-bar-compat', '')
|
||||
root.style.setProperty('--dsh-title-bar-strip', `${titleBarStrip}px`)
|
||||
} else {
|
||||
document.body.removeAttribute('data-dsh-title-bar-compat')
|
||||
root.style.removeProperty('--dsh-title-bar-strip')
|
||||
}
|
||||
return () => {
|
||||
document.body.removeAttribute('data-dsh-title-bar-compat')
|
||||
root.style.removeProperty('--dsh-title-bar-strip')
|
||||
}
|
||||
}, [titleBarCompat, titleBarStrip])
|
||||
|
||||
/**
|
||||
* Bottom-panel merge on narrow viewports: whenever a session is current
|
||||
* while narrow (mount, session switch, or a desktop→narrow transition),
|
||||
@@ -388,13 +457,28 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
const watcher = new MutationObserver(locate)
|
||||
const root = document.getElementById('root')
|
||||
if (root !== null) watcher.observe(root, { childList: true })
|
||||
// The layout push writes --dsh-sidebar-* on <html>. A HMR re-activation
|
||||
// clears those variables on teardown and re-writes them on setup — and
|
||||
// that is also the moment the shell may have re-created the center
|
||||
// column under a REUSED #root child (React swaps nodes in place, so
|
||||
// #root's childList never changes and the watcher above never fires).
|
||||
// Watching <html>'s style attribute catches that re-sync: the push
|
||||
// rewrite re-locates and re-measures, so the bottom panel recovers
|
||||
// instead of staying hidden on a stale {0,0} center rect.
|
||||
const htmlStyleWatcher = new MutationObserver(locate)
|
||||
htmlStyleWatcher.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] })
|
||||
return () => {
|
||||
disposed = true
|
||||
observer?.disconnect()
|
||||
watcher.disconnect()
|
||||
htmlStyleWatcher.disconnect()
|
||||
centerColRef.current = null
|
||||
}
|
||||
}, [measureCenter])
|
||||
// Opening the bottom panel re-runs the whole locate/measure chain: a
|
||||
// panel opened before the center column was ever found must not stay
|
||||
// invisible forever (the HMR recovery path depends on the observers
|
||||
// above, this is the belt-and-braces retry for the open moment itself).
|
||||
}, [measureCenter, state?.bottomOpen])
|
||||
|
||||
/**
|
||||
* Bottom-panel first-expansion auto terminal: the FIRST time the user
|
||||
@@ -435,7 +519,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// pointer up (clamping + persistence).
|
||||
const panelRef = useRef<HTMLDivElement | null>(null)
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||
const cornerRef = useRef<HTMLDivElement | null>(null)
|
||||
const widthDrag = useRef({ startX: 0, startWidth: 0 })
|
||||
const [draggingWidth, setDraggingWidth] = useState(false)
|
||||
const bottomDrag = useRef({ startY: 0, startHeight: 0 })
|
||||
@@ -460,10 +543,24 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
const clampHeight = (height: number): number =>
|
||||
Math.min(Math.max(BOTTOM_MIN, Math.round(height)), Math.max(BOTTOM_MIN, window.innerHeight - PANEL_MIN))
|
||||
|
||||
/** Single writer for the layout-push variables: the app shell gives up
|
||||
* the panel's width/height while open (0 while collapsed) through
|
||||
* layout.css's margins. Every size change — drag frames and committed
|
||||
* state — flows through here so the push never forks between paths. */
|
||||
const writeGeometry = (width: number, height: number): void => {
|
||||
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
|
||||
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
|
||||
// The corner handle positions itself relative to the panel (CSS
|
||||
// `bottom: calc(var(--dsh-sidebar-height) + 6px)`), so these two layout
|
||||
// variables are all it needs — no viewport coordinates written here
|
||||
// (issue #106: skins that inset the panels must not fight JS coords).
|
||||
}
|
||||
|
||||
/** Apply a drag size to the DOM without touching React state or the store.
|
||||
* The bottom panel's right edge tracks the right panel's left edge HERE
|
||||
* too — React state only updates on release, so the inline right must be
|
||||
* written directly or the bottom panel would lag the sidebar mid-drag. */
|
||||
* written directly or the bottom panel would lag the sidebar mid-drag.
|
||||
* The layout push rides the shared writer (writeGeometry). */
|
||||
const applyDrag = (width: number, height: number): void => {
|
||||
panelRef.current?.style.setProperty('width', `${width}px`)
|
||||
bottomRef.current?.style.setProperty('height', `${height}px`)
|
||||
@@ -472,12 +569,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// `width + detailsWidth` — derived from the measured column, keeping the
|
||||
// drag write-only (no React re-render mid-drag).
|
||||
bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRect.right) + (width - (state?.width ?? 0))}px`)
|
||||
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
|
||||
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
|
||||
if (cornerRef.current !== null) {
|
||||
cornerRef.current.style.left = `${window.innerWidth - width - 6}px`
|
||||
cornerRef.current.style.top = `${window.innerHeight - height - 6}px`
|
||||
}
|
||||
writeGeometry(width, height)
|
||||
}
|
||||
|
||||
// Drags write at most once per frame: pointer events fire several times
|
||||
@@ -509,6 +601,91 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
pendingDrag.current = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize a drag on pointer up: flush the LAST drag frame to the DOM
|
||||
* synchronously, then commit the SAME clamped values to the store. A fast
|
||||
* release cancels the rAF before it ran — without the flush the DOM would
|
||||
* sit at the pre-drag size until React re-renders with the committed
|
||||
* value, and a value that never made it into a move handler would never
|
||||
* be applied at all. The measurement pause ends here too: the center
|
||||
* column is re-measured BEFORE the committed re-render lands, so the
|
||||
* bottom panel's React-rendered right edge already reflects the new
|
||||
* width (otherwise the re-render would re-apply the stale rect — the
|
||||
* bottom panel visibly jumps for one frame).
|
||||
*/
|
||||
const commitDrag = (
|
||||
width: number,
|
||||
height: number,
|
||||
reduce: (state: SidebarState) => SidebarState,
|
||||
): void => {
|
||||
stopDragScheduling()
|
||||
applyDrag(width, height)
|
||||
draggingRef.current = false
|
||||
measureCenter()
|
||||
store.reduce(reduce)
|
||||
}
|
||||
|
||||
/** Set once a drag's pointerup handler commits — premature capture loss
|
||||
* (pointercancel / lostpointercapture without pointerup) must then be told
|
||||
* apart from a normal release. */
|
||||
const dragCommitted = useRef(false)
|
||||
/**
|
||||
* Abort a drag whose pointer stream was interrupted (pointercancel, or
|
||||
* capture lost before pointerup): no pointerup will arrive, so without
|
||||
* this the dragging state would stick true and center-column measurement
|
||||
* would stay paused forever — the bottom panel freezes at stale edges and
|
||||
* stops tracking sidebar/app-rail layout changes.
|
||||
*
|
||||
* A FAST release is the common trigger: browsers merge pointermove bursts,
|
||||
* and an ultra-fast flick can cancel the stream before ANY move lands.
|
||||
* The commit order is therefore: the LAST KNOWN dragged size (the rAF
|
||||
* pending value) first, then the interrupting event's own pointer
|
||||
* position (pointercancel / lostpointercapture still carry coordinates),
|
||||
* and only a drag that produced neither (pure down+up at the same spot)
|
||||
* reverts to the store's committed sizes.
|
||||
*/
|
||||
const abortDrag = (reset: () => void, event?: { clientX: number; clientY: number }): void => {
|
||||
if (dragCommitted.current) return
|
||||
const pending = pendingDrag.current
|
||||
let width: number | undefined
|
||||
let height: number | undefined
|
||||
if (pending !== null) {
|
||||
width = pending.width
|
||||
height = pending.height
|
||||
} else if (event !== undefined) {
|
||||
// No move ever landed: the cancel/lost-capture position is all we
|
||||
// have — commit it (clamped) instead of rolling back the flick.
|
||||
if (draggingWidth) {
|
||||
width = clampWidth(widthDrag.current.startWidth + (widthDrag.current.startX - event.clientX))
|
||||
height = state?.bottomOpen === true ? Math.min(state.bottomHeight, window.innerHeight) : 0
|
||||
} else if (draggingBottom) {
|
||||
width = Math.min(state?.width ?? 0, window.innerWidth)
|
||||
height = clampHeight(bottomDrag.current.startHeight + (bottomDrag.current.startY - event.clientY))
|
||||
} else if (draggingCorner) {
|
||||
width = clampWidth(cornerDrag.current.startWidth + (cornerDrag.current.startX - event.clientX))
|
||||
height = clampHeight(cornerDrag.current.startHeight + (cornerDrag.current.startY - event.clientY))
|
||||
}
|
||||
}
|
||||
if (width !== undefined && height !== undefined) {
|
||||
pendingDrag.current = null
|
||||
if (dragFrame.current !== null) {
|
||||
cancelAnimationFrame(dragFrame.current)
|
||||
dragFrame.current = null
|
||||
}
|
||||
applyDrag(width, height)
|
||||
draggingRef.current = false
|
||||
measureCenter()
|
||||
store.reduce(s => setBottomHeight(setWidth(s, width), height))
|
||||
} else {
|
||||
stopDragScheduling()
|
||||
applyDrag(
|
||||
!narrow && state?.panelOpen === true ? Math.min(state?.width ?? 0, window.innerWidth) : 0,
|
||||
!narrow && state?.bottomOpen === true ? Math.min(state.bottomHeight, window.innerHeight) : 0,
|
||||
)
|
||||
}
|
||||
reset()
|
||||
}
|
||||
|
||||
// Layout push: the app shell gives up the panel's width/height while the
|
||||
// panels are open (0 while collapsed), so the conversation and input bar
|
||||
// are squeezed instead of covered. The margins are capped at the viewport
|
||||
@@ -523,8 +700,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
const height = !narrow && snapshot.state?.bottomOpen === true
|
||||
? Math.min(snapshot.state.bottomHeight, window.innerHeight)
|
||||
: 0
|
||||
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
|
||||
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
|
||||
writeGeometry(width, height)
|
||||
// Unmount must release the push (issue #31): when the boundary swaps the
|
||||
// whole sidebar after a render crash (or the plugin fiber is disposed /
|
||||
// HMR), the CSS variables would otherwise stay on <html> and layout.css
|
||||
@@ -614,19 +790,21 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
|
||||
if (state === undefined || sessionId === undefined) {
|
||||
return (
|
||||
<div className={css.toggleCluster}>
|
||||
{!narrow && (
|
||||
<div data-dsh-panel-host>
|
||||
<div className={css.toggleCluster}>
|
||||
{!narrow && (
|
||||
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
|
||||
<IconPanelBottomOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
|
||||
<IconPanelBottomOutline16 />
|
||||
<IconPanelRightOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
|
||||
<IconPanelRightOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -698,7 +876,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div data-dsh-panel-host>
|
||||
{/*
|
||||
The persistent toggle cluster at the top-right corner: the bottom
|
||||
panel's button (bottom glyph) LEFT of the right panel's (side glyph).
|
||||
@@ -747,15 +925,24 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={clsx(css.panel, !state.panelOpen && css.panelHidden)}
|
||||
style={{ width: narrow ? '100vw' : Math.min(state.width, window.innerWidth) }}
|
||||
style={{
|
||||
width: narrow ? '100vw' : Math.min(state.width, window.innerWidth),
|
||||
// Narrow drawer: keep the bottom-anchored sheet above the on-screen
|
||||
// keyboard (visualViewport inset); desktop panels are full-height
|
||||
// and unaffected.
|
||||
bottom: narrow && keyboardInset > 0 ? `${keyboardInset}px` : undefined,
|
||||
}}
|
||||
|
||||
data-dragging={anyDragging || undefined}
|
||||
>
|
||||
{!narrow && (
|
||||
<div
|
||||
className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)}
|
||||
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
dragCommitted.current = false
|
||||
widthDrag.current = { startX: event.clientX, startWidth: state.width }
|
||||
setDraggingWidth(true)
|
||||
}}
|
||||
@@ -768,12 +955,23 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
dragCommitted.current = true
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
const { startX, startWidth } = widthDrag.current
|
||||
stopDragScheduling()
|
||||
store.reduce(s => setWidth(s, startWidth + (startX - event.clientX)))
|
||||
// A very fast release can merge/lose pointermove bursts:
|
||||
// prefer the LAST KNOWN dragged width (already clamped by
|
||||
// the move handler) over the up position, which may equal
|
||||
// the down position when no move landed.
|
||||
const pending = pendingDrag.current
|
||||
const width = pending !== null
|
||||
? pending.width
|
||||
: clampWidth(startWidth + (startX - event.clientX))
|
||||
const height = state.bottomOpen ? Math.min(state.bottomHeight, window.innerHeight) : 0
|
||||
commitDrag(width, height, s => setWidth(s, width))
|
||||
setDraggingWidth(false)
|
||||
}}
|
||||
onPointerCancel={() => { abortDrag(() => setDraggingWidth(false)) }}
|
||||
onLostPointerCapture={() => { abortDrag(() => setDraggingWidth(false)) }}
|
||||
/>
|
||||
)}
|
||||
<div className={css.panelBody}>
|
||||
@@ -787,6 +985,61 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
getTabBadge={tabBadgeOf}
|
||||
/>
|
||||
</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)
|
||||
dragCommitted.current = false
|
||||
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
|
||||
dragCommitted.current = true
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
const { startX, startY, startWidth, startHeight } = cornerDrag.current
|
||||
// Last known dragged size wins over the up position (fast
|
||||
// releases can merge/lose pointermove bursts).
|
||||
const pending = pendingDrag.current
|
||||
const width = pending !== null
|
||||
? pending.width
|
||||
: clampWidth(startWidth + (startX - event.clientX))
|
||||
const height = pending !== null
|
||||
? pending.height
|
||||
: clampHeight(startHeight + (startY - event.clientY))
|
||||
commitDrag(width, height, s => setBottomHeight(setWidth(s, width), height))
|
||||
setDraggingCorner(false)
|
||||
}}
|
||||
onPointerCancel={() => { abortDrag(() => setDraggingCorner(false)) }}
|
||||
onLostPointerCapture={() => { abortDrag(() => setDraggingCorner(false)) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/*
|
||||
The bottom panel: a second, independent workbench. It squeezes ONLY
|
||||
@@ -797,6 +1050,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
down like the right panel. On NARROW viewports it does not exist —
|
||||
the bottom workbench lives inside the drawer (MobileWorkbench).
|
||||
*/}
|
||||
{/* The bottom panel only becomes VISIBLE once the center column is
|
||||
measured: before that, `centerRect` is the {0,0} fallback and
|
||||
`right` computes to the full viewport width — the panel (and its
|
||||
overflow content) would flash full-width for a frame until the
|
||||
first measurement lands. Rendering stays unconditional so the
|
||||
mount/render chain (auto-terminal etc.) is never gated on
|
||||
geometry. */}
|
||||
{!narrow && (
|
||||
<div
|
||||
ref={bottomRef}
|
||||
@@ -804,6 +1064,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
style={{
|
||||
height: Math.min(state.bottomHeight, window.innerHeight),
|
||||
left: centerRect.left,
|
||||
// Keep the panel above the on-screen keyboard when the visual
|
||||
// viewport shrinks (see the keyboardInset effect).
|
||||
bottom: keyboardInset > 0 ? `${keyboardInset}px` : undefined,
|
||||
// Direct from the center column's measured right edge: the bottom
|
||||
// panel spans ONLY the center column, ending exactly at the
|
||||
// details column's left edge (the details column sits between the
|
||||
@@ -814,14 +1077,20 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// (the right panel's border-left alone is covered by this panel's
|
||||
// fill — without it the corner looks cut off).
|
||||
borderRight: state.panelOpen ? '1px solid var(--dsw-alias-border-l2)' : undefined,
|
||||
// Unmeasured center column → keep the panel invisible (zero-size
|
||||
// geometry would flash full-width overflow instead).
|
||||
visibility: centerRect.right > 0 ? undefined : 'hidden',
|
||||
}}
|
||||
|
||||
data-dragging={(draggingBottom || draggingCorner) || undefined}
|
||||
>
|
||||
<div
|
||||
className={clsx(css.bottomResize, draggingBottom && css.bottomResizeActive)}
|
||||
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
dragCommitted.current = false
|
||||
bottomDrag.current = { startY: event.clientY, startHeight: state.bottomHeight }
|
||||
setDraggingBottom(true)
|
||||
}}
|
||||
@@ -833,12 +1102,20 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
dragCommitted.current = true
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
const { startY, startHeight } = bottomDrag.current
|
||||
stopDragScheduling()
|
||||
store.reduce(s => setBottomHeight(s, startHeight + (startY - event.clientY)))
|
||||
// Last known dragged height wins over the up position (fast
|
||||
// releases can merge/lose pointermove bursts).
|
||||
const pending = pendingDrag.current
|
||||
const height = pending !== null
|
||||
? pending.height
|
||||
: clampHeight(startHeight + (startY - event.clientY))
|
||||
commitDrag(Math.min(state.width, window.innerWidth), height, s => setBottomHeight(s, height))
|
||||
setDraggingBottom(false)
|
||||
}}
|
||||
onPointerCancel={() => { abortDrag(() => setDraggingBottom(false)) }}
|
||||
onLostPointerCapture={() => { abortDrag(() => setDraggingBottom(false)) }}
|
||||
/>
|
||||
{/*
|
||||
The bottom panel's own close control at its tab strip's right end
|
||||
@@ -869,50 +1146,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
</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)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+39
-3
@@ -74,6 +74,38 @@ export function TabBar(props: {
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Middle-click close: the press target is recorded on middle mousedown
|
||||
// (preventDefaulted to disarm Chrome's middle-click autoscroll — its
|
||||
// indicator is inert here because the strip hides its scrollbar and only
|
||||
// the wheel handler scrolls) and the close settles on the first middle
|
||||
// mouseup OVER that same tab. Release-position semantics match VS Code
|
||||
// (microsoft/vscode#101028) and what users expect from Chrome tabs
|
||||
// (crbug/40679924): pressing on a tab and releasing elsewhere cancels the
|
||||
// close. The browser dispatches auxclick to the nearest common ancestor of
|
||||
// the press/release targets when they differ, so any drift, autoscroll
|
||||
// scroll, or tab-list reflow between press and release would otherwise
|
||||
// swallow the close; settling on the recorded press target at mouseup
|
||||
// keeps release semantics without depending on auxclick delivery.
|
||||
const onCloseRef = useRef(onClose)
|
||||
const middlePressed = useRef<{ id: string; node: HTMLElement } | null>(null)
|
||||
useEffect(() => {
|
||||
onCloseRef.current = onClose
|
||||
})
|
||||
useEffect(() => {
|
||||
const onMouseUp = (event: MouseEvent): void => {
|
||||
if (event.button !== 1) return
|
||||
const pressed = middlePressed.current
|
||||
middlePressed.current = null
|
||||
// Close only when the release lands on the pressed tab; a drag-away
|
||||
// release cancels the press (one-shot per press).
|
||||
if (pressed !== null && pressed.node.isConnected && pressed.node.contains(event.target as Node)) {
|
||||
onCloseRef.current(pressed.id)
|
||||
}
|
||||
}
|
||||
window.addEventListener('mouseup', onMouseUp)
|
||||
return () => { window.removeEventListener('mouseup', onMouseUp) }
|
||||
}, [])
|
||||
|
||||
// Wheel over the strip scrolls the tab row horizontally (a plain mouse
|
||||
// wheel emits deltaY, which overflow-x alone never consumes). Bound as a
|
||||
// native NON-passive listener: React registers onWheel passively at the
|
||||
@@ -151,11 +183,15 @@ export function TabBar(props: {
|
||||
if (payload !== null) onDropTab(payload, tab.id)
|
||||
}}
|
||||
onClick={() => { onActivate(tab.id) }}
|
||||
onAuxClick={(event) => {
|
||||
// Middle-click closes the tab (and suppresses autoscroll).
|
||||
onMouseDown={(event) => {
|
||||
// Middle-click close: record the press target and disarm
|
||||
// Chrome's middle-click autoscroll (its indicator is inert
|
||||
// here — the strip scrolls via the wheel handler only). The
|
||||
// close itself settles on the first middle mouseup over this
|
||||
// same tab (window-level), keeping release semantics.
|
||||
if (event.button === 1) {
|
||||
event.preventDefault()
|
||||
onClose(tab.id)
|
||||
middlePressed.current = { id: tab.id, node: event.currentTarget }
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -20,20 +20,32 @@
|
||||
* reconnect grace.
|
||||
*/
|
||||
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 '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 { 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 { isDarkScheme, subscribeColorScheme, tokenValue } from './theme.ts'
|
||||
import { isDarkScheme, subscribeColorScheme, effectiveTokenValue, tokenValue } from './theme.ts'
|
||||
import { resolveTerminalFont } from './terminal-font.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** How many consecutive unreasoned failures before showing the error banner. */
|
||||
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,
|
||||
* 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). */
|
||||
function xtermTheme(): ITheme {
|
||||
const dark = isDarkScheme()
|
||||
const background = tokenValue('--dsw-alias-bg-base') || (dark ? '#111114' : '#ffffff')
|
||||
const foreground = tokenValue('--dsw-alias-label-primary') || (dark ? '#e6e6e6' : '#1a1a1a')
|
||||
// Skin systems set --dsw-alias-bg-base to `transparent` or translucent
|
||||
// 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 {
|
||||
background,
|
||||
foreground,
|
||||
@@ -78,6 +96,7 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [fatal, setFatal] = useState<string | null>(null)
|
||||
const [depsFatal, setDepsFatal] = useState<TerminalDepsInfo | null>(null)
|
||||
const [lastUrl, setLastUrl] = useState<string | null>(null)
|
||||
const connectRef = useRef<(() => void) | null>(null)
|
||||
|
||||
@@ -151,6 +170,25 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
||||
}
|
||||
socket.onclose = (event) => {
|
||||
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
|
||||
// forever would only spin the banner, so surface it with a retry.
|
||||
if (event.code === 1011 && event.reason !== '') {
|
||||
@@ -257,6 +295,9 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
||||
|
||||
return (
|
||||
<div className={css.terminalWrap}>
|
||||
{depsFatal !== null && (
|
||||
<TerminalDepsBanner deps={depsFatal} onRetry={() => { setDepsFatal(null); connectRef.current?.() }} />
|
||||
)}
|
||||
{fatal !== null && (
|
||||
<div className={css.terminalBanner}>
|
||||
{t('terminalError')}: {fatal}
|
||||
@@ -270,8 +311,48 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
||||
</button>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
|
||||
+67
-11
@@ -7,9 +7,14 @@
|
||||
* so this component never fetches or dispatches — it only edits.
|
||||
*
|
||||
* 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, useMemo, useRef, useState } from 'react'
|
||||
import type { ComponentType } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import clsx from 'clsx'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
@@ -23,8 +28,10 @@ import { isDarkScheme, subscribeColorScheme } from './theme.ts'
|
||||
import { SandboxStatusBar } from './SandboxStatusBar.tsx'
|
||||
import { appendToDraft } from './conversation-draft.ts'
|
||||
import { buildSelectionInsert, linesOfSelection } from './selection-payload.ts'
|
||||
import { lazyChunkComponent } from './lazy-chunk.tsx'
|
||||
import { splitMermaidBlocks, type MermaidMarkdownProps } from './mermaid-blocks.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'
|
||||
|
||||
/** Previewable files (rendered output vs source editing). */
|
||||
@@ -37,6 +44,16 @@ interface SelectionPopup {
|
||||
top: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The chunk-resident markdown preview renderer (mermaid lazy chunk): one
|
||||
* MarkdownText pass over the whole source, with rendered mermaid fences
|
||||
* swapped for diagrams. Module-level `pick` keeps the load effect stable.
|
||||
*/
|
||||
const LazyMermaidMarkdown = lazyChunkComponent<MermaidMarkdownProps>(
|
||||
'mermaid',
|
||||
(mod) => mod.MermaidMarkdown as ComponentType<MermaidMarkdownProps> | undefined,
|
||||
)
|
||||
|
||||
/**
|
||||
* The sandbox tokens of the HTML preview iframe. NO allow-same-origin (the
|
||||
* preview must stay in an opaque origin — with the route's own origin it
|
||||
@@ -141,11 +158,11 @@ export function TextEditor(props: FileViewerProps) {
|
||||
...defaultKeymap,
|
||||
...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
|
||||
// its head. Scrolling (geometry/viewport change) or losing focus
|
||||
// hides it; typing collapses the selection and hides it too.
|
||||
...(viewerId === 'code' ? [
|
||||
...(viewerId === 'code' || viewerId === 'markdown' ? [
|
||||
CodeMirrorView.updateListener.of((update) => {
|
||||
if (update.geometryChanged || update.viewportChanged) {
|
||||
hidePopup()
|
||||
@@ -233,6 +250,19 @@ export function TextEditor(props: FileViewerProps) {
|
||||
|
||||
const markdown = viewerId === 'markdown'
|
||||
const html = viewerId === 'html'
|
||||
/** The markdown source the preview renders (draft wins over saved content). */
|
||||
const mdText = draft ?? content ?? ''
|
||||
/** md/mermaid block split for the preview (mermaid fences lift out). Split
|
||||
* only in preview mode: edit-mode keystrokes must not re-scan the source. */
|
||||
const mdBlocks = useMemo(
|
||||
() => (markdown && mode === 'preview' ? splitMermaidBlocks(mdText) : []),
|
||||
[markdown, mode, mdText],
|
||||
)
|
||||
const hasMermaid = useMemo(
|
||||
() => mdBlocks.some(block => block.kind === 'mermaid'),
|
||||
[mdBlocks],
|
||||
)
|
||||
const codeLabels = { copyLabel: t('copy'), copiedLabel: t('copied') }
|
||||
|
||||
/**
|
||||
* Selection popup for the markdown preview: a mouse-up inside the preview
|
||||
@@ -259,7 +289,7 @@ export function TextEditor(props: FileViewerProps) {
|
||||
return
|
||||
}
|
||||
const rect = sel.getRangeAt(0).getBoundingClientRect()
|
||||
const lines = linesOfSelection(draft ?? content ?? '', text)
|
||||
const lines = linesOfSelection(mdText, text)
|
||||
showPopup(
|
||||
buildSelectionInsert(path, scope.cwd, lines ?? undefined, text),
|
||||
rect.left + rect.width / 2,
|
||||
@@ -278,8 +308,31 @@ export function TextEditor(props: FileViewerProps) {
|
||||
const [localUnlock, setLocalUnlock] = useState(() => props.store?.getPrefs().htmlViewerDefaultUnsafe === true)
|
||||
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 (
|
||||
<>
|
||||
{!hostToolbar && (
|
||||
<div className={css.editorHeader}>
|
||||
{(markdown || html) && (
|
||||
<div className={css.editorModeToggle}>
|
||||
@@ -313,6 +366,7 @@ export function TextEditor(props: FileViewerProps) {
|
||||
)}
|
||||
{saveLabel !== '' && <span className={clsx(css.editorStatus, saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>}
|
||||
</div>
|
||||
)}
|
||||
{editable && (
|
||||
<>
|
||||
{truncated === true && mode === 'edit' && <div className={css.editorBanner}>{t('truncation')}</div>}
|
||||
@@ -333,11 +387,13 @@ export function TextEditor(props: FileViewerProps) {
|
||||
dictionary: the DSH MarkdownText/CodeBlock are cordis-free and
|
||||
fall back to hardcoded Chinese otherwise (same pattern as the
|
||||
chat's AssistantMarkdown). Render-time t() keeps them following
|
||||
the active locale on live switches. */}
|
||||
<MarkdownText
|
||||
text={draft ?? content ?? ''}
|
||||
codeLabels={{ copyLabel: t('copy'), copiedLabel: t('copied') }}
|
||||
/>
|
||||
the active locale on live switches. Mermaid fences hand the
|
||||
whole document to the mermaid lazy chunk (single markdown
|
||||
parse; cross-fence references/footnotes stay intact); files
|
||||
without one render exactly as before. */}
|
||||
{hasMermaid
|
||||
? <LazyMermaidMarkdown text={mdText} codeLabels={codeLabels} />
|
||||
: <MarkdownText text={mdText} codeLabels={codeLabels} />}
|
||||
</div>
|
||||
)}
|
||||
{html && mode === 'preview' && (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
* Modal must never be renderToString'd (same rule as the settingsFor popup
|
||||
* in SideCardSection); the modal itself mounts only while open.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { Modal, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { BetterSidebarService } from './service.ts'
|
||||
import { PLUGIN_TOPIC_URL, type PluginEntry } from './plugins-shared.ts'
|
||||
@@ -46,6 +46,31 @@ export function PluginListBody(props: { service: BetterSidebarService; kind: Plu
|
||||
const { service, kind } = props
|
||||
// Which entry's copy button currently shows the "已复制" feedback.
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||
// Live catalog filter (name / id / description). A free-text search keeps
|
||||
// the list usable once the catalogs grow beyond a handful of entries:
|
||||
// every keystroke narrows the rendered rows, so a long catalog never
|
||||
// becomes a wall of scrolling.
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const catalog = catalogOf(kind)
|
||||
const needle = query.trim().toLowerCase()
|
||||
const matches = (entry: PluginEntry): boolean => {
|
||||
if (needle === '') return true
|
||||
const description = typeof entry.description === 'function' ? entry.description() : entry.description
|
||||
return entry.name.toLowerCase().includes(needle)
|
||||
|| entry.id.toLowerCase().includes(needle)
|
||||
|| description.toLowerCase().includes(needle)
|
||||
}
|
||||
const filtered = catalog.filter(matches)
|
||||
// Group by the optional category (stable order: category order of first
|
||||
// appearance, uncategorized entries last under the plain list).
|
||||
const groups = new Map<string | undefined, PluginEntry[]>()
|
||||
for (const entry of filtered) {
|
||||
const key = entry.category === undefined ? undefined : (typeof entry.category === 'function' ? entry.category() : entry.category)
|
||||
const list = groups.get(key)
|
||||
if (list === undefined) groups.set(key, [entry])
|
||||
else list.push(entry)
|
||||
}
|
||||
|
||||
/** Copy the entry's install script to the clipboard and flash the button's
|
||||
* "已复制" label for a moment. The feedback ONLY appears after a
|
||||
@@ -68,6 +93,48 @@ export function PluginListBody(props: { service: BetterSidebarService; kind: Plu
|
||||
window.open(entry.url, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
/** One catalog row (extracted so the group render stays flat). */
|
||||
const renderEntry = (entry: PluginEntry): ReactNode => (
|
||||
<div key={entry.id} className={css.pluginEntry}>
|
||||
<div className={css.pluginEntryHead}>
|
||||
{/* The name is a BUTTON on the same window.open path as the
|
||||
jump button: as an anchor it would be caught by the
|
||||
document-capture link takeover (which ignores
|
||||
target=_blank) and land in the sidebar browser. */}
|
||||
<button
|
||||
type="button"
|
||||
className={css.pluginName}
|
||||
aria-label={`${t('openPlugin')}: ${entry.name}`}
|
||||
onClick={() => { jump(entry) }}
|
||||
>
|
||||
{entry.name}
|
||||
</button>
|
||||
<span className={css.pluginEntryActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.pluginJumpBtn}
|
||||
aria-label={`${t('openPlugin')}: ${entry.name}`}
|
||||
onClick={() => { jump(entry) }}
|
||||
>
|
||||
{t('openPlugin')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.pluginCopyBtn}
|
||||
aria-label={`${t('copyInstall')}: ${entry.name}`}
|
||||
onClick={() => { copy(entry) }}
|
||||
>
|
||||
{copiedId === entry.id ? t('copied') : t('copy')}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className={css.pluginDesc}>
|
||||
{typeof entry.description === 'function' ? entry.description() : entry.description}
|
||||
</div>
|
||||
<code className={css.pluginInstall}>{entry.install}</code>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={css.pluginList}>
|
||||
<button
|
||||
@@ -77,52 +144,30 @@ export function PluginListBody(props: { service: BetterSidebarService; kind: Plu
|
||||
>
|
||||
{t('addPluginsBrowseMore')}
|
||||
</button>
|
||||
<input
|
||||
type="search"
|
||||
className={css.pluginSearch}
|
||||
placeholder={t('addPluginsSearch')}
|
||||
aria-label={t('addPluginsSearch')}
|
||||
value={query}
|
||||
onChange={event => { setQuery(event.currentTarget.value) }}
|
||||
/>
|
||||
<div className={css.groupHeading}>
|
||||
<span>{t('addPluginsRecommended')}</span>
|
||||
<span className={css.count}>{catalogOf(kind).length}</span>
|
||||
<span className={css.count}>{filtered.length}</span>
|
||||
</div>
|
||||
{catalogOf(kind).length === 0 ? (
|
||||
{catalog.length === 0 ? (
|
||||
<div className={css.pluginEmpty}>{t('addPluginsEmpty')}</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className={css.pluginEmpty}>{t('addPluginsNoMatch')}</div>
|
||||
) : (
|
||||
<div className={css.pluginEntries}>
|
||||
{catalogOf(kind).map(entry => (
|
||||
<div key={entry.id} className={css.pluginEntry}>
|
||||
<div className={css.pluginEntryHead}>
|
||||
{/* The name is a BUTTON on the same window.open path as the
|
||||
jump button: as an anchor it would be caught by the
|
||||
document-capture link takeover (which ignores
|
||||
target=_blank) and land in the sidebar browser. */}
|
||||
<button
|
||||
type="button"
|
||||
className={css.pluginName}
|
||||
aria-label={`${t('openPlugin')}: ${entry.name}`}
|
||||
onClick={() => { jump(entry) }}
|
||||
>
|
||||
{entry.name}
|
||||
</button>
|
||||
<span className={css.pluginEntryActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.pluginJumpBtn}
|
||||
aria-label={`${t('openPlugin')}: ${entry.name}`}
|
||||
onClick={() => { jump(entry) }}
|
||||
>
|
||||
{t('openPlugin')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.pluginCopyBtn}
|
||||
aria-label={`${t('copyInstall')}: ${entry.name}`}
|
||||
onClick={() => { copy(entry) }}
|
||||
>
|
||||
{copiedId === entry.id ? t('copied') : t('copy')}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className={css.pluginDesc}>
|
||||
{typeof entry.description === 'function' ? entry.description() : entry.description}
|
||||
</div>
|
||||
<code className={css.pluginInstall}>{entry.install}</code>
|
||||
{[...groups.entries()].map(([category, entries]) => (
|
||||
<div key={category ?? '\u0000'} className={css.pluginGroup}>
|
||||
{category !== undefined && (
|
||||
<div className={css.pluginGroupHeading}>{category}</div>
|
||||
)}
|
||||
{entries.map(renderEntry)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+40
-4
@@ -25,6 +25,10 @@ export interface FsEntry {
|
||||
path: string
|
||||
isDir: 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). */
|
||||
@@ -75,6 +79,21 @@ export interface JobOutputResult {
|
||||
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> {
|
||||
let response: Response
|
||||
try {
|
||||
@@ -116,6 +135,10 @@ export const api = {
|
||||
call<{ sessionId: string; cwd: string; root: string; parent: string | null }>('session.cwd', scopePayload(scope, {}), signal),
|
||||
fsTree: (scope: SessionScope, path: string, signal?: AbortSignal) =>
|
||||
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) =>
|
||||
call<FsTextResult | FsBinaryResult>('fs.read', scopePayload(scope, { path }), signal),
|
||||
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). */
|
||||
agentPtyClose: (uuid: string) =>
|
||||
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
|
||||
* from the owner session's event log — never the model's job_output
|
||||
@@ -173,9 +201,12 @@ export const api = {
|
||||
id,
|
||||
...(reason !== undefined ? { reason } : {}),
|
||||
})),
|
||||
/** The effective terminal shell and its display name (plugin-global). */
|
||||
shellGet: () =>
|
||||
call<{ shell: string; name: string }>('shell.get', {}),
|
||||
/** Read the side card preferences (plugin-global, no session scope). */
|
||||
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). */
|
||||
settingsUpdate: (patch: Record<string, unknown>, expectedRevision?: number) =>
|
||||
call<{ value?: unknown; revision?: number }>('settings.update', {
|
||||
@@ -207,9 +238,14 @@ function fileUrl(scope: SessionScope, path: string, download: boolean): string {
|
||||
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
|
||||
* the same route with the session scope intact. */
|
||||
/**
|
||||
* 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
|
||||
* 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 {
|
||||
return encodeHtmlUrl(scope.sessionId, path)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import type { Context } from '../../context-types.ts'
|
||||
import type { BetterSidebarService } from '../service.ts'
|
||||
import { builtinTabs } from './tabs.tsx'
|
||||
import { builtinTabs, type BuiltinTabOptions } from './tabs.tsx'
|
||||
import { builtinViewers } from './viewers.tsx'
|
||||
|
||||
/**
|
||||
@@ -17,9 +17,13 @@ import { builtinViewers } from './viewers.tsx'
|
||||
* disposal). The `ctx` is threaded into tab descriptors that need it
|
||||
* (EditorHost reads `ctx.betterSidebar` for file-viewer matching).
|
||||
*/
|
||||
export function registerBuiltins(ctx: Context, service: BetterSidebarService): () => void {
|
||||
export function registerBuiltins(
|
||||
ctx: Context,
|
||||
service: BetterSidebarService,
|
||||
options: BuiltinTabOptions = {},
|
||||
): () => void {
|
||||
const disposers: (() => void)[] = []
|
||||
for (const tab of builtinTabs(ctx)) {
|
||||
for (const tab of builtinTabs(ctx, options)) {
|
||||
disposers.push(service.registerTab(tab))
|
||||
}
|
||||
for (const viewer of builtinViewers()) {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
/**
|
||||
* The 7 built-in tab descriptors: the plugin registers its own pages
|
||||
* (explorer / git / terminal / browser / subagent / editor / diff) through
|
||||
* The 6 built-in tab descriptors: the plugin registers its own pages
|
||||
* (editor / git / terminal / browser / subagent / diff) through
|
||||
* the same {@link BetterSidebarService} external plugins use — eating its
|
||||
* own dogfood. The terminal descriptor owns its quota (`TERMINAL_LIMIT`)
|
||||
* and mints `terminal:<n>` ids through `createTab`; the browser mints
|
||||
* `browser:<n>` the same way (no quota).
|
||||
* and mints `terminal:<uuid>` ids through `createTab`; the browser mints
|
||||
* `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 { allLeaves, isAgentTabId, type SidebarState } from '../state.ts'
|
||||
import { t } from '../locales.ts'
|
||||
import { openSidebarFile } from '../intercept.tsx'
|
||||
import { ExplorerView } from '../ExplorerView.tsx'
|
||||
import { EditorHost } from '../EditorHost.tsx'
|
||||
import { lazyChunkComponent } from '../lazy-chunk.tsx'
|
||||
import { GitView } from '../GitView.tsx'
|
||||
@@ -52,6 +52,20 @@ interface TerminalViewProps {
|
||||
/** How many UI-owned terminals may be open at once (agent-owned ones are uncapped). */
|
||||
export const TERMINAL_LIMIT = 3
|
||||
|
||||
/** Optional per-registration builtin behavior (currently terminal title). */
|
||||
export interface BuiltinTabOptions {
|
||||
/** Returns the display title for newly opened terminal tabs. */
|
||||
terminalTitle?: () => string
|
||||
}
|
||||
|
||||
/** A client-side uuid for terminal tab identity (not shown in the UI). */
|
||||
function terminalUuid(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return `t${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
|
||||
/** Count UI-owned terminals (agent:` tabs excluded — they are the model's). */
|
||||
function uiTerminalCount(state: SidebarState): number {
|
||||
return allLeaves(state.splits)
|
||||
@@ -59,33 +73,52 @@ function uiTerminalCount(state: SidebarState): number {
|
||||
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
|
||||
}
|
||||
|
||||
/** The 7 built-in tab descriptors. */
|
||||
export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
/** The 6 built-in tab descriptors. */
|
||||
export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): readonly TabDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: 'editor',
|
||||
title: () => t('editor'),
|
||||
icon: (size: number) => <IconCodeOutline16 size={size} />,
|
||||
order: -1,
|
||||
hidden: true,
|
||||
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'),
|
||||
// The single files window: an editor tab with no path IS the file
|
||||
// explorer (empty hint + docked tree); with a path it previews/edits
|
||||
// the file. Visible in the + menu in the explorer's old slot.
|
||||
title: () => t('files'),
|
||||
icon: (size: number) => <IconFolderOpen16 size={size} />,
|
||||
order: 10,
|
||||
single: true,
|
||||
component: ({ ctx, store, scope, expanded, onToggleDir, onReferenceFile }) => (
|
||||
<ExplorerView
|
||||
sessionId={scope.sessionId}
|
||||
cwd={scope.cwd}
|
||||
hidden: false,
|
||||
dedupeKey: (tab) => tab.path,
|
||||
// Declarative settings: the file-open behavior picker (in-place switch
|
||||
// vs per-path windows) renders as an iconed select row under the
|
||||
// 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 ?? []}
|
||||
onToggle={onToggleDir ?? (() => { /* no-op */ })}
|
||||
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
|
||||
onToggleDir={onToggleDir ?? (() => { /* no-op */ })}
|
||||
onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })}
|
||||
/>
|
||||
),
|
||||
@@ -152,6 +185,18 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
key: 'bottomPanelAutoTerminal',
|
||||
title: () => t('settingsBottomTerminalTitle'),
|
||||
desc: () => t('settingsBottomTerminalDesc'),
|
||||
}, {
|
||||
key: 'terminalShell',
|
||||
type: 'text',
|
||||
title: () => t('settingsShellTitle'),
|
||||
desc: () => t('settingsShellDesc'),
|
||||
placeholder: t('settingsShellPlaceholder'),
|
||||
}, {
|
||||
key: 'terminalShellArgs',
|
||||
type: 'text',
|
||||
title: () => t('settingsShellArgsTitle'),
|
||||
desc: () => t('settingsShellArgsDesc'),
|
||||
placeholder: t('settingsShellArgsPlaceholder'),
|
||||
}, {
|
||||
key: 'terminalFontFamily',
|
||||
type: 'text',
|
||||
@@ -173,10 +218,12 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
if (count >= TERMINAL_LIMIT) return null
|
||||
return {
|
||||
tab: {
|
||||
id: `terminal:${state.nextTerminal}`,
|
||||
id: `terminal:${terminalUuid()}`,
|
||||
type: 'terminal',
|
||||
title: `${t('terminal')} ${state.nextTerminal}`,
|
||||
title: options.terminalTitle?.() ?? t('terminal'),
|
||||
},
|
||||
// Keep the legacy counter advancing for compatibility with older
|
||||
// persisted states; new ids no longer use it.
|
||||
patch: { nextTerminal: state.nextTerminal + 1 },
|
||||
}
|
||||
},
|
||||
@@ -187,9 +234,10 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
title: () => t('browser'),
|
||||
icon: (size: number) => <IconGlobeOutline16 size={size} />,
|
||||
order: 50,
|
||||
// Declarative settings: the sandbox escape hatch and the link
|
||||
// takeover render under this tab's row in the Side card settings
|
||||
// page (the sandbox one is warned on).
|
||||
// Declarative settings: the sandbox escape hatch, the link-takeover
|
||||
// MASTER switch, and the per-protocol takeover switches (http on /
|
||||
// https off by default) render under this tab's row in the Side card
|
||||
// settings page (the sandbox one is warned on).
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'browserNoSandbox',
|
||||
@@ -199,6 +247,14 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
key: 'browserInterceptLinks',
|
||||
title: () => t('settingsBrowserLinksTitle'),
|
||||
desc: () => t('settingsBrowserLinksDesc'),
|
||||
}, {
|
||||
key: 'browserInterceptHttp',
|
||||
title: () => t('settingsBrowserHttpTitle'),
|
||||
desc: () => t('settingsBrowserHttpDesc'),
|
||||
}, {
|
||||
key: 'browserInterceptHttps',
|
||||
title: () => t('settingsBrowserHttpsTitle'),
|
||||
desc: () => t('settingsBrowserHttpsDesc'),
|
||||
}],
|
||||
},
|
||||
createTab: (state) => ({
|
||||
|
||||
+172
-19
@@ -29,7 +29,10 @@
|
||||
* Caching contract (three layers, each with a failure path):
|
||||
* - In-memory: one in-flight promise per chunk, memoized until
|
||||
* {@link resetChunks}; a failed load removes its entry so the next call
|
||||
* retries from scratch.
|
||||
* retries from scratch. HMR re-activation keeps the resolved exports of
|
||||
* unchanged chunks (ETag revalidation via
|
||||
* {@link revalidateChunksOnReactivate}) — the next lazy open skips the
|
||||
* re-inject / re-execute.
|
||||
* - Script execution: each re-execution overwrites the global registry slot
|
||||
* (assignment, never registration) — no "duplicate factory registration"
|
||||
* class of errors; a failed materialization clears the cache so the retry
|
||||
@@ -38,13 +41,15 @@
|
||||
* no-cache` + ETag, 304 when unchanged), so page refreshes and HMR
|
||||
* re-activations never re-download a multi-MB chunk that did not change.
|
||||
*
|
||||
* HMR: each plugin activation calls {@link resetChunks}, which drops the
|
||||
* in-memory cache (and any test registry), so a hot-reloaded core bundle
|
||||
* re-fetches and re-executes the current chunk scripts on the next lazy
|
||||
* open. Chunk-only source edits still need a manual page refresh (the HMR
|
||||
* poll watches only client.js).
|
||||
* HMR: each plugin activation calls {@link revalidateChunksOnReactivate},
|
||||
* which HEADs every loaded chunk against the bundle route and keeps the
|
||||
* in-memory cache for the ones whose ETag is unchanged (a hot-reloaded core
|
||||
* bundle therefore does not re-execute multi-MB chunk scripts). Chunk-only
|
||||
* source edits still need a manual page refresh (the HMR poll watches only
|
||||
* client.js); an edit that does land while a core HMR happens is caught by
|
||||
* the ETag comparison on the next activation.
|
||||
*/
|
||||
export type ChunkName = 'terminal' | 'editor'
|
||||
export type ChunkName = 'terminal' | 'editor' | 'mermaid'
|
||||
|
||||
/** The module exports a chunk factory provides (namespace-ish record). */
|
||||
export type ChunkExports = Record<string, unknown>
|
||||
@@ -56,7 +61,12 @@ type ChunkFactory = (require: (spec: string) => unknown) => ChunkExports
|
||||
* The platform externals a chunk bundle may require (mirror of
|
||||
* CLIENT_EXTERNALS in tsdown.config.ts — the chunk builds keep these
|
||||
* external and the loader resolves them here). A superset is safe: the
|
||||
* require only answers what the chunk actually asks for.
|
||||
* require only answers what the chunk actually asks for. The shell's static
|
||||
* module table seeds React, Cordis, and the UI libraries (primitives/slots);
|
||||
* `dsh-client-runtime/client` normalizes onto the runtime package row
|
||||
* (stripClientSuffix). dsh-client-web-react / dsh-client-schema-form were
|
||||
* dropped in DSH 0.1.0-rc.8 (no rc.8 publish, nothing requires them) — the
|
||||
* chunks never asked for them, so they no longer belong here.
|
||||
*/
|
||||
export const CHUNK_EXTERNALS: readonly string[] = [
|
||||
'react',
|
||||
@@ -65,23 +75,61 @@ export const CHUNK_EXTERNALS: readonly string[] = [
|
||||
'react-dom/client',
|
||||
'cordis',
|
||||
'@deepseek-ai/dsh-client-ui-slots',
|
||||
'@deepseek-ai/dsh-client-web-react',
|
||||
'@deepseek-ai/dsh-client-ui-primitives',
|
||||
'@deepseek-ai/dsh-client-schema-form',
|
||||
'@deepseek-ai/dsh-client-runtime/client',
|
||||
]
|
||||
|
||||
/** Chunk script endpoint served by the plugin host half (src/bundle-route.ts). */
|
||||
const CHUNK_URL = (name: ChunkName): string => `/sidebar/bundle/${name}.js`
|
||||
|
||||
/** The client module system surface this loader needs (window.__DSH_MODULES__). */
|
||||
interface ChunkModuleSystem {
|
||||
/** Bound on the revalidation HEAD round-trip. A timeout fails open (drop +
|
||||
* re-fetch on the next open) so a stuck bundle route can never wedge lazy
|
||||
* chunk loads behind the revalidation barrier. */
|
||||
const CHUNK_REVALIDATE_TIMEOUT_MS = 5_000
|
||||
|
||||
/**
|
||||
* The client module system surface this loader needs to resolve externals.
|
||||
* DSH 0.1.0-rc.8 provides it as the `ctx.modules` service (no page global
|
||||
* anymore); the plugin injects it at activation via
|
||||
* {@link setChunkModuleSystem}. The rc.7-era `window.__DSH_MODULES__` global
|
||||
* remains as a fallback so older hosts and the test harness keep working.
|
||||
*/
|
||||
export interface ChunkModuleSystem {
|
||||
import(specifier: string): Promise<unknown>
|
||||
}
|
||||
|
||||
/** Resolve the shell-installed module system (set before any plugin activates). */
|
||||
/** The module system injected by the client half at activation (rc.8+). */
|
||||
let injectedModuleSystem: ChunkModuleSystem | undefined
|
||||
|
||||
/**
|
||||
* Plugin-owned page global carrying the injected module system across
|
||||
* bundle copies: the lazy chunk bundles (client-editor.js etc.) inline their
|
||||
* own chunk-loader instance, and rc.8 no longer exposes the shell module
|
||||
* system as a page global — so the core bundle's injection must be visible
|
||||
* to the chunk copies through a namespace of our own.
|
||||
*/
|
||||
const MODULE_SYSTEM_GLOBAL = '__dshSidebarModuleSystem__'
|
||||
|
||||
/**
|
||||
* Inject the client module system the chunk externals resolve through.
|
||||
* Called by the client half's apply() with `ctx.modules` (rc.8+); pass
|
||||
* undefined to clear (tests). Survives {@link resetChunks} — the module
|
||||
* system is shell state, not chunk state, and stays live across HMR.
|
||||
*/
|
||||
export function setChunkModuleSystem(system: ChunkModuleSystem | undefined): void {
|
||||
injectedModuleSystem = system
|
||||
const g = globalThis as Record<string, unknown>
|
||||
if (system === undefined) delete g[MODULE_SYSTEM_GLOBAL]
|
||||
else g[MODULE_SYSTEM_GLOBAL] = system
|
||||
}
|
||||
|
||||
/** Resolve the shell-installed module system (injected, then the plugin
|
||||
* global shared with chunk-bundle copies, then the rc.7 page global). */
|
||||
function moduleSystem(): ChunkModuleSystem | undefined {
|
||||
return (globalThis as { __DSH_MODULES__?: ChunkModuleSystem }).__DSH_MODULES__
|
||||
const g = globalThis as Record<string, unknown>
|
||||
return injectedModuleSystem
|
||||
?? g[MODULE_SYSTEM_GLOBAL] as ChunkModuleSystem | undefined
|
||||
?? (g as { __DSH_MODULES__?: ChunkModuleSystem }).__DSH_MODULES__
|
||||
}
|
||||
|
||||
/** The plugin-owned chunk factory registry the chunk scripts populate. */
|
||||
@@ -155,6 +203,35 @@ async function buildExternalsRequire(modules: ChunkModuleSystem): Promise<(spec:
|
||||
/** In-flight/memoized chunk loads; a failure removes its entry so a retry re-fetches. */
|
||||
const cache = new Map<ChunkName, Promise<ChunkExports>>()
|
||||
|
||||
/** Chunk names whose exports are currently cached (loaded successfully). */
|
||||
const loadedChunks = new Set<ChunkName>()
|
||||
|
||||
/** ETags observed for loaded chunks (HEAD revalidation, see
|
||||
* {@link revalidateChunksOnReactivate}). */
|
||||
const chunkEtags = new Map<ChunkName, string>()
|
||||
|
||||
/** Pending revalidation barrier: while set, {@link loadChunk} awaits it
|
||||
* before serving cache (see revalidateChunksOnReactivate). */
|
||||
let revalidation: Promise<void> | null = null
|
||||
|
||||
/** Best-effort ETag capture for revalidation. The script tag itself exposes
|
||||
* no response headers, so after a successful load we HEAD the bundle route
|
||||
* once. Failures (including a stuck route — bounded by the timeout) are
|
||||
* ignored — revalidation then fails open (re-fetch). */
|
||||
async function recordEtag(name: ChunkName): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(CHUNK_URL(name), {
|
||||
method: 'HEAD',
|
||||
cache: 'no-cache',
|
||||
signal: AbortSignal.timeout(CHUNK_REVALIDATE_TIMEOUT_MS),
|
||||
})
|
||||
const etag = res.headers.get('etag')
|
||||
if (etag !== null && etag !== '') chunkEtags.set(name, etag)
|
||||
} catch {
|
||||
chunkEtags.delete(name)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load (once) and materialize a lazy chunk, returning its module exports.
|
||||
* Concurrent callers share one in-flight load; a failure clears the cache
|
||||
@@ -162,10 +239,14 @@ const cache = new Map<ChunkName, Promise<ChunkExports>>()
|
||||
* global registry slot — assignments are idempotent).
|
||||
* @param name - the chunk to load.
|
||||
*/
|
||||
export function loadChunk(name: ChunkName): Promise<ChunkExports> {
|
||||
export async function loadChunk(name: ChunkName): Promise<ChunkExports> {
|
||||
// Barrier: never serve a cache entry that a pending revalidation is about
|
||||
// to inspect — a stale chunk could otherwise render mid-HMR (CR #232 P1).
|
||||
if (revalidation !== null) await revalidation
|
||||
const cached = cache.get(name)
|
||||
if (cached !== undefined) return cached
|
||||
const task = (async (): Promise<ChunkExports> => {
|
||||
let task: Promise<ChunkExports>
|
||||
task = (async (): Promise<ChunkExports> => {
|
||||
const test = testLoaders.get(name)
|
||||
if (test !== undefined) return test()
|
||||
const modules = moduleSystem()
|
||||
@@ -178,10 +259,23 @@ export function loadChunk(name: ChunkName): Promise<ChunkExports> {
|
||||
throw new Error(`[dsh-better-sidebar] chunk "${name}" script did not register its factory`)
|
||||
}
|
||||
const require = await buildExternalsRequire(modules)
|
||||
return factory(require)
|
||||
const exports = factory(require)
|
||||
// Only track production loads whose cache entry survived (a revalidation
|
||||
// sweep may have dropped it mid-flight; the caller still gets these
|
||||
// exports, they just are not memoized for the next open). Test-registry
|
||||
// loads return above and never reach this tracking.
|
||||
if (cache.get(name) !== undefined) {
|
||||
loadedChunks.add(name)
|
||||
void recordEtag(name)
|
||||
}
|
||||
return exports
|
||||
})()
|
||||
cache.set(name, task)
|
||||
void task.catch(() => { cache.delete(name) })
|
||||
void task.catch(() => {
|
||||
cache.delete(name)
|
||||
loadedChunks.delete(name)
|
||||
chunkEtags.delete(name)
|
||||
})
|
||||
return task
|
||||
}
|
||||
|
||||
@@ -189,10 +283,69 @@ export function loadChunk(name: ChunkName): Promise<ChunkExports> {
|
||||
* Drop all chunk state for a fresh plugin activation (HMR-safe): clear the
|
||||
* in-memory cache and any test-registry entries, so the next lazy open
|
||||
* re-fetches and re-executes the current chunk scripts (the registry slots
|
||||
* are overwritten by the re-execution — no cleanup needed).
|
||||
* are overwritten by the re-execution — no cleanup needed). A pending
|
||||
* revalidation barrier is cleared too: it was only guarding the cache reads
|
||||
* of the state being dropped, so the next load must not wait on it (the
|
||||
* orphaned task still settles and its identity-guarded `finally` no-ops).
|
||||
*/
|
||||
export function resetChunks(): void {
|
||||
cache.clear()
|
||||
loadedChunks.clear()
|
||||
chunkEtags.clear()
|
||||
testLoaders.clear()
|
||||
externalsRequire = undefined
|
||||
revalidation = null
|
||||
}
|
||||
|
||||
/**
|
||||
* HMR-safe re-activation hook (index.tsx calls this instead of a full
|
||||
* reset): keep the resolved exports of every loaded chunk and drop only the
|
||||
* ones whose script changed on disk — the bundle route revalidates every
|
||||
* request (cache-control: no-cache + ETag), so an unchanged chunk keeps its
|
||||
* memory cache and the next lazy open skips the re-inject / re-execute.
|
||||
* Fail-open: an unreachable, ETag-less, or timed-out chunk is dropped
|
||||
* (re-fetch on next open). Test-registry entries are always cleared
|
||||
* (per-test fixtures).
|
||||
* A page refresh remains the authoritative reset (the HMR poll watches only
|
||||
* client.js; chunk-only edits surface here on the next core re-activation).
|
||||
*
|
||||
* The returned promise is also a BARRIER for {@link loadChunk}: while a
|
||||
* revalidation is pending, every chunk load awaits it before serving cache,
|
||||
* so a lazy tab opening mid-revalidation can never render stale exports
|
||||
* that the sweep is about to invalidate (CR #232 P1).
|
||||
*/
|
||||
export function revalidateChunksOnReactivate(): Promise<void> {
|
||||
testLoaders.clear()
|
||||
const task = (async (): Promise<void> => {
|
||||
// Entries not tracked as production-loaded (test fixtures, orphans) never
|
||||
// survive a re-activation — their resolved exports came from per-test
|
||||
// stubs, not from the bundle route.
|
||||
for (const name of [...cache.keys()]) {
|
||||
if (!loadedChunks.has(name)) cache.delete(name)
|
||||
}
|
||||
if (loadedChunks.size === 0) return
|
||||
const stale: ChunkName[] = []
|
||||
await Promise.all([...loadedChunks].map(async (name) => {
|
||||
try {
|
||||
const res = await fetch(CHUNK_URL(name), {
|
||||
method: 'HEAD',
|
||||
cache: 'no-cache',
|
||||
signal: AbortSignal.timeout(CHUNK_REVALIDATE_TIMEOUT_MS),
|
||||
})
|
||||
const etag = res.headers.get('etag')
|
||||
if (etag !== null && etag !== '' && chunkEtags.get(name) === etag) return
|
||||
} catch {
|
||||
// Fail open below (network errors and timeouts alike).
|
||||
}
|
||||
stale.push(name)
|
||||
}))
|
||||
for (const name of stale) {
|
||||
cache.delete(name)
|
||||
loadedChunks.delete(name)
|
||||
chunkEtags.delete(name)
|
||||
}
|
||||
})()
|
||||
revalidation = task
|
||||
void task.finally(() => { if (revalidation === task) revalidation = null })
|
||||
return task
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Lazy chunk entry: the mermaid diagram renderer for the markdown preview
|
||||
* (mermaid + its d3/dagre/cytoscape graph deps). Built as
|
||||
* lib/client-mermaid.js and registered under the `mermaid` global chunk
|
||||
* slot — fetched only when a previewed markdown file contains a mermaid
|
||||
* fence (see chunk-loader.ts and the mermaid markdown preview design doc).
|
||||
* Never import this module from the core bundle: it pulls mermaid into the
|
||||
* startup path.
|
||||
*/
|
||||
export { MermaidMarkdown } from '../mermaid.tsx'
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Desktop-shell detection for the sidebar. The official DSH Desktop shell
|
||||
* (Electron) stamps every render URL with `dsh-desktop-mode` and
|
||||
* `dsh-desktop-platform` and exposes `window.__DSH_DESKTOP_FILE_PATH__`
|
||||
* through its preload; community Tauri shells keep the native system frame
|
||||
* (no stamps — plain-browser semantics). Parsed once per page and memoized
|
||||
* (the URL never changes mid-session); `resetDesktopEnvForTests` clears the
|
||||
* memo for unit tests.
|
||||
*
|
||||
* Geometry facts the sidebar adapts to (from the Electron runtime):
|
||||
* - win32 `advanced` mode draws the window controls in a 32px overlay strip
|
||||
* at the top-right of the web content (`titleBarOverlay: { height: 32 }`)
|
||||
* — exactly where the toggle cluster sits, so it must drop below the
|
||||
* strip and the right panel's content must clear it.
|
||||
* - darwin `advanced` mode keeps the traffic lights at (16,16) in the
|
||||
* top-left; nothing on the plugin's side touches that corner today, but
|
||||
* the platform is still reported so left-edge controls can avoid it.
|
||||
* - `compatibility` mode keeps the native frame — no adaptation needed.
|
||||
*/
|
||||
export interface DesktopEnv {
|
||||
/** Running inside a desktop shell (any URL stamp or preload marker). */
|
||||
readonly desktop: boolean
|
||||
/** `advanced` = frameless/custom-titlebar shell; `compatibility` = native frame. */
|
||||
readonly mode: 'compatibility' | 'advanced' | null
|
||||
/** Shell platform stamp ('darwin' | 'win32' | …), lowercased, or null. */
|
||||
readonly platform: string | null
|
||||
/** Pixels the win32 advanced shell reserves at the top-right for window
|
||||
* controls (0 elsewhere). */
|
||||
readonly win32OverlayTop: number
|
||||
}
|
||||
|
||||
let cached: DesktopEnv | undefined
|
||||
|
||||
/** Read the shell's desktop stamps (memoized per page). */
|
||||
export function parseDesktopEnv(): DesktopEnv {
|
||||
if (cached !== undefined) return cached
|
||||
// location.search includes the leading '?', which URLSearchParams does NOT
|
||||
// strip (it would become part of the first key) — drop it explicitly.
|
||||
const params = new URLSearchParams(window.location.search.replace(/^\?/, ''))
|
||||
const modeParam = params.get('dsh-desktop-mode')
|
||||
const mode = modeParam === 'compatibility' || modeParam === 'advanced' ? modeParam : null
|
||||
const platformParam = params.get('dsh-desktop-platform')
|
||||
const platform = platformParam !== null && platformParam !== '' ? platformParam.toLowerCase() : null
|
||||
const desktop = mode !== null
|
||||
|| typeof (window as { __DSH_DESKTOP_FILE_PATH__?: unknown }).__DSH_DESKTOP_FILE_PATH__ !== 'undefined'
|
||||
cached = {
|
||||
desktop,
|
||||
mode,
|
||||
platform,
|
||||
win32OverlayTop: desktop && mode === 'advanced' && platform === 'win32' ? 32 : 0,
|
||||
}
|
||||
return cached
|
||||
}
|
||||
|
||||
/** Test hook: drop the memo so the next parse re-reads the URL/globals. */
|
||||
export function resetDesktopEnvForTests(): void {
|
||||
cached = undefined
|
||||
}
|
||||
+187
-30
@@ -11,16 +11,17 @@
|
||||
import { createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { createSidebarStore } from './state.ts'
|
||||
import { createBetterSidebarService } from './service.ts'
|
||||
import { resetChunks } from './chunk-loader.ts'
|
||||
import { allLeaves, createSidebarStore, isAgentTabId } from './state.ts'
|
||||
import { createBetterSidebarService, matchUrlTarget } from './service.ts'
|
||||
import { revalidateChunksOnReactivate, setChunkModuleSystem } from './chunk-loader.ts'
|
||||
import { registerBuiltins } from './builtins/index.ts'
|
||||
import { Sidebar } from './Sidebar.tsx'
|
||||
import { RenderBoundary } from './RenderBoundary.tsx'
|
||||
import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx'
|
||||
import { registerLinkInterception } from './link-intercept.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 { api } from './api.ts'
|
||||
import { LOCALE_NS, attachLocale, t, zh, en } from './locales.ts'
|
||||
@@ -28,8 +29,10 @@ import css from './sidebar.module.css'
|
||||
import './layout.css'
|
||||
|
||||
/** Services required before mounting (provided by the client runtime; the
|
||||
* locale service backs the sidebar's copy — see locales.ts). */
|
||||
export const inject = ['slots', 'sessions', 'connection', 'workspaces', 'locale']
|
||||
* locale service backs the sidebar's copy — see locales.ts). `modules`
|
||||
* (rc.8+) is the client module system the chunk loader resolves its
|
||||
* externals through — Cordis guards service access without inject. */
|
||||
export const inject = ['slots', 'sessions', 'connection', 'workspaces', 'locale', 'modules']
|
||||
|
||||
/**
|
||||
* Error boundary over the sidebar tree (root scope): a render error in the
|
||||
@@ -65,11 +68,31 @@ export function apply(ctx: Context): void {
|
||||
// are ready by the time the sidebar renders.
|
||||
const service = createBetterSidebarService(sidebarStore)
|
||||
ctx.provide('betterSidebar', service)
|
||||
// Terminal tab titles use the host's effective shell name (e.g. bash/zsh)
|
||||
// instead of "Terminal 1". Start with a safe fallback and replace it as
|
||||
// soon as the host shell info resolves. Tabs created before the response
|
||||
// arrives keep the fallback title, so also retitle any already-open UI
|
||||
// terminal tabs that still carry it.
|
||||
const fallbackTitle = t('terminal')
|
||||
let terminalTitle = fallbackTitle
|
||||
void api.shellGet().then(({ name }) => {
|
||||
terminalTitle = name
|
||||
const snapshot = service.getSnapshot()
|
||||
if (snapshot.state === undefined) return
|
||||
const tabs = allLeaves(snapshot.state.splits)
|
||||
.concat(allLeaves(snapshot.state.bottomSplits))
|
||||
.flatMap(leaf => leaf.tabs)
|
||||
for (const tab of tabs) {
|
||||
if (tab.type === 'terminal' && !isAgentTabId(tab.id) && tab.title === fallbackTitle) {
|
||||
service.updateTab(tab.id, { title: name })
|
||||
}
|
||||
}
|
||||
}).catch(() => { /* keep fallback */ })
|
||||
// Register the plugin's own built-in tabs and viewers through the same
|
||||
// service (eating our own dogfood). The disposer unregisters them on
|
||||
// fiber disposal (HMR-safe).
|
||||
ctx.effect(
|
||||
() => registerBuiltins(ctx, service),
|
||||
() => registerBuiltins(ctx, service, { terminalTitle: () => terminalTitle }),
|
||||
'dsh-better-sidebar: register built-in tabs and viewers',
|
||||
)
|
||||
// A failure anywhere in the client lifecycle must never take the app down
|
||||
@@ -89,15 +112,113 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
}
|
||||
try {
|
||||
// Fresh chunk state for this activation: invalidate any chunk factories
|
||||
// registered by a previous fiber (HMR) and drop the in-memory load cache
|
||||
// so the next lazy open re-fetches the current chunk scripts.
|
||||
resetChunks()
|
||||
// rc.8+ exposes the client module system as the `ctx.modules` service
|
||||
// (no window.__DSH_MODULES__ page global anymore); the chunk loader needs
|
||||
// it to resolve its externals, so inject it before anything can load a
|
||||
// lazy chunk. The loader falls back to the rc.7 global when absent.
|
||||
setChunkModuleSystem(ctx.modules)
|
||||
// Fresh chunk state for this activation: drop per-test fixtures and
|
||||
// revalidate loaded chunk scripts against the bundle route's ETags —
|
||||
// unchanged chunks keep their resolved exports (no re-inject /
|
||||
// re-execute on HMR), changed ones are dropped for a clean re-fetch.
|
||||
void revalidateChunksOnReactivate()
|
||||
ctx.effect(() => {
|
||||
let disposed = false
|
||||
let root: Root | undefined
|
||||
let host: HTMLDivElement | undefined
|
||||
void (async () => {
|
||||
let mounted = false
|
||||
let bodyObserver: MutationObserver | undefined
|
||||
let hostCheckFrame: number | null = null
|
||||
const unmount = (): void => {
|
||||
if (!mounted) return
|
||||
mounted = false
|
||||
bodyObserver?.disconnect()
|
||||
bodyObserver = undefined
|
||||
if (hostCheckFrame !== null) {
|
||||
cancelAnimationFrame(hostCheckFrame)
|
||||
hostCheckFrame = null
|
||||
}
|
||||
root?.unmount()
|
||||
root = undefined
|
||||
host?.remove()
|
||||
host = undefined
|
||||
}
|
||||
/** Re-attach the host if the page (a desktop shell wrapper, SPA
|
||||
* navigation, …) ever removes it from <body>. Cheap: childList only,
|
||||
* no subtree, no attribute filtering. */
|
||||
const guardAnchor = (): void => {
|
||||
if (bodyObserver !== undefined) return
|
||||
bodyObserver = new MutationObserver(() => {
|
||||
if (host !== undefined && !document.body.contains(host)) {
|
||||
document.body.appendChild(host)
|
||||
}
|
||||
})
|
||||
bodyObserver.observe(document.body, { childList: true })
|
||||
}
|
||||
/** One-shot geometry self-check: if the host page transforms
|
||||
* <html>/<body> itself (exotic shells), a fixed panel host would
|
||||
* track the transformed box instead of the viewport. Flip the
|
||||
* degraded mode and pin the host to the viewport every frame until
|
||||
* the ancestor transform is actually gone. The normal path (no
|
||||
* page-level transform) never runs the sync loop. */
|
||||
const scheduleHostCheck = (): void => {
|
||||
hostCheckFrame ??= requestAnimationFrame(() => {
|
||||
hostCheckFrame = null
|
||||
const layer = host?.querySelector<HTMLElement>('[data-dsh-panel-host]')
|
||||
if (layer === null || layer === undefined) return
|
||||
const rect = layer.getBoundingClientRect()
|
||||
const mismatched = Math.abs(rect.left) > 8 || Math.abs(rect.top) > 8
|
||||
|| Math.abs(rect.width - window.innerWidth) > 8 || Math.abs(rect.height - window.innerHeight) > 8
|
||||
if (!mismatched) {
|
||||
layer.removeAttribute('data-dsh-panel-host-degraded')
|
||||
layer.style.transform = ''
|
||||
return
|
||||
}
|
||||
layer.setAttribute('data-dsh-panel-host-degraded', '')
|
||||
console.warn('[dsh-better-sidebar] panel host geometry mismatch — a page-level transform was detected; using degraded viewport sync')
|
||||
// Track our own compensating translation so the loop judges the
|
||||
// UNCORRECTED geometry: clearing degraded mode must wait for the
|
||||
// ancestor transform to actually disappear — the frame right after
|
||||
// our correction applies would otherwise look "fixed" and the
|
||||
// offset would return immediately (CR #232 P1).
|
||||
let applied = { x: 0, y: 0 }
|
||||
const sync = (): void => {
|
||||
const r = layer.getBoundingClientRect()
|
||||
const rawLeft = r.left - applied.x
|
||||
const rawTop = r.top - applied.y
|
||||
if (Math.abs(rawLeft) <= 1 && Math.abs(rawTop) <= 1
|
||||
&& Math.abs(r.width - window.innerWidth) <= 1 && Math.abs(r.height - window.innerHeight) <= 1) {
|
||||
layer.removeAttribute('data-dsh-panel-host-degraded')
|
||||
layer.style.transform = ''
|
||||
return
|
||||
}
|
||||
const next = { x: -rawLeft, y: -rawTop }
|
||||
if (next.x !== applied.x || next.y !== applied.y) {
|
||||
applied = next
|
||||
layer.style.transform = `translate(${applied.x}px, ${applied.y}px)`
|
||||
}
|
||||
hostCheckFrame = requestAnimationFrame(sync)
|
||||
}
|
||||
hostCheckFrame = requestAnimationFrame(sync)
|
||||
})
|
||||
}
|
||||
const mount = (): void => {
|
||||
if (mounted || disposed) return
|
||||
try {
|
||||
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
|
||||
guardAnchor()
|
||||
scheduleHostCheck()
|
||||
} 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,
|
||||
// so a brand-new conversation opens (or stays closed) at the chosen
|
||||
// width from first paint. A settings route failure falls back to the
|
||||
@@ -109,20 +230,26 @@ export function apply(ctx: Context): void {
|
||||
])
|
||||
if (prefs !== null) sidebarStore.setPrefs(prefs)
|
||||
if (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 })))
|
||||
} catch (error) {
|
||||
fail('mount', error)
|
||||
}
|
||||
})()
|
||||
// Mutual exclusion with the dsh-web-ui family right panel: while the
|
||||
// aionui-panel provider is selected, the sidebar must not mount at
|
||||
// all. Re-evaluated on every settings-document update (live switch).
|
||||
const suspended = await loadExternalDisable(api)
|
||||
if (disposed) return
|
||||
sidebarStore.setSuspended(suspended)
|
||||
if (suspended) unmount()
|
||||
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 () => {
|
||||
disposed = true
|
||||
root?.unmount()
|
||||
host?.remove()
|
||||
offRemote?.()
|
||||
unmount()
|
||||
}
|
||||
}, 'dsh-better-sidebar: sidebar mount')
|
||||
|
||||
@@ -153,16 +280,36 @@ export function apply(ctx: Context): void {
|
||||
ctx.effect(
|
||||
() => {
|
||||
try {
|
||||
// External http(s) links in the chat/GUI open the sidebar browser
|
||||
// instead of a new window (gated on the browserInterceptLinks pref
|
||||
// AND the browser tab's enable switch; Ctrl/Cmd+click bypasses).
|
||||
// External http(s) links in the chat/GUI open the sidebar instead
|
||||
// of a new window. Gated on the browserInterceptLinks MASTER pref,
|
||||
// the URL's protocol flag (browserInterceptHttp / Https — https
|
||||
// defaults OFF: most https sites refuse iframe embedding), and the
|
||||
// target tab's enable switch; Ctrl/Cmd+click always bypasses. The
|
||||
// target is the first registered tab whose `urlTarget` claims the
|
||||
// URL (enabled tabs only), else the built-in browser tab.
|
||||
const urlTargetOf = (url: URL): string | undefined => {
|
||||
const prefs = sidebarStore.getPrefs()
|
||||
const enabled = service.getTabs().filter(tab => prefs.tabsEnabled[tab.id] !== false)
|
||||
return matchUrlTarget(enabled, url)?.id
|
||||
}
|
||||
return registerLinkInterception({
|
||||
takeoverEnabled: () => sidebarStore.getPrefs().browserInterceptLinks !== false
|
||||
&& sidebarStore.getPrefs().tabsEnabled['browser'] !== false,
|
||||
takeoverEnabled: (url) => {
|
||||
if (sidebarStore.getSuspended()) return false
|
||||
const prefs = sidebarStore.getPrefs()
|
||||
if (prefs.browserInterceptLinks === false) return false
|
||||
const protocolOn = url.protocol === 'https:'
|
||||
? prefs.browserInterceptHttps !== false
|
||||
: prefs.browserInterceptHttp !== false
|
||||
if (!protocolOn) return false
|
||||
// A plugin claim is the target (already enabled-filtered);
|
||||
// otherwise the built-in browser must be enabled.
|
||||
return urlTargetOf(url) !== undefined || prefs.tabsEnabled['browser'] !== false
|
||||
},
|
||||
openInSidebar: (url) => {
|
||||
let title: string | undefined
|
||||
try { title = new URL(url).hostname } catch { /* keep the default title */ }
|
||||
ctx.betterSidebar?.openTab({ type: 'browser', url, title })
|
||||
const type = urlTargetOf(new URL(url)) ?? 'browser'
|
||||
ctx.betterSidebar?.openTab({ type, url, title })
|
||||
},
|
||||
selfOrigin: window.location.origin,
|
||||
})
|
||||
@@ -194,6 +341,16 @@ export function apply(ctx: Context): void {
|
||||
'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
|
||||
// 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
|
||||
|
||||
@@ -76,8 +76,10 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
|
||||
name: 'conversation.chat.turnTail',
|
||||
// Decline the takeover while the editor tab type is disabled in the side
|
||||
// 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) => {
|
||||
if (store.getSuspended()) return null
|
||||
if (store.getPrefs().tabsEnabled['editor'] === false) return null
|
||||
return selectProducedFiles(owner)
|
||||
},
|
||||
@@ -99,7 +101,8 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
|
||||
*/
|
||||
export function registerOpenPathInterception(ctx: Context, store: SidebarStore): () => void {
|
||||
return wrapOpenPath(ctx.workspaces, {
|
||||
takeoverEnabled: () => store.getPrefs().interceptOpenPath !== false
|
||||
takeoverEnabled: () => !store.getSuspended()
|
||||
&& store.getPrefs().interceptOpenPath !== false
|
||||
&& store.getPrefs().tabsEnabled['editor'] !== false,
|
||||
currentSessionId: () => ctx.sessions.list.getSnapshot().current,
|
||||
openInSidebar: (path, sessionId) => { openSidebarFile(ctx, store, sessionId, path) },
|
||||
|
||||
@@ -23,6 +23,8 @@ import { toml } from '@codemirror/legacy-modes/mode/toml'
|
||||
import { nginx } from '@codemirror/legacy-modes/mode/nginx'
|
||||
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile'
|
||||
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). */
|
||||
export function extOf(path: string): string {
|
||||
@@ -48,6 +50,9 @@ export function languageKeyForExt(ext: string): string | null {
|
||||
case 'yaml': case 'yml': return 'yaml'
|
||||
case 'sql': return 'sql'
|
||||
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 'cc': case 'cpp': case 'cxx': case 'hpp': case 'hh': case 'hxx': return 'cpp'
|
||||
case 'rs': return 'rust'
|
||||
@@ -76,6 +81,9 @@ const FACTORIES: Record<string, () => Language | LanguageSupport> = {
|
||||
yaml: () => yaml(),
|
||||
sql: () => sql(),
|
||||
java: () => java(),
|
||||
csharp: () => StreamLanguage.define(csharp),
|
||||
kotlin: () => StreamLanguage.define(kotlin),
|
||||
swift: () => StreamLanguage.define(swift),
|
||||
c: () => cpp(),
|
||||
cpp: () => cpp(),
|
||||
rust: () => rust(),
|
||||
|
||||
+42
-15
@@ -5,14 +5,22 @@
|
||||
* panel's width squeeze (margin-right on #root) lands exactly on the
|
||||
* conversation output and the input bar, like a VSCode sidebar.
|
||||
*
|
||||
* The width rides `calc(100% - var(...))` instead of a bare margin on a
|
||||
* full-width box: some desktop shells (DSH Desktop, #208) set #root to
|
||||
* width:100%, where a margin would overflow the viewport additively —
|
||||
* the calc keeps the box at exactly 100% minus the push in every shell.
|
||||
* Width and margin transition in lockstep (same variable, same duration
|
||||
* and easing), so expand/collapse animates the content width exactly as
|
||||
* the bare-margin version did.
|
||||
*
|
||||
* The bottom panel squeezes ONLY the center column — it must not cover the
|
||||
* app's own left sidebar or the right panel. DSH 0.1.x wraps slot hosts in
|
||||
* [data-slot] containers, so the AppFrame grid lives one level deeper:
|
||||
* #root > div[data-slot="root"] > div (the frame). Its grid items are
|
||||
* (frame > div:nth-child(1..3)): sidebarCol, centerCol, detailsCol, so the
|
||||
* vertical push lands on the center column alone. The fixed panels cover
|
||||
* the vacated strips, which looks seamless because #root's background is
|
||||
* the theme base.
|
||||
* app's own left sidebar or the right panel. The anchor is the frame's
|
||||
* center grid item via its stable data attribute ([data-pane="conversation"]
|
||||
* on the centerCol inside [data-dsh-frame]), not a positional path: the
|
||||
* frame's child order also contains an overlay layer and drag handles
|
||||
* (verified against a live DSH 0.1.x page), so nth-child is brittle. A
|
||||
* stretched grid item shrinks by its margins, so the conversation content
|
||||
* (output + input bar) lifts without touching the sidebars.
|
||||
*
|
||||
* The sizes ride CSS variables updated by the Sidebar shell (0 while
|
||||
* collapsed); expand/collapse animates both the margins and the panel
|
||||
@@ -21,14 +29,14 @@
|
||||
*/
|
||||
#root {
|
||||
margin-right: var(--dsh-sidebar-width, 0px);
|
||||
transition: margin-right var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
width: calc(100% - var(--dsh-sidebar-width, 0px));
|
||||
transition:
|
||||
margin-right var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
/* The AppFrame's grid items are sidebarCol, centerCol, detailsCol (children
|
||||
1-3 of #root > div[data-slot="root"] > div) — nth-child(2) is the center
|
||||
column. A stretched grid item shrinks by its margins, so the conversation
|
||||
content (output + input bar) lifts without touching the sidebars. */
|
||||
#root > div[data-slot="root"] > div > div:nth-child(2) {
|
||||
/* The AppFrame's center column, anchored by data attributes (see above). */
|
||||
#root [data-dsh-frame] > [data-pane="conversation"] {
|
||||
margin-bottom: var(--dsh-sidebar-height, 0px);
|
||||
transition: margin-bottom var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
@@ -47,13 +55,32 @@ body[data-dsh-sidebar-collapsed] [data-slot="conversation.session.header"] > hea
|
||||
}
|
||||
|
||||
body[data-dsh-sidebar-dragging] #root,
|
||||
body[data-dsh-sidebar-dragging] #root > div[data-slot="root"] > div > div:nth-child(2) {
|
||||
body[data-dsh-sidebar-dragging] #root [data-dsh-frame] > [data-pane="conversation"] {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
#root,
|
||||
#root > div[data-slot="root"] > div > div:nth-child(2) {
|
||||
#root [data-dsh-frame] > [data-pane="conversation"] {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* Chat/GUI external-link interception: clicking an http(s) link that points
|
||||
* OUTSIDE the GUI (chat messages, tool rows, prose mentions) opens the
|
||||
* sidebar browser instead of a new browser tab. Gated by BOTH the
|
||||
* `browserInterceptLinks` pref and the browser tab's enable switch; a
|
||||
* Ctrl/Cmd/Shift/Alt-modified click always bypasses the takeover so the
|
||||
* user can still force a real browser tab.
|
||||
* sidebar instead of a new browser tab. Gated by the caller through
|
||||
* `takeoverEnabled(url)` — the `browserInterceptLinks` master, the URL's
|
||||
* protocol flag (`browserInterceptHttp` / `browserInterceptHttps`) and the
|
||||
* target tab's enable switch — and a Ctrl/Cmd/Shift/Alt-modified click
|
||||
* always bypasses the takeover so the user can still force a real browser
|
||||
* tab.
|
||||
*
|
||||
* Only the GUI's OWN document is watched — links inside the browser tab's
|
||||
* sandboxed iframe live in another document and never bubble here (and
|
||||
@@ -13,7 +15,10 @@
|
||||
|
||||
/** The pure decision: the URL to open in the sidebar, or null to let the
|
||||
* click fall through. Extracted so the policy is unit-testable without a
|
||||
* DOM. `anchorHref` must be the ABSOLUTE href (`<a>.href` already is). */
|
||||
* DOM. `anchorHref` must be the ABSOLUTE href (`<a>.href` already is).
|
||||
* The protocol/same-origin policy lives HERE; the prefs gates (master +
|
||||
* protocol flags + target enablement) live in the caller's
|
||||
* `takeoverEnabled(url)` callback. */
|
||||
export function shouldInterceptLink(anchorHref: string, selfOrigin: string): string | null {
|
||||
let url: URL
|
||||
try {
|
||||
@@ -23,7 +28,7 @@ export function shouldInterceptLink(anchorHref: string, selfOrigin: string): str
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
|
||||
// Same-origin links are GUI-internal navigation (settings pages, tool
|
||||
// docs) — never routed into the sidebar browser.
|
||||
// docs) — never routed into the sidebar.
|
||||
try {
|
||||
if (url.origin === new URL(selfOrigin).origin) return null
|
||||
} catch {
|
||||
@@ -39,11 +44,13 @@ export function isPlainLeftClick(event: { button: number; metaKey: boolean; ctrl
|
||||
|
||||
/**
|
||||
* Register the document-level click capture that funnels external links
|
||||
* into the sidebar browser. Returns the disposer (HMR-safe).
|
||||
* into the sidebar. Returns the disposer (HMR-safe).
|
||||
*/
|
||||
export function registerLinkInterception(opts: {
|
||||
takeoverEnabled: () => boolean
|
||||
/** Open the sidebar browser tab at `url` (the caller resolves the session). */
|
||||
/** Whether the takeover may happen for THIS url (the caller's prefs
|
||||
* gates: master switch, protocol flag, target enablement). */
|
||||
takeoverEnabled: (url: URL) => boolean
|
||||
/** Open the sidebar tab at `url` (the caller resolves the target type). */
|
||||
openInSidebar: (url: string) => void
|
||||
/** The GUI's own origin (window.location.origin at registration). */
|
||||
selfOrigin: string
|
||||
@@ -51,13 +58,13 @@ export function registerLinkInterception(opts: {
|
||||
const onClick = (event: MouseEvent): void => {
|
||||
if (!isPlainLeftClick(event)) return
|
||||
if (event.defaultPrevented) return
|
||||
if (!opts.takeoverEnabled()) return
|
||||
const target = event.target
|
||||
if (target === null || typeof (target as Element).closest !== 'function') return
|
||||
const anchor = (target as Element).closest('a[href]') as HTMLAnchorElement | null
|
||||
if (anchor === null) return
|
||||
const url = shouldInterceptLink(anchor.href, opts.selfOrigin)
|
||||
if (url === null) return
|
||||
if (!opts.takeoverEnabled(new URL(url))) return
|
||||
event.preventDefault()
|
||||
opts.openInSidebar(url)
|
||||
}
|
||||
|
||||
+88
-2
@@ -11,12 +11,28 @@
|
||||
|
||||
/** The zh dictionary (also registered into the DSH locale registry under {@link LOCALE_NS}). */
|
||||
export const zh = {
|
||||
files: '文件',
|
||||
explorer: '资源管理器',
|
||||
git: '源代码管理',
|
||||
terminal: '终端',
|
||||
editor: '编辑器',
|
||||
editorExplorer: '文件打开方式',
|
||||
editorExplorerDesc: '控制文件打开方式',
|
||||
editorExplorerMerged: '合并',
|
||||
editorExplorerMergedDesc: '文件在同一窗口内原地切换;新窗口默认展开文件树',
|
||||
editorExplorerSplit: '独立',
|
||||
editorExplorerSplitDesc: '无路径窗口即资源管理器(仅文件树);文件各自新开窗口(带文件树,默认收起)',
|
||||
editorTreeToggle: '文件树面板',
|
||||
editorPathPlaceholder: '输入文件路径(相对会话目录或绝对路径),Enter 打开',
|
||||
editorSearchPlaceholder: '按文件名搜索…',
|
||||
editorSearchNoResults: '无匹配文件',
|
||||
editorSearchTruncated: '结果过多,仅显示部分匹配',
|
||||
editorEmptyHint: '从右侧文件树或上方路径输入框选择文件开始预览',
|
||||
openFileNewTab: '在新 Tab 中打开',
|
||||
openFileSide: '在侧边打开',
|
||||
newTab: '新建标签页',
|
||||
openExplorer: '资源管理器',
|
||||
brokenSymlink: '失效的软链接',
|
||||
openGit: 'Git 面板',
|
||||
newTerminal: '新终端',
|
||||
terminalLimit: '终端数量已达上限 (3)',
|
||||
@@ -28,8 +44,16 @@ export const zh = {
|
||||
terminalError: '终端连接失败',
|
||||
terminalConnectFailed: '终端多次连接失败',
|
||||
terminalRetry: '重试',
|
||||
terminalDepsFailed: '终端依赖 node-pty 加载失败',
|
||||
terminalDepsHint: '在 DSH 所在环境的终端或 cmd 中执行以下命令修复,然后点重试(node-pty 与 DSH 核心保持同一版本):',
|
||||
terminalDepsProfile: '(检测到 profile:{profile})',
|
||||
preview: '预览',
|
||||
edit: '编辑',
|
||||
mermaidError: 'Mermaid 渲染失败',
|
||||
mermaidZoomIn: '放大',
|
||||
mermaidZoomOut: '缩小',
|
||||
mermaidZoomReset: '重置',
|
||||
mermaidZoomHint: '滚轮缩放 · 拖拽平移 · Esc 关闭',
|
||||
refresh: '刷新',
|
||||
save: '保存',
|
||||
saved: '已保存',
|
||||
@@ -116,6 +140,10 @@ export const zh = {
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: '聊天区文件在侧边栏打开',
|
||||
settingsOpenPathDesc: '在聊天里点击文件链接(工具行、产物列表、文件提及)时,在侧边栏编辑器中打开,不再调用系统默认应用',
|
||||
settingsTitleBarTitle: '位置兼容模式',
|
||||
settingsTitleBarDesc: '为 Windows 右上角的原生标题栏预留空间:侧边栏按钮与侧边栏内容整体下移,避免被标题栏遮挡',
|
||||
settingsTitleBarStripTitle: '下移距离',
|
||||
settingsTitleBarStripDesc: '标题栏条带高度:侧边栏按钮与内容下移的像素数(0–120,默认 40)',
|
||||
settingsSaveFailed: '保存失败',
|
||||
settingsConflict: '设置已被其他窗口修改,请重试',
|
||||
binaryNoPreview: '此文件类型不支持预览',
|
||||
@@ -134,6 +162,12 @@ export const zh = {
|
||||
settingsFontSizeTitle: '终端字号',
|
||||
settingsFontSizeDesc: '终端字号(9–32,默认 13)',
|
||||
settingsFontSizeSuffix: 'px',
|
||||
settingsShellTitle: 'Shell 路径',
|
||||
settingsShellDesc: 'UI 与模型终端启动的 shell(绝对路径或可执行名)。留空按既有顺序解析:yaml 的 config.shell → $SHELL / 登录 shell / Windows 的 powershell.exe。对之后打开的终端生效',
|
||||
settingsShellPlaceholder: '如 /bin/zsh(留空自动解析)',
|
||||
settingsShellArgsTitle: 'Shell 参数',
|
||||
settingsShellArgsDesc: '显式 shell 启动参数,空格分隔;非空时完全替换默认参数(与 yaml 的 shellArgs 契约一致)',
|
||||
settingsShellArgsPlaceholder: '如 -l(留空用默认参数)',
|
||||
settingsTabsTitle: '侧边栏内容',
|
||||
settingsViewersTitle: '文件预览',
|
||||
settingsGeneralTitle: '常规',
|
||||
@@ -166,7 +200,11 @@ export const zh = {
|
||||
settingsBrowserSandboxTitle: '关闭浏览器沙箱(不安全)',
|
||||
settingsBrowserSandboxDesc: '关闭后,访问的任何网站都将与界面同源运行,可读取会话数据并冒充你的登录状态。仅对完全可信的站点开启',
|
||||
settingsBrowserLinksTitle: '聊天区外链在侧边栏打开',
|
||||
settingsBrowserLinksDesc: '开启后,点击聊天或界面中的 http/https 外链时在侧边栏浏览器中打开,不再弹出新窗口;Ctrl/Cmd 点击可临时放行',
|
||||
settingsBrowserLinksDesc: '开启后,点击聊天或界面中的外链时在侧边栏打开,不再弹出新窗口;HTTP 与 HTTPS 可分别通过下方开关控制;Ctrl/Cmd 点击可临时放行',
|
||||
settingsBrowserHttpTitle: '侧边打开HTTP网页',
|
||||
settingsBrowserHttpDesc: '开启后,点击聊天或界面中的 HTTP 外链时在侧边栏打开(声明了 urlTarget 的插件页面优先);Ctrl/Cmd 点击可临时放行',
|
||||
settingsBrowserHttpsTitle: '侧边打开HTTPS网页',
|
||||
settingsBrowserHttpsDesc: '开启后,点击聊天或界面中的 HTTPS 外链时在侧边栏打开。默认关闭:多数 HTTPS 站点拒绝被嵌入,走系统浏览器更顺畅',
|
||||
browserOpenExternal: '在浏览器中打开',
|
||||
browserEmbedBlocked: '{host} 拒绝了嵌入请求',
|
||||
browserEmbedBlockedDesc: '该站点通过 X-Frame-Options / frame-ancestors 禁止在其它页面中显示,无法在侧边栏内加载。可在浏览器中直接打开',
|
||||
@@ -213,22 +251,43 @@ export const zh = {
|
||||
addPluginsTabDesc: '侧边栏页面(Tab)可以由插件扩展。插件通过 ctx.betterSidebar 服务注册;点击「安装」复制安装命令,粘贴到 DSH 所在环境的终端执行。',
|
||||
addPluginsViewerDesc: '文件预览器可以由插件扩展。插件通过 ctx.betterSidebar 服务注册;点击「安装」复制安装命令,粘贴到 DSH 所在环境的终端执行。',
|
||||
addPluginsBrowseMore: '在 GitHub 上浏览更多插件(topic: dsh-better-sidebar)',
|
||||
addPluginsSearch: '搜索插件名称 / 描述…',
|
||||
addPluginsNoMatch: '没有匹配的插件',
|
||||
addPluginsRecommended: '推荐插件',
|
||||
addPluginsEmpty: '暂未收录插件,欢迎在 GitHub topic 下发布你的插件',
|
||||
openPlugin: '跳转',
|
||||
copyInstall: '复制安装命令',
|
||||
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 展示服务器全局监控表',
|
||||
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). */
|
||||
export const en: Record<keyof typeof zh, string> = {
|
||||
files: 'Files',
|
||||
explorer: 'Explorer',
|
||||
git: 'Source Control',
|
||||
terminal: 'Terminal',
|
||||
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',
|
||||
openExplorer: 'Explorer',
|
||||
brokenSymlink: 'Broken symlink',
|
||||
openGit: 'Git panel',
|
||||
newTerminal: 'New terminal',
|
||||
terminalLimit: 'Terminal limit reached (3)',
|
||||
@@ -240,8 +299,16 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
terminalError: 'Terminal connection failed',
|
||||
terminalConnectFailed: 'Terminal failed to connect repeatedly',
|
||||
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',
|
||||
edit: 'Edit',
|
||||
mermaidError: 'Mermaid render failed',
|
||||
mermaidZoomIn: 'Zoom in',
|
||||
mermaidZoomOut: 'Zoom out',
|
||||
mermaidZoomReset: 'Reset',
|
||||
mermaidZoomHint: 'Scroll to zoom · drag to pan · Esc to close',
|
||||
refresh: 'Refresh',
|
||||
save: 'Save',
|
||||
saved: 'Saved',
|
||||
@@ -328,6 +395,10 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'Open chat files in the sidebar',
|
||||
settingsOpenPathDesc: 'Open file links in the chat (tool rows, produced files, mentions) in the sidebar editor instead of the system default app',
|
||||
settingsTitleBarTitle: 'Position compatibility mode',
|
||||
settingsTitleBarDesc: 'Reserve space for the native Windows title bar at the top-right so the sidebar buttons and content sit below it instead of underneath',
|
||||
settingsTitleBarStripTitle: 'Shift distance',
|
||||
settingsTitleBarStripDesc: 'Title-bar strip height: how far the sidebar buttons and content move down in px (0–120, default 40)',
|
||||
settingsSaveFailed: 'Failed to save',
|
||||
settingsConflict: 'The setting changed in another window — please retry',
|
||||
binaryNoPreview: 'This file type cannot be previewed',
|
||||
@@ -344,6 +415,12 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
settingsFontFamilyDesc: 'Custom terminal font family (a CSS font-family stack like "JetBrains Mono", monospace; leave empty to follow the theme\'s monospace font)',
|
||||
settingsFontFamilyPlaceholder: '"JetBrains Mono", monospace',
|
||||
settingsFontSizeTitle: 'Terminal font size',
|
||||
settingsShellTitle: 'Shell path',
|
||||
settingsShellDesc: 'Shell spawned for UI and model terminals (absolute path or bare executable). Empty keeps the legacy order: yaml config.shell → $SHELL / login shell / Windows powershell.exe. Applies to terminals opened afterwards',
|
||||
settingsShellPlaceholder: 'e.g. /bin/zsh (empty = auto)',
|
||||
settingsShellArgsTitle: 'Shell arguments',
|
||||
settingsShellArgsDesc: 'Explicit shell arguments, space-separated; when non-empty they fully replace the defaults (same contract as the yaml shellArgs)',
|
||||
settingsShellArgsPlaceholder: 'e.g. -l (empty = defaults)',
|
||||
settingsFontSizeDesc: 'Terminal font size in px (9–32, default 13)',
|
||||
settingsFontSizeSuffix: 'px',
|
||||
settingsTabsTitle: 'Sidebar content',
|
||||
@@ -378,7 +455,11 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
settingsBrowserSandboxTitle: 'Disable browser sandbox (unsafe)',
|
||||
settingsBrowserSandboxDesc: 'With the sandbox off, any visited site runs with the same origin as the GUI: it can read session data and act as your logged-in session. Only enable for fully trusted sites',
|
||||
settingsBrowserLinksTitle: 'Open chat external links in the sidebar',
|
||||
settingsBrowserLinksDesc: 'When on, clicking an http/https external link in the chat or GUI opens the sidebar browser instead of a new window; Ctrl/Cmd+click always bypasses',
|
||||
settingsBrowserLinksDesc: 'When on, clicking an external link in the chat or GUI opens the sidebar instead of a new window; HTTP and HTTPS are controlled separately by the switches below; Ctrl/Cmd+click always bypasses',
|
||||
settingsBrowserHttpTitle: 'Open HTTP pages in the sidebar',
|
||||
settingsBrowserHttpDesc: 'When on, clicking an HTTP external link in the chat or GUI opens the sidebar (plugin pages declaring urlTarget win); Ctrl/Cmd+click always bypasses',
|
||||
settingsBrowserHttpsTitle: 'Open HTTPS pages in the sidebar',
|
||||
settingsBrowserHttpsDesc: 'When on, clicking an HTTPS external link in the chat or GUI opens the sidebar. Off by default: most HTTPS sites refuse to be embedded, so the system browser is the smoother default',
|
||||
browserOpenExternal: 'Open in browser',
|
||||
browserEmbedBlocked: '{host} refused to be embedded',
|
||||
browserEmbedBlockedDesc: 'The site forbids being displayed inside other pages (X-Frame-Options / frame-ancestors), so it cannot load in the sidebar. Open it directly in your browser instead.',
|
||||
@@ -425,12 +506,17 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
addPluginsTabDesc: 'Sidebar pages (tabs) can be extended by plugins. Plugins register through the ctx.betterSidebar service; clicking Install copies the install command — paste it into a terminal where your DSH profile lives and run it.',
|
||||
addPluginsViewerDesc: 'File previewers can be extended by plugins. Plugins register through the ctx.betterSidebar service; clicking Install copies the install command — paste it into a terminal where your DSH profile lives and run it.',
|
||||
addPluginsBrowseMore: 'Browse more plugins on GitHub (topic: dsh-better-sidebar)',
|
||||
addPluginsSearch: 'Search by plugin name or description…',
|
||||
addPluginsNoMatch: 'No plugins match',
|
||||
addPluginsRecommended: 'Recommended plugins',
|
||||
addPluginsEmpty: 'No plugins curated yet — publish yours under the GitHub topic',
|
||||
openPlugin: 'Open',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Markdown/mermaid fence detection for the markdown preview. The preview
|
||||
* renders the WHOLE document through one DSH `MarkdownText` pass (so
|
||||
* cross-fence semantics — reference-style links, footnotes, list
|
||||
* continuity — stay intact) and the mermaid lazy chunk then swaps the
|
||||
* rendered `language-mermaid` code blocks for diagrams. This module's pure
|
||||
* splitter exists to detect whether the source contains a mermaid fence at
|
||||
* all, so the mermaid chunk is only fetched when needed (unit-tested in
|
||||
* tests/mermaid-blocks.spec.ts).
|
||||
*/
|
||||
|
||||
/** One fenced mermaid diagram lifted out of the markdown source. */
|
||||
export interface MermaidBlock {
|
||||
kind: 'mermaid'
|
||||
/** The raw diagram source between the fences (info string stripped). */
|
||||
code: string
|
||||
}
|
||||
|
||||
/** A span of plain markdown source (may itself contain non-mermaid fences). */
|
||||
export interface MarkdownBlock {
|
||||
kind: 'markdown'
|
||||
text: string
|
||||
}
|
||||
|
||||
export type MdBlock = MarkdownBlock | MermaidBlock
|
||||
|
||||
/** Props of the chunk-resident `MermaidMarkdown` component (shared contract). */
|
||||
export interface MermaidMarkdownProps {
|
||||
/** The full markdown source (rendered in a single MarkdownText pass). */
|
||||
text: string
|
||||
codeLabels: { copyLabel: string; copiedLabel: string }
|
||||
}
|
||||
|
||||
/** CommonMark opening fence: 0-3 spaces indent + a run of 3+ backticks or tildes. */
|
||||
const OPEN_FENCE_RE = /^ {0,3}(`{3,}|~{3,})/
|
||||
|
||||
/** A closing-fence line: 0-3 spaces indent + 3+ backticks/tildes + trailing spaces only. */
|
||||
const CLOSE_FENCE_RE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/
|
||||
|
||||
/** Parse the info string from the line tail after the fence run; null when invalid. */
|
||||
function fenceInfo(rest: string, fence: string): string | null {
|
||||
const info = rest.trimStart().split(/\s+/)[0] ?? ''
|
||||
// CommonMark: a backtick fence's info string may not contain backticks —
|
||||
// such a line is not an opening fence at all.
|
||||
if (fence.charAt(0) === '`' && info.includes('`')) return null
|
||||
return info
|
||||
}
|
||||
|
||||
/** True when the fence info string names mermaid (bare or `mermaid{...}`). */
|
||||
function isMermaidInfo(info: string): boolean {
|
||||
const word = info.toLowerCase()
|
||||
return word === 'mermaid' || word.startsWith('mermaid{')
|
||||
}
|
||||
|
||||
/**
|
||||
* Split markdown source into md/mermaid blocks for detection: only fences
|
||||
* whose info string names mermaid are lifted; every other line stays in the
|
||||
* markdown stream untouched. CommonMark fence rules are honored — opening
|
||||
* fences of 3+ backticks OR tildes, and a closing fence must use the same
|
||||
* character with at least as many characters as the opening fence. An
|
||||
* unterminated mermaid fence swallows the rest of the file (the same
|
||||
* recovery CommonMark applies to open fences).
|
||||
*/
|
||||
export function splitMermaidBlocks(text: string): MdBlock[] {
|
||||
if (text === '') return []
|
||||
const lines = text.split('\n')
|
||||
const blocks: MdBlock[] = []
|
||||
let markdown: string[] = []
|
||||
let index = 0
|
||||
const flushMarkdown = (): void => {
|
||||
if (markdown.length === 0) return
|
||||
blocks.push({ kind: 'markdown', text: markdown.join('\n') })
|
||||
markdown = []
|
||||
}
|
||||
while (index < lines.length) {
|
||||
const line = lines[index] ?? ''
|
||||
const fenceMatch = OPEN_FENCE_RE.exec(line)
|
||||
if (fenceMatch === null) {
|
||||
markdown.push(line)
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
const fence = fenceMatch[1]!
|
||||
// The line tail after the whole matched prefix (indent + fence run).
|
||||
const rest = line.slice(fenceMatch.index + fenceMatch[0].length)
|
||||
const info = fenceInfo(rest, fence)
|
||||
if (info === null || !isMermaidInfo(info)) {
|
||||
markdown.push(line)
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
flushMarkdown()
|
||||
const char = fence.charAt(0)
|
||||
const length = fence.length
|
||||
const code: string[] = []
|
||||
index += 1
|
||||
while (index < lines.length) {
|
||||
const candidate = lines[index] ?? ''
|
||||
const close = CLOSE_FENCE_RE.exec(candidate)
|
||||
if (close !== null && close[1]!.charAt(0) === char && close[1]!.length >= length) break
|
||||
code.push(candidate)
|
||||
index += 1
|
||||
}
|
||||
// Consume the closing fence (or run off the end on an open fence).
|
||||
index += 1
|
||||
blocks.push({ kind: 'mermaid', code: code.join('\n') })
|
||||
}
|
||||
flushMarkdown()
|
||||
return blocks
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* SVG sanitization for mermaid-rendered diagrams (markdown preview). The
|
||||
* diagrams come from untrusted markdown sources, so the emitted SVG is
|
||||
* re-sanitized before it reaches dangerouslySetInnerHTML — defense in depth
|
||||
* on top of mermaid's own `securityLevel: 'strict'` (labels escaped, click
|
||||
* directives inert) and `htmlLabels: false` (labels as real SVG <text>).
|
||||
*
|
||||
* What is stripped, and why:
|
||||
* - `foreignObject`: the only channel that can carry raw HTML inside an
|
||||
* SVG document — with it gone, a hostile label cannot smuggle an
|
||||
* <img onerror> / <iframe> through the XML parse (any non-SVG element
|
||||
* outside foreignObject makes the XML parse fail and the whole diagram
|
||||
* is rejected).
|
||||
* - `script` and foreign HTML elements (`img`/`iframe`/`object`/`embed`/
|
||||
* `video`/`audio`/`input`/`button`/`form`/`link`/`meta`/`base`): belt and
|
||||
* braces — real browsers reject these outside foreignObject at XML parse
|
||||
* time (→ '') while lenient parsers keep them, so they are stripped
|
||||
* explicitly instead of relying on the parser alone. Element names are
|
||||
* matched case-insensitively (XML preserves their case; the output is
|
||||
* re-parsed as HTML whose parser normalizes tag casing, so `<sCrIpT>` /
|
||||
* `<foreignobject>` would otherwise come back as script/foreignObject).
|
||||
* - `@*` / `on*` attributes: event-handler channels (Vue-style directives
|
||||
* and native listeners), matched case-insensitively — the output is
|
||||
* re-parsed as HTML whose parser normalizes attribute casing, so
|
||||
* `oNload`/`OnClick` must not survive either.
|
||||
* - ALL `href` / `xlink:href` attributes: the diagrams are static (no
|
||||
* bindFunctions, no click navigation), so links carry no value — and an
|
||||
* attacker-controlled URL (even an http(s) one) could otherwise navigate
|
||||
* the whole GUI away from the sidebar. Removing every href keeps the
|
||||
* surface deterministic.
|
||||
*
|
||||
* Parsing happens with `image/svg+xml`, so a malformed SVG fails the XML
|
||||
* parse and the function returns '' (the caller surfaces the error
|
||||
* fallback) instead of ever passing the raw string through; only a document
|
||||
* whose root is an `<svg>` element is accepted.
|
||||
*/
|
||||
/** Element local names stripped case-insensitively (all lowercase). */
|
||||
const STRIP_ELEMENTS = new Set([
|
||||
'foreignobject',
|
||||
'script',
|
||||
'img',
|
||||
'iframe',
|
||||
'object',
|
||||
'embed',
|
||||
'video',
|
||||
'audio',
|
||||
'input',
|
||||
'button',
|
||||
'form',
|
||||
'link',
|
||||
'meta',
|
||||
'base',
|
||||
])
|
||||
|
||||
/** A parse failure keeps nothing of the input: the caller shows the error. */
|
||||
export function sanitizeSvg(svg: string): string {
|
||||
if (typeof DOMParser === 'undefined' || typeof XMLSerializer === 'undefined') return ''
|
||||
let doc: Document
|
||||
try {
|
||||
doc = new DOMParser().parseFromString(svg, 'image/svg+xml')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
if (doc.querySelector('parsererror') !== null) return ''
|
||||
if (doc.documentElement === null || doc.documentElement.localName !== 'svg') return ''
|
||||
doc.querySelectorAll('*').forEach((node) => {
|
||||
// XML element names preserve case; the string is later re-parsed as HTML
|
||||
// whose parser normalizes tag casing — so mixed-case spellings of the
|
||||
// strip list (e.g. `<sCrIpT>`, `<foreignobject>`) must be caught the
|
||||
// same way as their canonical forms.
|
||||
if (STRIP_ELEMENTS.has(node.localName.toLowerCase())) {
|
||||
node.remove()
|
||||
return
|
||||
}
|
||||
for (const attribute of [...node.attributes]) {
|
||||
const name = attribute.name
|
||||
// XML attribute names preserve case; the string is later re-parsed as
|
||||
// HTML (dangerouslySetInnerHTML), whose parser normalizes attribute
|
||||
// casing — so a mixed-case `oNload`/`HREF` must be caught the same way
|
||||
// as its lowercase form. Compare on the lowercased name, remove with
|
||||
// the original.
|
||||
const normalized = name.toLowerCase()
|
||||
if (normalized.startsWith('@') || normalized.startsWith('on')) {
|
||||
node.removeAttribute(name)
|
||||
continue
|
||||
}
|
||||
if (normalized === 'href' || normalized === 'xlink:href') {
|
||||
node.removeAttribute(name)
|
||||
}
|
||||
}
|
||||
})
|
||||
return new XMLSerializer().serializeToString(doc.documentElement)
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* Mermaid diagram renderer for the markdown preview, resident in the
|
||||
* `mermaid` lazy chunk (src/client/chunks/mermaid.tsx): the mermaid library
|
||||
* and its transitive graph deps (d3/dagre/cytoscape) are inlined into
|
||||
* lib/client-mermaid.js and fetched only when a previewed markdown file
|
||||
* actually contains a mermaid fence (see mermaid-blocks.ts).
|
||||
*
|
||||
* Rendering architecture: the whole document is rendered ONCE through the
|
||||
* DSH `MarkdownText` (so cross-fence semantics — reference-style links,
|
||||
* footnotes, ordered-list continuity — stay intact), then a layout effect
|
||||
* swaps every rendered `language-mermaid` CodeBlock for a diagram. The
|
||||
* swap keeps the React-managed `.md-code-block` host node in the tree and
|
||||
* only replaces its children (display:contents suppresses the code-block
|
||||
* chrome), so React reconciliation never loses the host; when a swapped
|
||||
* block stops being a mermaid fence, the original children are restored.
|
||||
*
|
||||
* Security: mermaid.render → sanitized SVG injected into the block.
|
||||
* `bindFunctions` is intentionally NOT applied (static diagrams; no
|
||||
* flowchart click handlers), `securityLevel` stays 'strict' (labels are
|
||||
* escaped, no raw-HTML foreignObject), `htmlLabels: false` keeps node text
|
||||
* as real SVG <text>, and the emitted SVG is re-sanitized (see
|
||||
* mermaid-sanitize.ts) before it reaches dangerouslySetInnerHTML. Clicking
|
||||
* a rendered diagram opens a zoom/pan modal (borrowed interaction design
|
||||
* from the mermaid PR #75, reimplemented on top of the sanitized SVG —
|
||||
* the clone carries no event surface, so the modal adds no attack surface).
|
||||
*/
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import mermaid from 'mermaid'
|
||||
import { IconCopyOutline16, MarkdownText, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { sanitizeSvg } from './mermaid-sanitize.ts'
|
||||
import type { MermaidMarkdownProps } from './mermaid-blocks.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** Monotonic id seed: every render call gets a fresh, document-unique id. */
|
||||
let mermaidSeq = 0
|
||||
|
||||
/** Configure mermaid for the current color scheme (idempotent). */
|
||||
function configureMermaid(): void {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: 'strict',
|
||||
// Labels as real SVG <text>: mermaid's default htmlLabels renders node
|
||||
// text inside <foreignObject>, which the sanitizer strips wholesale —
|
||||
// forcing pure SVG text keeps labels visible and the HTML label channel
|
||||
// closed (strict already escapes label content).
|
||||
htmlLabels: false,
|
||||
theme: isDarkScheme() ? 'dark' : 'default',
|
||||
})
|
||||
}
|
||||
|
||||
/** First lines of a mermaid error (its dumps are huge; the head explains). */
|
||||
function summarizeError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return message.split('\n').slice(0, 6).join('\n')
|
||||
}
|
||||
|
||||
/** The zoom/pan modal for one rendered diagram (click-to-enlarge). */
|
||||
function MermaidZoomModal({ svg, onClose }: { svg: SVGSVGElement; onClose: () => void }): React.ReactNode {
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
const stageRef = useRef<HTMLDivElement>(null)
|
||||
const svgRef = useRef<SVGSVGElement | null>(null)
|
||||
const dragRef = useRef({ active: false, startX: 0, startY: 0 })
|
||||
const zoomRef = useRef({ scale: 1, tx: 0, ty: 0 })
|
||||
|
||||
const applyTransform = (): void => {
|
||||
const node = svgRef.current
|
||||
if (node === null) return
|
||||
const { scale, tx, ty } = zoomRef.current
|
||||
node.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`
|
||||
}
|
||||
|
||||
/** Zoom by `delta` keeping the stage point (centerX/centerY) fixed. */
|
||||
const zoom = useCallback((delta: number, centerX?: number, centerY?: number): void => {
|
||||
const stage = stageRef.current
|
||||
if (stage === null) return
|
||||
const rect = stage.getBoundingClientRect()
|
||||
const cx = centerX ?? rect.width / 2
|
||||
const cy = centerY ?? rect.height / 2
|
||||
const current = zoomRef.current
|
||||
const newScale = Math.min(8, Math.max(0.2, current.scale * delta))
|
||||
// The svg is flex-centered in the stage, so its center sits at
|
||||
// (rect.width/2, rect.height/2). Solve for the translate that keeps the
|
||||
// mouse point stationary in stage coordinates across the scale change.
|
||||
const sx = rect.width / 2
|
||||
const sy = rect.height / 2
|
||||
const ratio = newScale / current.scale
|
||||
current.tx = cx - sx - (cx - sx - current.tx) * ratio
|
||||
current.ty = cy - sy - (cy - sy - current.ty) * ratio
|
||||
current.scale = newScale
|
||||
applyTransform()
|
||||
}, [])
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
zoomRef.current = { scale: 1, tx: 0, ty: 0 }
|
||||
applyTransform()
|
||||
}, [])
|
||||
|
||||
const close = useCallback((): void => { onClose() }, [onClose])
|
||||
|
||||
// Mount the caller-provided (sanitized) svg clone imperatively: React
|
||||
// types don't accept a raw DOM node as a child, and the modal owns the
|
||||
// node's lifetime (removed on unmount; the preview copy is untouched).
|
||||
useEffect(() => {
|
||||
const stage = stageRef.current
|
||||
if (stage === null) return
|
||||
svgRef.current = svg
|
||||
stage.appendChild(svg)
|
||||
return () => {
|
||||
svg.remove()
|
||||
svgRef.current = null
|
||||
}
|
||||
}, [svg])
|
||||
|
||||
useEffect(() => {
|
||||
const stage = stageRef.current
|
||||
const node = svgRef.current
|
||||
const overlay = overlayRef.current
|
||||
if (stage === null || node === null || overlay === null) return
|
||||
|
||||
const onWheel = (event: WheelEvent): void => {
|
||||
event.preventDefault()
|
||||
const rect = stage.getBoundingClientRect()
|
||||
zoom(event.deltaY < 0 ? 1.1 : 1 / 1.1, event.clientX - rect.left, event.clientY - rect.top)
|
||||
}
|
||||
const onKey = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') { close(); return }
|
||||
if (event.key === '+' || event.key === '=') zoom(1.2)
|
||||
else if (event.key === '-') zoom(1 / 1.2)
|
||||
else if (event.key === '0') reset()
|
||||
}
|
||||
const onMouseDown = (event: MouseEvent): void => {
|
||||
event.preventDefault()
|
||||
dragRef.current = {
|
||||
active: true,
|
||||
startX: event.clientX - zoomRef.current.tx,
|
||||
startY: event.clientY - zoomRef.current.ty,
|
||||
}
|
||||
}
|
||||
const onMouseMove = (event: MouseEvent): void => {
|
||||
if (!dragRef.current.active) return
|
||||
zoomRef.current.tx = event.clientX - dragRef.current.startX
|
||||
zoomRef.current.ty = event.clientY - dragRef.current.startY
|
||||
applyTransform()
|
||||
}
|
||||
const onMouseUp = (): void => { dragRef.current.active = false }
|
||||
const onOverlayClick = (event: MouseEvent): void => {
|
||||
if (event.target === overlay) close()
|
||||
}
|
||||
|
||||
// React's synthetic wheel is passive; a native listener is required to
|
||||
// preventDefault (the page must not scroll while zooming the modal).
|
||||
stage.addEventListener('wheel', onWheel, { passive: false })
|
||||
node.addEventListener('mousedown', onMouseDown)
|
||||
window.addEventListener('mousemove', onMouseMove)
|
||||
window.addEventListener('mouseup', onMouseUp)
|
||||
window.addEventListener('keydown', onKey)
|
||||
overlay.addEventListener('click', onOverlayClick)
|
||||
return () => {
|
||||
stage.removeEventListener('wheel', onWheel)
|
||||
node.removeEventListener('mousedown', onMouseDown)
|
||||
window.removeEventListener('mousemove', onMouseMove)
|
||||
window.removeEventListener('mouseup', onMouseUp)
|
||||
window.removeEventListener('keydown', onKey)
|
||||
overlay.removeEventListener('click', onOverlayClick)
|
||||
}
|
||||
}, [zoom, reset, close])
|
||||
|
||||
return createPortal(
|
||||
<div className={css.mermaidModal} data-mermaid-modal ref={overlayRef}>
|
||||
<div className={css.mermaidModalToolbar}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.mermaidModalButton}
|
||||
title={t('mermaidZoomOut')}
|
||||
onClick={() => zoom(1 / 1.2)}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.mermaidModalButton}
|
||||
title={t('mermaidZoomIn')}
|
||||
onClick={() => zoom(1.2)}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.mermaidModalButton}
|
||||
title={t('mermaidZoomReset')}
|
||||
onClick={reset}
|
||||
>
|
||||
⟳
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.mermaidModalButton}
|
||||
title={t('close')}
|
||||
onClick={close}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className={css.mermaidModalStage} ref={stageRef} />
|
||||
<div className={css.mermaidModalHint}>{t('mermaidZoomHint')}</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
/** One rendered mermaid fence: header chrome + diagram (or error + source). */
|
||||
function MermaidDiagram({ code }: { code: string }): React.ReactNode {
|
||||
const [svg, setSvg] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
/** Scheme state: a flip re-renders the diagram with the matching theme. */
|
||||
const [dark, setDark] = useState(() => isDarkScheme())
|
||||
/** The cloned svg shown in the zoom modal (null = closed). */
|
||||
const [zoomSvg, setZoomSvg] = useState<SVGSVGElement | null>(null)
|
||||
const copyTimer = useRef<number | undefined>(undefined)
|
||||
|
||||
useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setSvg(null)
|
||||
setError(null)
|
||||
if (code.trim() === '') return () => { cancelled = true }
|
||||
configureMermaid()
|
||||
const id = `dsh-md-mermaid-${mermaidSeq += 1}`
|
||||
mermaid.render(id, code)
|
||||
.then(({ svg: rendered }) => {
|
||||
if (cancelled) return
|
||||
const clean = sanitizeSvg(rendered)
|
||||
if (clean === '') {
|
||||
// Parse/sanitize rejection: never pass the raw string through.
|
||||
setError(t('mermaidError'))
|
||||
return
|
||||
}
|
||||
setSvg(clean)
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (cancelled) return
|
||||
setError(summarizeError(reason))
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [code, dark])
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
if (copied) return
|
||||
writeClipboard(code).then((ok) => {
|
||||
if (!ok) return
|
||||
setCopied(true)
|
||||
window.clearTimeout(copyTimer.current)
|
||||
copyTimer.current = window.setTimeout(() => { setCopied(false) }, 1000)
|
||||
})
|
||||
}, [code, copied])
|
||||
|
||||
/** Clicking the diagram opens the zoom modal with a sanitized clone. */
|
||||
const onBodyClick = (event: ReactMouseEvent<HTMLDivElement>): void => {
|
||||
const svgEl = (event.target as Element).closest('svg')
|
||||
if (svgEl === null) return
|
||||
const clone = svgEl.cloneNode(true) as SVGSVGElement
|
||||
// The modal owns sizing/transform; drop the preview's inline geometry.
|
||||
clone.removeAttribute('style')
|
||||
clone.removeAttribute('width')
|
||||
clone.removeAttribute('height')
|
||||
setZoomSvg(clone)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.mermaidWrap}>
|
||||
<div className={css.mermaidHeader}>
|
||||
<span className={css.mermaidInfo}>mermaid</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.mermaidCopy}
|
||||
onClick={onCopy}
|
||||
aria-label={t('copy')}
|
||||
title={t('copy')}
|
||||
>
|
||||
<IconCopyOutline16 />
|
||||
<span>{copied ? t('copied') : t('copy')}</span>
|
||||
</button>
|
||||
</div>
|
||||
{error !== null && <div className={css.mermaidError} title={error}>{t('mermaidError')}</div>}
|
||||
{svg !== null && (
|
||||
<div
|
||||
className={css.mermaidBody}
|
||||
data-mermaid-diagram
|
||||
onClick={onBodyClick}
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
)}
|
||||
{error !== null && <pre className={css.mermaidCode}><code>{code}</code></pre>}
|
||||
{zoomSvg !== null && <MermaidZoomModal svg={zoomSvg} onClose={() => { setZoomSvg(null) }} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One swapped mount: the diagram root + the CodeBlock children it displaced. */
|
||||
interface MermaidMount {
|
||||
root: Root
|
||||
source: string
|
||||
removed: ChildNode[]
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a rendered CodeBlock is a mermaid fence. The DSH CodeBlock has
|
||||
* two bodies: the shiki path carries the `language-*` class on generated
|
||||
* <code> elements, while the plain path (which is always the one mermaid
|
||||
* takes — no shiki grammar) only shows the language in the banner
|
||||
* infostring (first element of the banner row; CSS-module classes are
|
||||
* hashed, so that match is structural).
|
||||
*/
|
||||
function isMermaidBlock(block: HTMLElement): boolean {
|
||||
const code = block.querySelector('code')
|
||||
if (code !== null && [...code.classList].some(c => c.startsWith('language-mermaid'))) return true
|
||||
const infostring = block.firstElementChild?.firstElementChild?.firstElementChild
|
||||
return infostring !== null
|
||||
&& infostring !== undefined
|
||||
&& (infostring.textContent ?? '').trim() === 'mermaid'
|
||||
}
|
||||
|
||||
/**
|
||||
* The chunk-resident markdown preview renderer: ONE MarkdownText pass over
|
||||
* the full source (cross-fence reference/footnote/list semantics intact),
|
||||
* then every rendered `language-mermaid` code block is swapped for a
|
||||
* `MermaidDiagram`. The `.md-code-block` host stays in the React tree —
|
||||
* only its children are replaced — so reconciliation never loses the host;
|
||||
* a block that stops being a mermaid fence gets its original children back.
|
||||
* Only mounted when the source contains at least one mermaid fence (see
|
||||
* TextEditor.tsx).
|
||||
*/
|
||||
export function MermaidMarkdown({ text, codeLabels }: MermaidMarkdownProps): React.ReactNode {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const mountsRef = useRef(new Map<HTMLElement, MermaidMount>())
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (container === null) return
|
||||
const mounts = mountsRef.current
|
||||
const seen = new Set<HTMLElement>()
|
||||
|
||||
for (const block of container.querySelectorAll<HTMLElement>('.md-code-block')) {
|
||||
const mount = mounts.get(block)
|
||||
const isMermaid = isMermaidBlock(block)
|
||||
if (!isMermaid) {
|
||||
// A previously swapped block that is no longer a mermaid fence:
|
||||
// restore the CodeBlock children React still manages, so the plain
|
||||
// fence renders normally again.
|
||||
if (mount !== undefined) {
|
||||
mount.root.unmount()
|
||||
block.replaceChildren(...mount.removed)
|
||||
block.removeAttribute('data-mermaid-processed')
|
||||
mounts.delete(block)
|
||||
}
|
||||
continue
|
||||
}
|
||||
seen.add(block)
|
||||
// The plain body always carries the fence source in <code>.
|
||||
const source = block.querySelector('code')?.textContent ?? ''
|
||||
if (mount !== undefined && mount.source === source) continue
|
||||
if (mount === undefined) {
|
||||
const host = document.createElement('div')
|
||||
const removed = [...block.childNodes]
|
||||
block.replaceChildren(host)
|
||||
block.setAttribute('data-mermaid-processed', 'true')
|
||||
const root = createRoot(host)
|
||||
mounts.set(block, { root, source, removed })
|
||||
root.render(<MermaidDiagram code={source} />)
|
||||
} else {
|
||||
mount.source = source
|
||||
mount.root.render(<MermaidDiagram code={source} />)
|
||||
}
|
||||
}
|
||||
|
||||
// Drop mounts whose code block left the tree (fence removed, document
|
||||
// restructured): the host node is gone with it.
|
||||
for (const [block, mount] of mounts) {
|
||||
if (seen.has(block)) continue
|
||||
mount.root.unmount()
|
||||
mounts.delete(block)
|
||||
}
|
||||
}, [text])
|
||||
|
||||
useEffect(() => () => {
|
||||
for (const { root } of mountsRef.current.values()) root.unmount()
|
||||
mountsRef.current.clear()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={css.mermaidMarkdown} ref={containerRef}>
|
||||
<MarkdownText text={text} codeLabels={codeLabels} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,8 +3,27 @@
|
||||
* the session cwd (for the @-reference button and "copy relative path").
|
||||
* The fs-tree joins with '/' even on Windows, so both separators normalize
|
||||
* 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.
|
||||
* @param cwd - the explorer root (absolute).
|
||||
|
||||
@@ -20,6 +20,9 @@ export interface PluginEntry {
|
||||
url: string
|
||||
/** One-line description (i18n friendly: string or () => string). */
|
||||
description: string | (() => string)
|
||||
/** Optional catalog group heading (i18n friendly); entries without one
|
||||
* render under the plain list (a flat catalog stays flat). */
|
||||
category?: string | (() => string)
|
||||
/** The full shell command pre-filled into the install terminal (not
|
||||
* executed until the user presses Enter). */
|
||||
install: string
|
||||
|
||||
@@ -24,4 +24,20 @@ export const builtinTabPlugins: readonly PluginEntry[] = [
|
||||
// context consistent with the other entries.
|
||||
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',
|
||||
name: 'dsh-sidebar-qa 划选追问',
|
||||
url: 'https://github.com/ChenRuoT/dsh-sidebar-qa',
|
||||
description: () => t('pluginSidebarQaDesc'),
|
||||
// dsh-sidebar-qa hard-depends on dsh-better-sidebar (required peer), so
|
||||
// the install line installs the prerequisite first, then the plugin.
|
||||
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-better-sidebar && dsh plugin --profile web add git+https://github.com/ChenRuoT/dsh-sidebar-qa.git',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -20,4 +20,11 @@ export const builtinViewerPlugins: readonly PluginEntry[] = [
|
||||
description: () => t('pluginOfficeDesc'),
|
||||
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',
|
||||
},
|
||||
]
|
||||
|
||||
+42
-1
@@ -11,12 +11,13 @@
|
||||
import type { api } from './api.ts'
|
||||
import {
|
||||
clampTerminalFontSize,
|
||||
clampTitleBarStrip,
|
||||
clampWidthPercent,
|
||||
SIDEBAR_PREFS_DEFAULTS,
|
||||
type SidebarPrefs,
|
||||
} from '../prefs-shared.ts'
|
||||
|
||||
export { SIDEBAR_PREFS_DEFAULTS, clampTerminalFontSize, clampWidthPercent }
|
||||
export { SIDEBAR_PREFS_DEFAULTS, clampTerminalFontSize, clampTitleBarStrip, clampWidthPercent }
|
||||
export type { SidebarPrefs }
|
||||
|
||||
/** The settings wire face the preferences need (a subset of the plugin api). */
|
||||
@@ -53,12 +54,27 @@ export function parsePrefs(value: unknown): SidebarPrefs {
|
||||
terminalFontFamily: typeof record.terminalFontFamily === 'string'
|
||||
? record.terminalFontFamily
|
||||
: SIDEBAR_PREFS_DEFAULTS.terminalFontFamily,
|
||||
terminalShell: typeof record.terminalShell === 'string'
|
||||
? record.terminalShell
|
||||
: SIDEBAR_PREFS_DEFAULTS.terminalShell,
|
||||
terminalShellArgs: typeof record.terminalShellArgs === 'string'
|
||||
? record.terminalShellArgs
|
||||
: SIDEBAR_PREFS_DEFAULTS.terminalShellArgs,
|
||||
terminalFontSize: typeof record.terminalFontSize === 'number' && Number.isFinite(record.terminalFontSize)
|
||||
? clampTerminalFontSize(record.terminalFontSize)
|
||||
: SIDEBAR_PREFS_DEFAULTS.terminalFontSize,
|
||||
interceptOpenPath: typeof record.interceptOpenPath === 'boolean'
|
||||
? record.interceptOpenPath
|
||||
: SIDEBAR_PREFS_DEFAULTS.interceptOpenPath,
|
||||
editorExplorer: typeof record.editorExplorer === 'boolean'
|
||||
? record.editorExplorer
|
||||
: SIDEBAR_PREFS_DEFAULTS.editorExplorer,
|
||||
titleBarCompat: typeof record.titleBarCompat === 'boolean'
|
||||
? record.titleBarCompat
|
||||
: SIDEBAR_PREFS_DEFAULTS.titleBarCompat,
|
||||
titleBarStripPx: typeof record.titleBarStripPx === 'number' && Number.isFinite(record.titleBarStripPx)
|
||||
? clampTitleBarStrip(record.titleBarStripPx)
|
||||
: SIDEBAR_PREFS_DEFAULTS.titleBarStripPx,
|
||||
htmlViewerNoSandbox: typeof record.htmlViewerNoSandbox === 'boolean'
|
||||
? record.htmlViewerNoSandbox
|
||||
: SIDEBAR_PREFS_DEFAULTS.htmlViewerNoSandbox,
|
||||
@@ -71,6 +87,12 @@ export function parsePrefs(value: unknown): SidebarPrefs {
|
||||
browserInterceptLinks: typeof record.browserInterceptLinks === 'boolean'
|
||||
? record.browserInterceptLinks
|
||||
: SIDEBAR_PREFS_DEFAULTS.browserInterceptLinks,
|
||||
browserInterceptHttp: typeof record.browserInterceptHttp === 'boolean'
|
||||
? record.browserInterceptHttp
|
||||
: SIDEBAR_PREFS_DEFAULTS.browserInterceptHttp,
|
||||
browserInterceptHttps: typeof record.browserInterceptHttps === 'boolean'
|
||||
? record.browserInterceptHttps
|
||||
: SIDEBAR_PREFS_DEFAULTS.browserInterceptHttps,
|
||||
tabsEnabled: booleanMapOf(record.tabsEnabled),
|
||||
viewersEnabled: booleanMapOf(record.viewersEnabled),
|
||||
pluginSettings: pluginSettingsMapOf(record.pluginSettings),
|
||||
@@ -123,3 +145,22 @@ export async function loadPrefs(settings: SidebarSettingsClient): Promise<Sideba
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* Kept dependency-free so the takeover logic is unit-testable and the
|
||||
* 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. */
|
||||
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
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
const absolute = path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path)
|
||||
if (absolute) return path
|
||||
if (isAbsolutePath(path)) return path
|
||||
const base = cwd ?? ''
|
||||
if (base === '') return path
|
||||
const separator = base.includes('\\') ? '\\' : '/'
|
||||
|
||||
+100
-3
@@ -48,14 +48,32 @@ export type { SessionScope } from './api.ts'
|
||||
export type { SidebarPrefs } from '../prefs-shared.ts'
|
||||
|
||||
/** 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
|
||||
* 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`
|
||||
* selects the control: 'switch' (default) renders the custom switch,
|
||||
* '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 {
|
||||
/** The SidebarPrefs field this toggle reads and writes ('autoOpenSubagent'). */
|
||||
key: string
|
||||
@@ -73,6 +91,11 @@ export interface SidebarSettingToggle {
|
||||
placeholder?: string
|
||||
/** Unit suffix rendered after the input (e.g. 'px' for a size row). */
|
||||
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`). */
|
||||
@@ -169,6 +192,22 @@ export interface TabDescriptor {
|
||||
* When omitted, a default `{ id, type, title }` tab is created.
|
||||
*/
|
||||
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
|
||||
/**
|
||||
* External-link target claim (v0.13.0+): when a GUI external-link click
|
||||
* is taken over (the `browserInterceptLinks` master AND the URL's
|
||||
* protocol flag — `browserInterceptHttp` / `browserInterceptHttps` —
|
||||
* are on), the first registered tab whose `urlTarget(url)` returns true
|
||||
* is opened with `openTab({ type, url, title: hostname })` — the URL is
|
||||
* the whole payload (the tab reads it from `tab.path`). Registration
|
||||
* order wins (first claim first served); a disabled tab type is skipped;
|
||||
* a throwing predicate is swallowed (console.error, the type is skipped).
|
||||
* The built-in browser tab declares NO urlTarget — it stays the implicit
|
||||
* fallback target, so plugins can never be shadowed by it. To host more
|
||||
* than one URL at a time, mint per-URL ids through `createTab` (the
|
||||
* browser builtin's pattern); otherwise the id safety net focuses the
|
||||
* existing tab of the same type and the new URL is not applied.
|
||||
*/
|
||||
urlTarget?: (url: URL) => boolean
|
||||
/**
|
||||
* Declarative settings shown in the Side card settings page: every
|
||||
* registered tab gets an enable/disable switch (icon + title + id), and
|
||||
@@ -224,6 +263,33 @@ export interface FileViewerProps {
|
||||
mediaUrl?: string
|
||||
/** custom load() return value (fetchStrategy='custom'). */
|
||||
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). */
|
||||
@@ -373,11 +439,38 @@ function baseNameOf(path: string): string {
|
||||
return at === -1 ? path : path.slice(at + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the tab type that claims an intercepted external-link URL (v0.13.0+).
|
||||
* Walks the descriptors in REGISTRATION order and returns the first one
|
||||
* that declares `urlTarget` and matches `url`; a throwing predicate is
|
||||
* swallowed (console.error, type skipped) so one broken plugin can never
|
||||
* break the whole link pipeline. The caller passes the ENABLED tab
|
||||
* descriptors (enablement is the caller's prefs domain — filter
|
||||
* `service.getTabs()` through `tabsEnabled` before matching) and falls
|
||||
* back to the built-in browser tab when nothing claims the URL (the
|
||||
* browser never declares `urlTarget` itself, so it can never shadow a
|
||||
* plugin claim).
|
||||
*/
|
||||
export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDescriptor | undefined {
|
||||
for (const tab of tabs) {
|
||||
if (tab.urlTarget === undefined) continue
|
||||
let claimed = false
|
||||
try {
|
||||
claimed = tab.urlTarget(url) === true
|
||||
} catch (error) {
|
||||
console.error('[dsh-better-sidebar] urlTarget error:', error)
|
||||
continue
|
||||
}
|
||||
if (claimed) return tab
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin version this service instance reports. Keep in lockstep with
|
||||
* `package.json`'s version — `tests/service.spec.ts` asserts the pair.
|
||||
*/
|
||||
export const SIDEBAR_SERVICE_VERSION = '0.12.1'
|
||||
export const SIDEBAR_SERVICE_VERSION = '0.14.0'
|
||||
|
||||
/**
|
||||
* Monotonic capability list consumers use to gate new API usage (features
|
||||
@@ -390,6 +483,8 @@ export const SIDEBAR_SERVICE_VERSION = '0.12.1'
|
||||
* - 'stateSubscription': getSnapshot/subscribeState
|
||||
* - 'tabMeta': SidebarTab.meta (seeds, createTab, updateTab, persistence)
|
||||
* - 'pluginSettings': SidebarSettingsDeclaration.pluginToggles/render
|
||||
* - 'urlTarget' (v0.13.0): TabDescriptor.urlTarget (external-link claims)
|
||||
* - 'settingSelect': SidebarSettingToggle type 'select' (options/multi)
|
||||
*/
|
||||
export const SIDEBAR_FEATURES = [
|
||||
'badge',
|
||||
@@ -400,6 +495,8 @@ export const SIDEBAR_FEATURES = [
|
||||
'stateSubscription',
|
||||
'tabMeta',
|
||||
'pluginSettings',
|
||||
'urlTarget',
|
||||
'settingSelect',
|
||||
] as const
|
||||
|
||||
/** Run one plugin callback; a throw is logged and never breaks the caller. */
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
+502
-17
@@ -10,19 +10,57 @@
|
||||
|
||||
/* ── 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 unified panel host: a fixed, viewport-sized, click-transparent
|
||||
containing block for every panel element. Panels are ABSOLUTE within it
|
||||
(not fixed), so a desktop shell's intermediate wrapper transform can
|
||||
never hijack their containing block — the host itself is the only fixed
|
||||
element, appended directly to document.body. The layer sits at z-index 40
|
||||
(below the app's overlay stack at 100+); each panel re-enables pointer
|
||||
events on itself. Degraded mode ([data-dsh-panel-host-degraded]) covers
|
||||
the rare shell that transforms <html>/<body> itself: the host becomes
|
||||
absolute and the mount self-check pins it to the viewport every frame. */
|
||||
:global([data-dsh-panel-host]) {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:global([data-dsh-panel-host][data-dsh-panel-host-degraded]) {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* The persistent expand/collapse cluster at the top-right corner: two rail
|
||||
controls side by side (bottom panel's glyph LEFT of the right panel's).
|
||||
Always pinned to the viewport corner — inside the right panel's top-right
|
||||
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). Absolute within the panel host (the host's inset: 0 makes
|
||||
viewport coordinates identical). */
|
||||
.toggleCluster {
|
||||
position: fixed;
|
||||
top: 3px;
|
||||
position: absolute;
|
||||
/* env() resolves to 0 on devices without a top inset (desktops). */
|
||||
top: calc(3px + env(safe-area-inset-top));
|
||||
right: 10px;
|
||||
z-index: 55;
|
||||
z-index: 45;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 4px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* The tab strip of the OPEN right panel reserves the cluster's width at its
|
||||
@@ -64,19 +102,30 @@
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
/* Full height: the bottom panel squeezes only the center column, so the
|
||||
right panel never gives up any vertical position. */
|
||||
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;
|
||||
flex-direction: column;
|
||||
/* The native sidebar surface: bluish-50 over white / bluish-900 over dark,
|
||||
exactly like the app's own sidebar column (SidebarRoot .root). */
|
||||
background: var(--dsw-specific-sidebar-fill);
|
||||
/* Re-enable interaction inside the click-transparent panel host. */
|
||||
pointer-events: auto;
|
||||
/* The generic card surface (see the root contract above). Never
|
||||
`--dsw-specific-sidebar-fill` — skin plugins restyle that token for the
|
||||
app's own left nav and a panel consuming it loses its fill or clashes
|
||||
with labels (#60/#105/#106). */
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
border-left: 1px solid var(--dsw-alias-border-l2);
|
||||
/* Bottom safe-area (notched phones): the drawer's bottom content clears
|
||||
the home indicator; 0 on desktops. */
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
/* Expand/collapse slides the panel; fullscreen animates the width. */
|
||||
transition:
|
||||
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
@@ -101,6 +150,8 @@
|
||||
|
||||
.panelResize {
|
||||
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;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
@@ -127,15 +178,21 @@
|
||||
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
|
||||
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 {
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
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);
|
||||
/* Re-enable interaction inside the click-transparent panel host. */
|
||||
pointer-events: auto;
|
||||
/* Bottom safe-area (notched phones); 0 on desktops. */
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
/* Expand/collapse slides the panel; height drags animate the height. */
|
||||
transition:
|
||||
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
@@ -158,8 +215,24 @@
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* Layout isolation: panel-internal reflows (tabs, editors, terminals) never
|
||||
leak into the app shell's layout, and the app shell's layout changes
|
||||
never cascade through the panel subtree — the drag stays confined.
|
||||
will-change: transform is only live while a drag is (the body attribute
|
||||
toggles with the drag state), promoting the panels to their own layer
|
||||
without paying the compositing cost at rest. */
|
||||
.panel,
|
||||
.bottomPanel {
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
body[data-dsh-sidebar-dragging] .panel,
|
||||
body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* The bottom panel's height drag: the top edge strip (mirror of the right
|
||||
panel's width strip). */
|
||||
panel's width strip), centered on the border like the width strip. */
|
||||
.bottomResize {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
@@ -209,15 +282,44 @@
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
/* ── Position compatibility mode (Windows native title bar) ────────────── */
|
||||
|
||||
/* Windows frameless / hidden-title-bar windows draw the native caption
|
||||
strip (minimize / maximize / close) at the window's top-right corner,
|
||||
OVER the web content. When the user enables the "position compatibility"
|
||||
pref, the sidebar yields the strip: the toggle cluster drops below it and
|
||||
the right panel's content starts below it — the panel's surface still
|
||||
spans the full height, so the caption buttons float over empty sidebar
|
||||
fill (the VSCode hidden-title-bar look). The strip height is user-tunable
|
||||
(`--dsh-title-bar-strip`, written by the Sidebar shell from
|
||||
`titleBarStripPx`; 40px fallback). The bottom panel is unaffected: it
|
||||
sits at the window's bottom, never under the top strip. */
|
||||
:global(body[data-dsh-title-bar-compat]) .toggleCluster {
|
||||
top: calc(var(--dsh-title-bar-strip, 40px) + 3px);
|
||||
}
|
||||
|
||||
:global(body[data-dsh-title-bar-compat]) .panel {
|
||||
/* The caption strip is drawn over the panel's own top edge: keep the
|
||||
surface full-height and push the content (the tab strip) below the
|
||||
strip instead. .panelResize is absolutely positioned and unaffected. */
|
||||
padding-top: var(--dsh-title-bar-strip, 40px);
|
||||
}
|
||||
|
||||
/* The shared corner (both panels open): the intersection of the right
|
||||
panel's left edge and the bottom panel's top edge. Horizontal drags
|
||||
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 {
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
left: -6px;
|
||||
bottom: calc(var(--dsh-sidebar-height, 0px) + 6px);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
z-index: 52;
|
||||
z-index: 2;
|
||||
cursor: nwse-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
@@ -614,6 +716,8 @@
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
/* Deep trees truncate their labels instead of scrolling horizontally. */
|
||||
overflow-x: hidden;
|
||||
padding: 2px 6px 8px;
|
||||
}
|
||||
|
||||
@@ -625,7 +729,12 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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%;
|
||||
max-width: 100%;
|
||||
height: 34px;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
@@ -651,9 +760,25 @@
|
||||
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 {
|
||||
/* 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;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The hover-revealed @-reference button at the row's far right (and the
|
||||
@@ -735,6 +860,139 @@
|
||||
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 {
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
@@ -1082,6 +1340,174 @@
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
/* ── Mermaid diagram block (markdown preview, mermaid lazy chunk) ────────
|
||||
Mirrors the md-code-block chrome the DSH CodeBlock renders, so a diagram
|
||||
reads as a peer of the surrounding fences: hairline border, layer-2
|
||||
header with the info string + copy action, layer-1 body. The SVG body is
|
||||
static (no bindFunctions) and sanitized (no foreignObject/script/@attrs/
|
||||
hrefs), so it carries no event surface beyond the copy button; the body
|
||||
click opens the zoom modal on a sanitized clone. */
|
||||
|
||||
.mermaidWrap {
|
||||
margin: 6px 0;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mermaidHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l1);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
}
|
||||
|
||||
.mermaidInfo {
|
||||
font: var(--dsw-font-xxxs-strong-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.mermaidCopy {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 6px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mermaidCopy:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.mermaidBody {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.mermaidBody :global(svg) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.mermaidError {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l1);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
}
|
||||
|
||||
/* The diagram source shown under a render error (helps fixing the fence). */
|
||||
.mermaidCode {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
overflow: auto;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
/* The mermaid markdown container (single MarkdownText pass): a swapped
|
||||
code block keeps its React-managed .md-code-block wrapper in the tree
|
||||
(reconciliation safety), but the wrapper's own box is suppressed so only
|
||||
the diagram chrome renders; the swapped-in host div flows as a block. */
|
||||
.mermaidMarkdown :global(.md-code-block[data-mermaid-processed]) {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* ── Mermaid zoom modal (click-to-enlarge; portalled to document.body) ──
|
||||
Fixed overlay above the DSH overlay stack (1000+), token-driven surface.
|
||||
The stage centers the (sanitized, cloned) svg and the modal owns the
|
||||
transform; the drag/zoom listeners live in the component, nothing here
|
||||
targets global class names. */
|
||||
|
||||
.mermaidModal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--dsw-alias-bg-mask-1);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.mermaidModalToolbar {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.mermaidModalButton {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: var(--dsw-font-xs-strong-13);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mermaidModalButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.mermaidModalStage {
|
||||
width: 90vw;
|
||||
height: 80vh;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mermaidModalStage :global(svg) {
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
cursor: grab;
|
||||
transform-origin: center center;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.mermaidModalStage :global(svg):active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.mermaidModalHint {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* The floating "add to conversation" button anchored above a text
|
||||
selection in the preview/code viewers. Portalled to document.body and
|
||||
position:fixed, so the editor's overflow clips cannot crop it; flat
|
||||
@@ -1241,6 +1667,65 @@
|
||||
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
|
||||
.boundaryError, the full-height fixed rail for shell-level crashes), so a
|
||||
crashing viewer/editor never hides the toggle cluster or the other tabs. */
|
||||
|
||||
+69
-18
@@ -13,8 +13,8 @@ import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts'
|
||||
import { isNarrowWidth } from './breakpoints.ts'
|
||||
|
||||
/**
|
||||
* Tab type identifier. Builtins register their ids (explorer / git / editor
|
||||
* / terminal / subagent / diff) through the sidebar service; external
|
||||
* Tab type identifier. Builtins register their ids (editor / git / terminal
|
||||
* / subagent / browser / diff) through the sidebar service; external
|
||||
* plugins register their own (e.g. `'my-plugin:db'`). Kept as `string` so
|
||||
* the registry stays open.
|
||||
*/
|
||||
@@ -25,7 +25,7 @@ export type SidebarDiffRef =
|
||||
| { kind: 'worktree'; path: string; staged: boolean; untracked?: boolean }
|
||||
| { 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
|
||||
* plugin-owned JSON-serializable state, preserved across reloads. */
|
||||
export interface SidebarTab {
|
||||
@@ -105,6 +105,13 @@ function uid(prefix: string): string {
|
||||
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
|
||||
* (`pane:N` / `tab:N` / `split:N`). The uid counter is module-global and
|
||||
@@ -139,17 +146,24 @@ function maxCounterId(parsed: unknown): number {
|
||||
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
|
||||
* PANEL_DEFAULT) and `panelOpen` whether the panel starts expanded (default
|
||||
* true); the store seeds new sessions from the user's side card prefs.
|
||||
* `seedExplorer` places the default explorer tab — the store passes false
|
||||
* when the user disabled the explorer tab type in settings, so a fresh
|
||||
* session starts with an empty pane instead of a tab they turned off. */
|
||||
export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seedExplorer = true): SidebarState {
|
||||
* `seed` picks the seeded tab: 'editor-home' places the EMPTY files window
|
||||
* (an editor tab with no path whose tree panel starts open,
|
||||
* `meta.treeOpen: true`) — in BOTH editorExplorer modes that window is the
|
||||
* 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 }
|
||||
if (seedExplorer) {
|
||||
leaf.tabs = [{ id: uid('tab'), type: 'explorer', title: 'Explorer' }]
|
||||
if (seed === 'editor-home') {
|
||||
// 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
|
||||
}
|
||||
// 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
|
||||
// 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
|
||||
// starts open only when the preference says so, and the default explorer
|
||||
// tab is skipped when the user disabled the explorer tab type. On a
|
||||
// NARROW viewport a brand-new session starts collapsed instead — the
|
||||
// panel is a full-screen drawer there, and auto-opening it on first
|
||||
// paint would cover the conversation before the user asked. Only the
|
||||
// first seeding is affected: once the user expands the drawer,
|
||||
// `panelOpen: true` persists like any other state.
|
||||
// starts open only when the preference says so, and the seed tab is the
|
||||
// empty files window (tree panel open) in BOTH editorExplorer modes — a
|
||||
// disabled editor type seeds nothing. On a NARROW viewport a brand-new
|
||||
// session starts collapsed instead — the panel is a full-screen drawer
|
||||
// there, and auto-opening it on first paint would cover the conversation
|
||||
// before the user asked. Only the first seeding is affected: once the
|
||||
// user expands the drawer, `panelOpen: true` persists like any other
|
||||
// state.
|
||||
const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
|
||||
const width = viewport === undefined
|
||||
? PANEL_DEFAULT
|
||||
: defaultWidthFor(viewport, prefs.defaultWidthPercent)
|
||||
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
|
||||
// <OrphanedTab/> at view time and recovers if its plugin loads later.
|
||||
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
|
||||
// persisted value already went through JSON.parse, so it is inherently
|
||||
// serializable — carry it through verbatim (absent on older states).
|
||||
@@ -943,6 +974,26 @@ export class SidebarStore {
|
||||
private readonly persistTimers = new Map<string, number>()
|
||||
/** User-facing side card prefs seeding brand-new session states (defaults until the settings RPC resolves). */
|
||||
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
|
||||
|
||||
@@ -30,6 +30,72 @@ export function tokenValue(name: string): string {
|
||||
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
|
||||
* attribute). The callback fires after the attribute changed; re-read the
|
||||
|
||||
+38
-1
@@ -12,6 +12,9 @@ import {
|
||||
TERMINAL_FONT_SIZE_DEFAULT,
|
||||
TERMINAL_FONT_SIZE_MAX,
|
||||
TERMINAL_FONT_SIZE_MIN,
|
||||
TITLE_BAR_STRIP_DEFAULT,
|
||||
TITLE_BAR_STRIP_MAX,
|
||||
TITLE_BAR_STRIP_MIN,
|
||||
WIDTH_PERCENT_DEFAULT,
|
||||
WIDTH_PERCENT_MAX,
|
||||
WIDTH_PERCENT_MIN,
|
||||
@@ -24,6 +27,9 @@ export {
|
||||
TERMINAL_FONT_SIZE_DEFAULT,
|
||||
TERMINAL_FONT_SIZE_MAX,
|
||||
TERMINAL_FONT_SIZE_MIN,
|
||||
TITLE_BAR_STRIP_DEFAULT,
|
||||
TITLE_BAR_STRIP_MAX,
|
||||
TITLE_BAR_STRIP_MIN,
|
||||
WIDTH_PERCENT_DEFAULT,
|
||||
WIDTH_PERCENT_MAX,
|
||||
WIDTH_PERCENT_MIN,
|
||||
@@ -42,6 +48,22 @@ export interface SidebarConfig {
|
||||
terminalsPerSession?: number
|
||||
/** How long a disconnected terminal process survives awaiting a reconnect. */
|
||||
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). Set it from `cordis.patch.yml` / profile
|
||||
* plugin config, e.g. `config: { shell: /bin/zsh }`.
|
||||
*/
|
||||
shell?: string
|
||||
/**
|
||||
* Optional arguments passed to the shell executable. When non-empty these
|
||||
* REPLACE the automatic platform defaults (POSIX `-l` / Windows none), so
|
||||
* the deployment has full control over how the shell starts. When omitted
|
||||
* the existing default behavior is kept.
|
||||
*/
|
||||
shellArgs?: string[]
|
||||
}
|
||||
|
||||
/** Schemastery schema for the plugin configuration. */
|
||||
@@ -51,6 +73,8 @@ export const Config: z<SidebarConfig> = z.object({
|
||||
listLimit: z.number().step(1).min(1).default(1000),
|
||||
terminalsPerSession: z.number().step(1).min(1).default(3),
|
||||
reconnectGraceMs: z.number().step(1).min(0).default(30_000),
|
||||
shell: z.string().default(''),
|
||||
shellArgs: z.array(z.string()).default([]),
|
||||
})
|
||||
|
||||
/** Fully defaulted sidebar host settings. */
|
||||
@@ -60,6 +84,10 @@ export interface ResolvedSidebarConfig {
|
||||
listLimit: number
|
||||
terminalsPerSession: number
|
||||
reconnectGraceMs: number
|
||||
/** The configured terminal shell; empty means the host auto-resolves it. */
|
||||
shell: string
|
||||
/** Explicit shell arguments; empty means use the platform defaults. */
|
||||
shellArgs: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,6 +103,8 @@ export function resolveSidebarConfig(config: SidebarConfig | undefined): Resolve
|
||||
listLimit: config?.listLimit ?? 1000,
|
||||
terminalsPerSession: config?.terminalsPerSession ?? 3,
|
||||
reconnectGraceMs: config?.reconnectGraceMs ?? 30_000,
|
||||
shell: config?.shell?.trim() ?? '',
|
||||
shellArgs: config?.shellArgs ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +112,7 @@ export function resolveSidebarConfig(config: SidebarConfig | undefined): Resolve
|
||||
|
||||
/** Schemastery schema for the user-facing preferences (validated by the settings service). */
|
||||
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),
|
||||
autoOpenSubagent: z.boolean().default(true),
|
||||
autoOpenJobs: z.boolean().default(true),
|
||||
@@ -91,10 +121,17 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
|
||||
terminalFontFamily: z.string().default(''),
|
||||
terminalFontSize: z.number().step(1).min(TERMINAL_FONT_SIZE_MIN).max(TERMINAL_FONT_SIZE_MAX).default(TERMINAL_FONT_SIZE_DEFAULT),
|
||||
interceptOpenPath: z.boolean().default(true),
|
||||
editorExplorer: z.boolean().default(false),
|
||||
terminalShell: z.string().default(''),
|
||||
terminalShellArgs: z.string().default(''),
|
||||
titleBarCompat: z.boolean().default(false),
|
||||
titleBarStripPx: z.number().step(1).min(TITLE_BAR_STRIP_MIN).max(TITLE_BAR_STRIP_MAX).default(TITLE_BAR_STRIP_DEFAULT),
|
||||
htmlViewerNoSandbox: z.boolean().default(false),
|
||||
htmlViewerDefaultUnsafe: z.boolean().default(false),
|
||||
browserNoSandbox: z.boolean().default(false),
|
||||
browserInterceptLinks: z.boolean().default(true),
|
||||
browserInterceptHttp: z.boolean().default(true),
|
||||
browserInterceptHttps: z.boolean().default(false),
|
||||
// Per-feature enable switches are OPEN maps (any tab/viewer id, built-in or
|
||||
// external): an absent key means enabled, so old documents resolve to {}
|
||||
// (everything on) with no migration. Non-boolean values fail validation.
|
||||
|
||||
@@ -428,6 +428,13 @@ declare module 'cordis' {
|
||||
* dictionaries under the `betterSidebar` namespace. Client side only.
|
||||
*/
|
||||
locale: SidebarLocaleService
|
||||
/**
|
||||
* The client module system (rc.8+): resolves module-table specifiers
|
||||
* (seed words, graph rows) — the chunk loader's externals go through it
|
||||
* (`ctx.modules.import`). rc.7 exposed the same surface as the
|
||||
* `window.__DSH_MODULES__` page global; the loader keeps that fallback.
|
||||
*/
|
||||
modules: { import(specifier: string): Promise<unknown> }
|
||||
/**
|
||||
* The host background-job registry (`ctx.get('jobs')`; optional — the
|
||||
* sidebar routes degrade to a 503 when the deployment lacks it).
|
||||
|
||||
@@ -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
@@ -2,11 +2,13 @@
|
||||
* Single-level directory listing for the sidebar explorer. Streams the level
|
||||
* with opendir, sorts directories first then names (case-insensitive), and
|
||||
* marks POSIX-hidden entries (dot-prefixed) for dimmed display. Symlinks are
|
||||
* reported as files without probing their target — the explorer shows what
|
||||
* dirent says, keeping the read cheap for arbitrarily large levels.
|
||||
* stat'ed once to expose their target kind — a symlink to a directory
|
||||
* 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 { basename, dirname, join, resolve } from 'node:path'
|
||||
import { opendir, stat } from 'node:fs/promises'
|
||||
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
|
||||
import { SidebarError } from './wire.ts'
|
||||
|
||||
/** One explorer row. */
|
||||
@@ -15,6 +17,10 @@ export interface SidebarFsEntry {
|
||||
path: string
|
||||
isDir: 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. */
|
||||
@@ -52,22 +58,53 @@ export async function listDirectory(path: string, maxEntries = 1000): Promise<Si
|
||||
overflow += 1
|
||||
continue
|
||||
}
|
||||
// Platform join: on Windows the level path uses '\' — a hardcoded '/'
|
||||
// would leak mixed separators into every row's path.
|
||||
rows.push({
|
||||
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),
|
||||
isDir: dirent.isDirectory(),
|
||||
isSymlink: dirent.isSymbolicLink(),
|
||||
broken: false,
|
||||
hidden: dirent.name.startsWith('.'),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
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)
|
||||
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). */
|
||||
export function rootLabel(path: string): string {
|
||||
const base = basename(path)
|
||||
@@ -80,9 +117,15 @@ export function parentOf(path: string): string | undefined {
|
||||
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 {
|
||||
if (!path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(path)) {
|
||||
if (!isAbsolute(path)) {
|
||||
throw new SidebarError('fs-error', `"${path}" is not an absolute path`, 400)
|
||||
}
|
||||
return resolve(path)
|
||||
|
||||
+43
-16
@@ -13,6 +13,17 @@
|
||||
* /sidebar/html/S/Users/me/proj/index.html
|
||||
* + ./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
|
||||
* 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
|
||||
* 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). */
|
||||
export function encodeHtmlUrl(sessionId: string, path: string): string {
|
||||
const unc = /^[\\/]{2}[^\\/]/.test(path)
|
||||
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
|
||||
* a wrong prefix (404), an empty or double-slash path, malformed percent
|
||||
* encoding, and a missing sessionId or file path (400). The caller still
|
||||
* must bound the decoded path with requireAbsolute + isWithin(cwd) — a
|
||||
* decoded `..` segment resolves outside the cwd and is refused there.
|
||||
* a wrong prefix (404), an empty path, malformed percent encoding, and a
|
||||
* missing sessionId or file path (400). The caller still must bound the
|
||||
* decoded path with requireAbsolute + isWithin(cwd) — a decoded `..`
|
||||
* segment resolves outside the cwd and is refused there.
|
||||
*/
|
||||
export function decodeHtmlUrl(pathname: string): HtmlDecodeResult {
|
||||
if (!pathname.startsWith(HTML_ROUTE_PREFIX)) {
|
||||
return { ok: false, status: 404, message: 'not an html route' }
|
||||
}
|
||||
const rest = pathname.slice(HTML_ROUTE_PREFIX.length)
|
||||
if (rest === '' || rest.includes('//')) {
|
||||
if (rest === '') {
|
||||
return { ok: false, status: 400, message: 'invalid html route path' }
|
||||
}
|
||||
let segments: string[]
|
||||
@@ -63,17 +75,32 @@ export function decodeHtmlUrl(pathname: string): HtmlDecodeResult {
|
||||
return { ok: false, status: 400, message: 'malformed URL encoding' }
|
||||
}
|
||||
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' }
|
||||
}
|
||||
// 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.
|
||||
const first = pathSegments[0] ?? ''
|
||||
const path = /^[A-Za-z]:$/.test(first)
|
||||
? pathSegments.join('/')
|
||||
: `/${pathSegments.join('/')}`
|
||||
// An empty FIRST path segment is the UNC marker (encodeHtmlUrl emits
|
||||
// '<sid>//server/share/...' for UNC paths); the encoder filters empty
|
||||
// segments everywhere else, so an empty segment can only be the marker or
|
||||
// a malformed URL — both handled here.
|
||||
const unc = pathSegments[0] === ''
|
||||
const tail = unc ? pathSegments.slice(1) : pathSegments
|
||||
if (tail.length === 0 || tail.some(segment => segment === '')) {
|
||||
return { ok: false, status: 400, message: 'sessionId and file path are required' }
|
||||
}
|
||||
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 } }
|
||||
}
|
||||
|
||||
+136
-23
@@ -29,14 +29,21 @@ import {
|
||||
type SidebarPrefs,
|
||||
} from './config.ts'
|
||||
import { isWithin, parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
|
||||
import { searchFiles } from './fs-search.ts'
|
||||
import { decodeHtmlUrl } from './html-route.ts'
|
||||
import { extractFrameAncestors } from './browser-probe.ts'
|
||||
import { isTrustedApiRequest, isLoopbackHostname } from './trust-fence.ts'
|
||||
import { registerBundleRoute } from './bundle-route.ts'
|
||||
import * as git from './git.ts'
|
||||
import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts'
|
||||
import { defaultShell, ensureSpawnHelper, PtyManager, shellDisplayName } from './pty-manager.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 { buildJobsApi, type SidebarJobsRoutes } from './jobs-routes.ts'
|
||||
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
|
||||
@@ -172,16 +179,45 @@ type ApiMethod = (payload: unknown) => Promise<unknown> | unknown
|
||||
export interface SidebarSettingsFace {
|
||||
/** The current resolved value + revision (undefined while the settings service is absent). */
|
||||
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. */
|
||||
update(patch: Record<string, unknown>, expectedRevision?: number): Promise<{ value?: unknown; revision?: number }>
|
||||
}
|
||||
|
||||
/** 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, resolved config, and effective terminal shell. */
|
||||
/**
|
||||
* Resolve the settings-page terminal shell overrides (the terminal card's
|
||||
* gear rows). Empty fields mean "unset": keep the yaml `config.shell` /
|
||||
* `shellArgs` (or the platform auto-resolution). The settings page is the
|
||||
* runtime complement to the boot-time yaml — same contract, later binding:
|
||||
* the values here win for terminals opened afterwards.
|
||||
*/
|
||||
function shellOverridesOf(getSettings: () => SidebarSettingsFace | undefined): { shell?: string; shellArgs?: string[] } {
|
||||
const settings = getSettings()
|
||||
const value = settings?.get().value
|
||||
if (value === null || typeof value !== 'object') return {}
|
||||
const record = value as Record<string, unknown>
|
||||
const shell = typeof record.terminalShell === 'string' ? record.terminalShell.trim() : ''
|
||||
const args = typeof record.terminalShellArgs === 'string' ? record.terminalShellArgs.trim() : ''
|
||||
return {
|
||||
shell: shell === '' ? undefined : shell,
|
||||
shellArgs: args === '' ? undefined : args.split(/\s+/).filter(Boolean),
|
||||
}
|
||||
}
|
||||
|
||||
function buildApi(
|
||||
ctx: Context,
|
||||
ptyManager: PtyManager,
|
||||
agentPtyRegistry: AgentPtyRegistry,
|
||||
ptyManager: PtyManager | null,
|
||||
agentPtyRegistry: AgentPtyRegistry | null,
|
||||
resolved: ResolvedSidebarConfig,
|
||||
terminalShell: string,
|
||||
getSettings: () => SidebarSettingsFace | undefined,
|
||||
): Record<string, ApiMethod> {
|
||||
const cwdOf = (payload: unknown): { sessionId: string; cwd: string } => {
|
||||
@@ -207,6 +243,14 @@ function buildApi(
|
||||
const target = record.path === undefined ? cwd : requireAbsolute(requireString(payload, 'path'))
|
||||
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) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
// Relative paths are git-derived (status/diff report repo-root-relative
|
||||
@@ -313,7 +357,9 @@ function buildApi(
|
||||
'pty.close': (payload) => {
|
||||
const sessionId = requireString(payload, 'sessionId')
|
||||
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 }
|
||||
},
|
||||
// Release an agent terminal by uuid. The WS close frame already does
|
||||
@@ -322,9 +368,14 @@ function buildApi(
|
||||
// tab never leaves a zombie pty behind. Idempotent.
|
||||
'agent-pty.close': (payload) => {
|
||||
const uuid = requireString(payload, 'uuid')
|
||||
agentPtyRegistry.close(uuid)
|
||||
agentPtyRegistry?.close(uuid)
|
||||
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
|
||||
// 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
|
||||
@@ -333,6 +384,11 @@ function buildApi(
|
||||
// exists. Kill is fenced to the owning session by the jobs registry.
|
||||
'jobs.output': (payload) => jobsApi.output(payload),
|
||||
'jobs.kill': (payload) => jobsApi.kill(payload),
|
||||
// The effective terminal shell and its display name. The client uses
|
||||
// this to title terminal tabs with the shell name instead of a numbered
|
||||
// "Terminal N" label; the shell itself is configured through
|
||||
// `cordis.patch.yml` (`config.shell`) or resolved by the host default.
|
||||
'shell.get': () => ({ shell: terminalShell, name: shellDisplayName(terminalShell) }),
|
||||
// The side card preferences. The settings service is optional in the
|
||||
// composition; while absent the routes report undefined and the client
|
||||
// keeps the schema defaults. Writes are revision-guarded: a stale editor
|
||||
@@ -340,7 +396,9 @@ function buildApi(
|
||||
// silently overwritten (mirror of the settings seam's own guard).
|
||||
'settings.get': () => {
|
||||
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) => {
|
||||
const settings = getSettings()
|
||||
@@ -426,18 +484,39 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
// restore it before any terminal can spawn (idempotent).
|
||||
ensureSpawnHelper()
|
||||
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
|
||||
// plus --trusted-host authorities) — the authoritative source the /api
|
||||
// gateway fence derives its list from. Read per request from the live
|
||||
// service value; a replaced list takes effect without a plugin restart.
|
||||
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, resolved.shellArgs, nodePty)
|
||||
: null
|
||||
// The agent-owned terminal registry: parallel to the UI-tab ptyManager,
|
||||
// keyed by uuid (the model's opaque handle) instead of `${sessionId}:${tabId}`,
|
||||
// uncapped, and torn down with the plugin. The model creates terminals here
|
||||
// through the terminal_create tool; the sidebar view attaches through the
|
||||
// same /sidebar/ws/terminal upgrade with ?uuid=... instead of ?tab=...
|
||||
const agentPtyRegistry = new AgentPtyRegistry(defaultShell())
|
||||
const agentPtyRegistry = nodePty !== null
|
||||
? new AgentPtyRegistry(terminalShell, resolved.shellArgs, nodePty)
|
||||
: null
|
||||
|
||||
// ── User-facing "Side card" preferences ──────────────────────────────────
|
||||
// Register the namespace with the settings provider so the Settings page
|
||||
@@ -456,7 +535,10 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
const syncToolsGate = (scope: { get(): SidebarPrefs }): void => {
|
||||
if (scope.get().agentTerminalTools) {
|
||||
if (toolsDisposers === null) {
|
||||
toolsDisposers = registerTools(ctx, agentPtyRegistry, (sessionId) => sessionCwdOf(ctx, sessionId))
|
||||
// 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), () => shellOverridesOf(() => settingsFace))
|
||||
}
|
||||
} else if (toolsDisposers !== null) {
|
||||
toolsDisposers()
|
||||
@@ -464,7 +546,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
// The feature is off: release every agent terminal the model created
|
||||
// while it was on (they are only reachable through the tools). The
|
||||
// registry change fires the push, so the sidebar reconciles them away.
|
||||
agentPtyRegistry.disposeAll()
|
||||
agentPtyRegistry?.disposeAll()
|
||||
}
|
||||
}
|
||||
ctx.inject(['settings'], (sctx) => {
|
||||
@@ -482,8 +564,20 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
? { value: undefined, revision: undefined }
|
||||
: { 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 = {
|
||||
get: viewOf,
|
||||
externalDisable,
|
||||
update: async (patch, expectedRevision) => {
|
||||
await sctx.settings.update(ns, patch, expectedRevision)
|
||||
return viewOf()
|
||||
@@ -496,7 +590,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
})
|
||||
|
||||
// ── JSON API ────────────────────────────────────────────────────────────
|
||||
const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, () => settingsFace)
|
||||
const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, terminalShell, () => settingsFace)
|
||||
ctx.effect(() => ctx.webServer.register({
|
||||
kind: 'prefix',
|
||||
path: '/sidebar/api',
|
||||
@@ -666,7 +760,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
// The structural request/socket/head faces satisfy the shared fence;
|
||||
// the `ws` package wants the real Node types — cast at this boundary.
|
||||
wss.handleUpgrade(req as unknown as IncomingMessage, socket as unknown as Duplex, head as Buffer, (ws) => {
|
||||
void attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolved)
|
||||
void attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolved, () => settingsFace)
|
||||
})
|
||||
},
|
||||
}), 'dsh-better-sidebar: terminal WebSocket')
|
||||
@@ -695,8 +789,8 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
|
||||
ctx.effect(() => () => {
|
||||
toolsDisposers?.()
|
||||
ptyManager.disposeAll()
|
||||
agentPtyRegistry.disposeAll()
|
||||
ptyManager?.disposeAll()
|
||||
agentPtyRegistry?.disposeAll()
|
||||
wss.close()
|
||||
agentListWss.close()
|
||||
}, 'dsh-better-sidebar: teardown')
|
||||
@@ -704,7 +798,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
|
||||
/** Push the live agent-terminal list for one session to a connected sidebar view. */
|
||||
async function attachAgentList(
|
||||
registry: AgentPtyRegistry,
|
||||
registry: AgentPtyRegistry | null,
|
||||
ws: WebSocket,
|
||||
req: SidebarHttpRequest,
|
||||
): Promise<void> {
|
||||
@@ -717,13 +811,15 @@ async function attachAgentList(
|
||||
}
|
||||
const send = (): void => {
|
||||
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()
|
||||
const unsubscribe = registry.subscribe(send)
|
||||
ws.on('close', () => { unsubscribe() })
|
||||
ws.on('error', () => { unsubscribe() })
|
||||
const unsubscribe = registry?.subscribe(send)
|
||||
ws.on('close', () => { unsubscribe?.() })
|
||||
ws.on('error', () => { unsubscribe?.() })
|
||||
} catch (error) {
|
||||
ws.close(1011, error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
@@ -743,16 +839,23 @@ async function attachAgentList(
|
||||
*/
|
||||
async function attachTerminal(
|
||||
ctx: Context,
|
||||
ptyManager: PtyManager,
|
||||
agentPtyRegistry: AgentPtyRegistry,
|
||||
ptyManager: PtyManager | null,
|
||||
agentPtyRegistry: AgentPtyRegistry | null,
|
||||
ws: WebSocket,
|
||||
req: SidebarHttpRequest,
|
||||
resolved: ResolvedSidebarConfig,
|
||||
getSettings: () => SidebarSettingsFace | undefined,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
||||
const uuid = url.searchParams.get('uuid')
|
||||
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)
|
||||
if (handle === undefined) {
|
||||
ws.close(1011, `agent terminal "${uuid}" not found`)
|
||||
@@ -767,8 +870,18 @@ async function attachTerminal(
|
||||
ws.close(1008, 'either ?uuid or ?sessionId+?tab are required')
|
||||
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 handle = ptyManager.open(sessionId, tabId, cwd, 80, 24)
|
||||
// Settings-page shell overrides win over the yaml/auto shell for
|
||||
// terminals opened from now on (existing pty handles keep their shell).
|
||||
const overrides = shellOverridesOf(getSettings)
|
||||
const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24, overrides.shell, overrides.shellArgs)
|
||||
// Replay the transcript, then follow live output.
|
||||
if (handle.transcript !== '') ws.send(handle.transcript)
|
||||
const onData = (data: string): void => {
|
||||
|
||||
+81
-7
@@ -60,6 +60,45 @@ export interface SidebarPrefs {
|
||||
* own enable switch gates it too (both must be on for the takeover).
|
||||
*/
|
||||
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
|
||||
/**
|
||||
* The shell the UI and agent terminals spawn (absolute path or bare
|
||||
* executable name). Empty (default) keeps the legacy resolution order:
|
||||
* `cordis.patch.yml` `config.shell`, then `$SHELL` / login shell /
|
||||
* `powershell.exe` on Windows. Set it from the terminal card's gear in
|
||||
* the Side card settings (or the yaml) to pin a specific shell — takes
|
||||
* effect for terminals opened afterwards.
|
||||
*/
|
||||
terminalShell: string
|
||||
/**
|
||||
* Explicit arguments for `terminalShell`, space-separated (empty keeps
|
||||
* the platform defaults; when set, they fully replace them — same
|
||||
* contract as the yaml `shellArgs`).
|
||||
*/
|
||||
terminalShellArgs: string
|
||||
/**
|
||||
* Position compatibility mode: reserves space at the top for the native
|
||||
* Windows title bar (drawn at the window's top-right corner over the web
|
||||
* content in frameless/hidden-title-bar windows). When on, the toggle
|
||||
* cluster drops below the strip and the right panel's content starts
|
||||
* below it. Off by default — the sidebar layout is untouched.
|
||||
*/
|
||||
titleBarCompat: boolean
|
||||
/**
|
||||
* The reserved top strip height in px when `titleBarCompat` is on
|
||||
* (0–120, default 40). Drives the `--dsh-title-bar-strip` CSS variable:
|
||||
* the toggle cluster drops `strip + 3px` and the right panel's content
|
||||
* starts `strip` px below its top edge.
|
||||
*/
|
||||
titleBarStripPx: number
|
||||
/**
|
||||
* Whether the HTML previewer drops its sandboxed iframe. Sandbox ON (the
|
||||
* default) renders previewed HTML in an opaque-origin iframe that cannot
|
||||
@@ -85,13 +124,31 @@ export interface SidebarPrefs {
|
||||
*/
|
||||
browserNoSandbox: boolean
|
||||
/**
|
||||
* Whether clicking an http(s) EXTERNAL link in the GUI (chat messages,
|
||||
* tool rows, prose mentions) opens the sidebar browser instead of a new
|
||||
* browser tab. On by default; gated on the browser tab's own enable
|
||||
* switch (both must be on for the takeover). Ctrl/Cmd+click always
|
||||
* bypasses the takeover.
|
||||
* MASTER switch: whether clicking an EXTERNAL link in the GUI (chat
|
||||
* messages, tool rows, prose mentions) is taken over into the sidebar at
|
||||
* all. On by default; the per-protocol granularity lives in
|
||||
* `browserInterceptHttp` / `browserInterceptHttps` (the protocol flag
|
||||
* must also be on), and the target tab's own enable switch gates it too.
|
||||
* Ctrl/Cmd+click always bypasses the takeover. Kept as the master so old
|
||||
* documents keep their meaning with no migration (an explicit `false`
|
||||
* stays "never take over").
|
||||
*/
|
||||
browserInterceptLinks: boolean
|
||||
/**
|
||||
* Whether clicking an http EXTERNAL link in the GUI opens the sidebar
|
||||
* (the built-in browser tab, or a plugin tab that declares `urlTarget`)
|
||||
* instead of a new browser tab. On by default; gated on the
|
||||
* `browserInterceptLinks` master and the target tab's own enable switch.
|
||||
*/
|
||||
browserInterceptHttp: boolean
|
||||
/**
|
||||
* Whether clicking an https EXTERNAL link in the GUI opens the sidebar
|
||||
* instead of a new browser tab. OFF by default — most https sites (e.g.
|
||||
* GitHub) refuse iframe embedding, so the system browser is the smoother
|
||||
* default; gated on the `browserInterceptLinks` master and the target
|
||||
* tab's own enable switch.
|
||||
*/
|
||||
browserInterceptHttps: boolean
|
||||
/**
|
||||
* Per-tab enable switches, keyed by tab descriptor id (`'explorer'`,
|
||||
* `'my-plugin:db'`). An ABSENT key means enabled — only an explicit
|
||||
@@ -123,16 +180,21 @@ export interface SidebarPrefs {
|
||||
/** Range contract of {@link SidebarPrefs.defaultWidthPercent}. */
|
||||
export const WIDTH_PERCENT_MIN = 20
|
||||
export const WIDTH_PERCENT_MAX = 60
|
||||
export const WIDTH_PERCENT_DEFAULT = 30
|
||||
export const WIDTH_PERCENT_DEFAULT = 35
|
||||
|
||||
/** Range contract of {@link SidebarPrefs.terminalFontSize}. */
|
||||
export const TERMINAL_FONT_SIZE_MIN = 9
|
||||
export const TERMINAL_FONT_SIZE_MAX = 32
|
||||
export const TERMINAL_FONT_SIZE_DEFAULT = 13
|
||||
|
||||
/** Range contract of {@link SidebarPrefs.titleBarStripPx}. */
|
||||
export const TITLE_BAR_STRIP_MIN = 0
|
||||
export const TITLE_BAR_STRIP_MAX = 120
|
||||
export const TITLE_BAR_STRIP_DEFAULT = 40
|
||||
|
||||
/** Fallback prefs used whenever the settings document is unreachable or malformed. */
|
||||
export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
|
||||
openByDefault: true,
|
||||
openByDefault: false,
|
||||
defaultWidthPercent: WIDTH_PERCENT_DEFAULT,
|
||||
autoOpenSubagent: true,
|
||||
autoOpenJobs: true,
|
||||
@@ -141,10 +203,17 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT,
|
||||
interceptOpenPath: true,
|
||||
editorExplorer: false,
|
||||
terminalShell: '',
|
||||
terminalShellArgs: '',
|
||||
titleBarCompat: false,
|
||||
titleBarStripPx: TITLE_BAR_STRIP_DEFAULT,
|
||||
htmlViewerNoSandbox: false,
|
||||
htmlViewerDefaultUnsafe: false,
|
||||
browserNoSandbox: false,
|
||||
browserInterceptLinks: true,
|
||||
browserInterceptHttp: true,
|
||||
browserInterceptHttps: false,
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
pluginSettings: {},
|
||||
@@ -159,3 +228,8 @@ export function clampWidthPercent(value: number): number {
|
||||
export function clampTerminalFontSize(value: number): number {
|
||||
return Math.min(TERMINAL_FONT_SIZE_MAX, Math.max(TERMINAL_FONT_SIZE_MIN, Math.round(value)))
|
||||
}
|
||||
|
||||
/** Clamp one title-bar strip height into the contract range (shared by schema and client reads). */
|
||||
export function clampTitleBarStrip(value: number): number {
|
||||
return Math.min(TITLE_BAR_STRIP_MAX, Math.max(TITLE_BAR_STRIP_MIN, Math.round(value)))
|
||||
}
|
||||
|
||||
+240
@@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
+139
-9
@@ -9,7 +9,9 @@
|
||||
import { chmodSync, existsSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import * as nodePty from 'node-pty'
|
||||
import { userInfo } from 'node:os'
|
||||
import type { IPty } from 'node-pty'
|
||||
import { loadRequiredNodePty, type NodePtyModule } from './pty-deps.ts'
|
||||
import { SidebarError } from './wire.ts'
|
||||
|
||||
/** Per-terminal transcript bound (bytes kept for replay). */
|
||||
@@ -51,7 +53,7 @@ export interface SidebarPty {
|
||||
* the page-load hydrate race can attach the real cwd after the first
|
||||
* connect, and a shell in the wrong directory must not linger). */
|
||||
cwd: string
|
||||
pty: nodePty.IPty
|
||||
pty: IPty
|
||||
/** Output accumulated since spawn (bounded; head dropped when over the limit). */
|
||||
transcript: string
|
||||
/** Whether the top-level process exited (transcript stays replayable). */
|
||||
@@ -70,6 +72,9 @@ export class PtyManager {
|
||||
constructor(
|
||||
private readonly shell: string,
|
||||
private readonly maxPerSession: number,
|
||||
private readonly shellArgs: string[] = [],
|
||||
/** 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. */
|
||||
@@ -99,7 +104,15 @@ export class PtyManager {
|
||||
* @returns the live handle.
|
||||
* @throws {SidebarError} pty-error when the per-session cap is reached.
|
||||
*/
|
||||
open(sessionId: string, tabId: string, cwd: string, cols: number, rows: number): SidebarPty {
|
||||
open(
|
||||
sessionId: string,
|
||||
tabId: string,
|
||||
cwd: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
shell?: string,
|
||||
shellArgs?: string[],
|
||||
): SidebarPty {
|
||||
const key = `${sessionId}:${tabId}`
|
||||
this.cancelClose(key)
|
||||
const existing = this.sessions.get(key)
|
||||
@@ -118,7 +131,7 @@ export class PtyManager {
|
||||
sessionId,
|
||||
tabId,
|
||||
cwd,
|
||||
pty: nodePty.spawn(this.shell, [], {
|
||||
pty: this.nodePty.spawn(shell ?? this.shell, shellSpawnArgs(shellArgs ?? this.shellArgs), {
|
||||
name: 'xterm-256color',
|
||||
cols: Math.max(2, Math.floor(cols)),
|
||||
rows: Math.max(2, Math.floor(rows)),
|
||||
@@ -191,9 +204,126 @@ export class PtyManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** The interactive shell for this platform (empty SHELL falls back). */
|
||||
export function defaultShell(): string {
|
||||
if (process.platform === 'win32') return 'powershell.exe'
|
||||
const shell = process.env.SHELL
|
||||
return shell !== undefined && shell.trim() !== '' ? shell : '/bin/bash'
|
||||
/**
|
||||
* Inputs for {@link defaultShell} resolution. Every field is optional and
|
||||
* defaults to the live process, which keeps the no-argument call sites
|
||||
* working while tests (and exotic embedders) can pin the platform, the
|
||||
* environment, and the existence probe independently — the Windows chain
|
||||
* never executes on the ubuntu CI runners, so it is only testable through
|
||||
* these injection points.
|
||||
*/
|
||||
export interface ShellResolutionOptions {
|
||||
/** Platform override (defaults to `process.platform`). */
|
||||
platform?: NodeJS.Platform
|
||||
/** 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);
|
||||
// without a login shell there is nothing better than the bash default.
|
||||
try {
|
||||
const loginShell = userInfo().shell
|
||||
if (typeof loginShell === 'string' && loginShell.trim() !== '') return loginShell
|
||||
} catch {
|
||||
// no passwd entry: fall through to /bin/bash
|
||||
}
|
||||
return '/bin/bash'
|
||||
}
|
||||
|
||||
/**
|
||||
* A short display name for a shell executable, used as the terminal tab
|
||||
* title. `/bin/zsh` → `zsh`, `C:\...\powershell.exe` → `powershell`.
|
||||
* Falls back to the raw value when no basename can be derived.
|
||||
*/
|
||||
export function shellDisplayName(shell: string): string {
|
||||
const normalized = shell.replace(/\\/g, '/')
|
||||
const base = normalized.slice(normalized.lastIndexOf('/') + 1)
|
||||
if (base === '') return shell
|
||||
return base.replace(/\.(exe|cmd|bat)$/i, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn arguments that make the shell behave like a terminal-emulator tab:
|
||||
* POSIX shells start as login shells (`-l`) so they read the profile files
|
||||
* (`~/.profile`, `~/.zprofile`); Windows PowerShell takes no login flag.
|
||||
*
|
||||
* When explicit `configured` args are supplied they REPLACE the platform
|
||||
* defaults entirely, giving deployments full control over shell startup.
|
||||
*/
|
||||
export function shellSpawnArgs(configured: string[] = []): string[] {
|
||||
if (configured.length > 0) return [...configured]
|
||||
return process.platform === 'win32' ? [] : ['-l']
|
||||
}
|
||||
|
||||
+3
-1
@@ -81,6 +81,7 @@ export function registerTools(
|
||||
ctx: Context,
|
||||
registry: AgentPtyRegistry,
|
||||
resolveCwd: (sessionId: string) => string,
|
||||
readShellOverrides: () => { shell?: string; shellArgs?: string[] },
|
||||
): () => void {
|
||||
const disposers: Array<() => void> = []
|
||||
const register = (tool: ReturnType<typeof defineTool>): void => {
|
||||
@@ -127,7 +128,8 @@ export function registerTools(
|
||||
exec.signal.throwIfAborted()
|
||||
const sessionId = sessionIdOf(exec)
|
||||
const cwd = resolveCwd(sessionId)
|
||||
const uuid = registry.create(sessionId, args.title, args.command, cwd, 80, 24)
|
||||
const { shell, shellArgs } = readShellOverrides()
|
||||
const uuid = registry.create(sessionId, args.title, args.command, cwd, 80, 24, shell, shellArgs)
|
||||
return Promise.resolve({ uuid, title: args.title })
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -15,6 +15,7 @@ export type SidebarErrorCode =
|
||||
| 'fs-error'
|
||||
| 'git-error'
|
||||
| 'pty-error'
|
||||
| 'pty-deps-missing'
|
||||
| 'job-error'
|
||||
| 'settings-rejected'
|
||||
| 'settings-conflict'
|
||||
|
||||
@@ -155,7 +155,7 @@ describe('PluginListBody copy click (interactive)', () => {
|
||||
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 service = createBetterSidebarService(store)
|
||||
const opened: Array<[string, string, string]> = []
|
||||
@@ -171,14 +171,21 @@ describe('PluginListBody copy click (interactive)', () => {
|
||||
act(() => {
|
||||
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:"]')]
|
||||
expect(jumpButtons).toHaveLength(2) // the name + the jump button
|
||||
act(() => { (jumpButtons[0] as HTMLButtonElement).click() })
|
||||
act(() => { (jumpButtons[1] as HTMLButtonElement).click() })
|
||||
expect(opened).toEqual([
|
||||
[officeEntry.url, '_blank', 'noopener'],
|
||||
[officeEntry.url, '_blank', 'noopener'],
|
||||
])
|
||||
expect(jumpButtons).toHaveLength(builtinViewerPlugins.length * 2)
|
||||
for (const button of jumpButtons) {
|
||||
act(() => { (button as HTMLButtonElement).click() })
|
||||
}
|
||||
expect(opened).toEqual(
|
||||
builtinViewerPlugins.flatMap((entry) => [
|
||||
[entry.url, '_blank', 'noopener'],
|
||||
[entry.url, '_blank', 'noopener'],
|
||||
]),
|
||||
)
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
} finally {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -55,6 +55,9 @@ if (g.window === undefined) {
|
||||
innerWidth: 1024,
|
||||
innerHeight: 768,
|
||||
getComputedStyle: () => ({ getPropertyValue: () => '' }),
|
||||
// desktop-env.ts reads the shell's URL stamps from location.search
|
||||
// (Sidebar renders with parseDesktopEnv even in specs).
|
||||
location: { search: '' },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+75
-22
@@ -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);
|
||||
* the catch-all `code` viewer, the NUL-sniffing `binary-download` viewer,
|
||||
* and the html sandbox settings pin the registry's behavior. (Office
|
||||
@@ -15,30 +15,35 @@ import { createBetterSidebarService } from '../src/client/service.ts'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
import { allLeaves } from '../src/client/state.ts'
|
||||
import { registerBuiltins } from '../src/client/builtins/index.ts'
|
||||
import type { BuiltinTabOptions } from '../src/client/builtins/tabs.tsx'
|
||||
import { t } from '../src/client/locales.ts'
|
||||
|
||||
function setup(): { service: ReturnType<typeof createBetterSidebarService>; store: ReturnType<typeof createSidebarStore>; dispose: () => void } {
|
||||
function setup(options: BuiltinTabOptions = {}): { service: ReturnType<typeof createBetterSidebarService>; store: ReturnType<typeof createSidebarStore>; dispose: () => void } {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
const dispose = registerBuiltins({} as Context, service)
|
||||
const dispose = registerBuiltins({} as Context, service, options)
|
||||
return { service, store, dispose }
|
||||
}
|
||||
|
||||
describe('built-in tab registrations', () => {
|
||||
it('registers the 7 built-in tabs', () => {
|
||||
it('registers the 6 built-in tabs', () => {
|
||||
const { service } = setup()
|
||||
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()
|
||||
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', () => {
|
||||
const { service } = setup()
|
||||
for (const id of ['explorer', 'git', 'subagent']) {
|
||||
for (const id of ['git', 'subagent']) {
|
||||
expect(service.getTab(id)?.single).toBe(true)
|
||||
}
|
||||
})
|
||||
@@ -49,32 +54,56 @@ describe('built-in tab registrations', () => {
|
||||
expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent', 'autoOpenJobs'])
|
||||
})
|
||||
|
||||
it('the terminal tab declares the model terminal-tools, auto-terminal and custom-font settings', () => {
|
||||
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, shell and custom-font settings', () => {
|
||||
const { service } = setup()
|
||||
const toggles = service.getTab('terminal')?.settings?.toggles ?? []
|
||||
expect(toggles.map(t => t.key)).toEqual(['agentTerminalTools', 'bottomPanelAutoTerminal', 'terminalFontFamily', 'terminalFontSize'])
|
||||
// The font rows are text/number inputs (not switches), with the size
|
||||
// row bounded by the shared 9–32 contract.
|
||||
expect(toggles.map(t => t.key)).toEqual(['agentTerminalTools', 'bottomPanelAutoTerminal', 'terminalShell', 'terminalShellArgs', 'terminalFontFamily', 'terminalFontSize'])
|
||||
// The shell rows are text inputs (empty = yaml/auto resolution), the
|
||||
// font rows text/number inputs (not switches), with the size row bounded
|
||||
// by the shared 9–32 contract.
|
||||
expect(toggles[2]?.type).toBe('text')
|
||||
expect(toggles[2]?.title).toBeDefined()
|
||||
expect(toggles[2]?.placeholder).toBeDefined()
|
||||
expect(toggles[3]?.type).toBe('number')
|
||||
expect(toggles[3]?.min).toBe(9)
|
||||
expect(toggles[3]?.max).toBe(32)
|
||||
expect(toggles[3]?.unit).toBe('px')
|
||||
expect(toggles[3]?.type).toBe('text')
|
||||
expect(toggles[3]?.title).toBeDefined()
|
||||
expect(toggles[4]?.type).toBe('text')
|
||||
expect(toggles[4]?.title).toBeDefined()
|
||||
expect(toggles[4]?.placeholder).toBeDefined()
|
||||
expect(toggles[5]?.type).toBe('number')
|
||||
expect(toggles[5]?.min).toBe(9)
|
||||
expect(toggles[5]?.max).toBe(32)
|
||||
expect(toggles[5]?.unit).toBe('px')
|
||||
// The first two rows stay plain boolean switches.
|
||||
expect(toggles[0]?.type ?? 'switch').toBe('switch')
|
||||
expect(toggles[1]?.type ?? 'switch').toBe('switch')
|
||||
})
|
||||
|
||||
it('the browser tab declares its sandbox and link-takeover related settings', () => {
|
||||
it('the browser tab declares its sandbox, link-takeover master and per-protocol settings', () => {
|
||||
const { service } = setup()
|
||||
const toggles = service.getTab('browser')?.settings?.toggles ?? []
|
||||
expect(toggles.map(t => t.key)).toEqual(['browserNoSandbox', 'browserInterceptLinks'])
|
||||
expect(toggles[0]?.title).toBeDefined()
|
||||
expect(toggles[0]?.desc).toBeDefined()
|
||||
expect(toggles[1]?.title).toBeDefined()
|
||||
expect(toggles[1]?.desc).toBeDefined()
|
||||
expect(toggles.map(t => t.key)).toEqual([
|
||||
'browserNoSandbox',
|
||||
'browserInterceptLinks',
|
||||
'browserInterceptHttp',
|
||||
'browserInterceptHttps',
|
||||
])
|
||||
for (const toggle of toggles) {
|
||||
expect(toggle.title).toBeDefined()
|
||||
expect(toggle.desc).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('the browser createTab mints browser:<n> ids and bumps nextBrowser', () => {
|
||||
@@ -90,6 +119,30 @@ describe('built-in tab registrations', () => {
|
||||
expect(state.nextBrowser).toBe(3)
|
||||
})
|
||||
|
||||
it('the terminal createTab uses shell-name titles and hidden uuid ids, allowing duplicates', () => {
|
||||
const { service, store } = setup({ terminalTitle: () => 'bash' })
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'terminal' })
|
||||
service.openTab({ type: 'terminal' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs).filter(t => t.type === 'terminal')
|
||||
expect(tabs).toHaveLength(2)
|
||||
expect(tabs[0]!.title).toBe('bash')
|
||||
expect(tabs[1]!.title).toBe('bash')
|
||||
expect(tabs[0]!.id).toMatch(/^terminal:[0-9a-f-]{36}$/)
|
||||
expect(tabs[1]!.id).toMatch(/^terminal:[0-9a-f-]{36}$/)
|
||||
expect(tabs[0]!.id).not.toBe(tabs[1]!.id)
|
||||
})
|
||||
|
||||
it('the terminal createTab falls back to the localized terminal label before shell info resolves', () => {
|
||||
const { service, store } = setup()
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'terminal' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tab = allLeaves(state.splits).flatMap(leaf => leaf.tabs).find(t => t.type === 'terminal')
|
||||
expect(tab?.title).toBe(t('terminal'))
|
||||
})
|
||||
|
||||
it('every built-in tab carries the settings-surface icon', () => {
|
||||
const { service } = setup()
|
||||
for (const tab of service.getTabs()) {
|
||||
|
||||
@@ -145,6 +145,6 @@ describe('/sidebar/bundle route', () => {
|
||||
})
|
||||
|
||||
it('exports the chunk allowlist (mirror of src/client/chunk-loader.ts)', () => {
|
||||
expect([...CHUNK_NAMES]).toEqual(['terminal', 'editor'])
|
||||
expect([...CHUNK_NAMES]).toEqual(['terminal', 'editor', 'mermaid'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,11 +15,16 @@ import { CHUNK_EXTERNALS } from '../src/client/chunk-loader.ts'
|
||||
|
||||
const g = globalThis as Record<string, unknown>
|
||||
|
||||
const CHUNKS = ['terminal', 'editor']
|
||||
const CHUNKS = ['terminal', 'editor', 'mermaid']
|
||||
|
||||
describe('built chunk artifacts', () => {
|
||||
it('each chunk assigns its global registry slot when executed as a script', () => {
|
||||
g.window = g // classic-script globals
|
||||
// mermaid's core hooks window.addEventListener('load') at module scope
|
||||
// (its startOnLoad wiring); the Node global lacks the API, so stub it
|
||||
// exactly like browser-globals.ts does for its window stub.
|
||||
if (typeof g.addEventListener !== 'function') g.addEventListener = () => {}
|
||||
if (typeof g.removeEventListener !== 'function') g.removeEventListener = () => {}
|
||||
for (const name of CHUNKS) {
|
||||
const code = readFileSync(`lib/client-${name}.js`, 'utf8')
|
||||
// eslint-disable-next-line no-new-func
|
||||
|
||||
+216
-3
@@ -18,13 +18,15 @@ import {
|
||||
CHUNK_EXTERNALS,
|
||||
loadChunk,
|
||||
registerChunkForTests,
|
||||
revalidateChunksOnReactivate,
|
||||
resetChunks,
|
||||
setChunkModuleSystem,
|
||||
setChunkScriptLoaderForTests,
|
||||
} from '../src/client/chunk-loader.ts'
|
||||
import type { ChunkExports } from '../src/client/chunk-loader.ts'
|
||||
|
||||
interface FakeModuleSystem {
|
||||
import: ReturnType<typeof vi.fn>
|
||||
import: (specifier: string) => Promise<unknown>
|
||||
}
|
||||
|
||||
function installModuleSystem(): FakeModuleSystem {
|
||||
@@ -53,6 +55,7 @@ function simulateScript(name: string, factory: (require: (spec: string) => unkno
|
||||
|
||||
beforeEach(() => {
|
||||
removeModuleSystem()
|
||||
setChunkModuleSystem(undefined)
|
||||
delete (globalThis as Record<string, unknown>).__dshChunks__
|
||||
resetChunks()
|
||||
setChunkScriptLoaderForTests(null)
|
||||
@@ -89,6 +92,34 @@ describe('test-registry path (vitest / jsdom-less environments)', () => {
|
||||
})
|
||||
|
||||
describe('production path (script injection + global registry + externals require)', () => {
|
||||
it('resolves externals through an injected ctx.modules system (rc.8 — no page global)', async () => {
|
||||
const modules = installModuleSystem()
|
||||
// rc.8 drops window.__DSH_MODULES__; the client half injects ctx.modules.
|
||||
removeModuleSystem()
|
||||
setChunkModuleSystem(modules)
|
||||
const loaded: string[] = []
|
||||
setChunkScriptLoaderForTests(async (src) => {
|
||||
loaded.push(src)
|
||||
simulateScript('editor', (require) => ({ TextEditor: `view:${String(require('react'))}` }))
|
||||
})
|
||||
const exports = await loadChunk('editor')
|
||||
expect(loaded).toEqual(['/sidebar/bundle/editor.js'])
|
||||
expect(exports).toEqual({ TextEditor: 'view:[object Object]' })
|
||||
expect(modules.import).toHaveBeenCalledTimes(CHUNK_EXTERNALS.length)
|
||||
// The injection also lands on a plugin-owned global so chunk-bundle
|
||||
// copies of this loader (which inline their own module instance and
|
||||
// never run apply()) can resolve externals too — rc.8 has no shell
|
||||
// page global anymore.
|
||||
expect((globalThis as Record<string, unknown>).__dshSidebarModuleSystem__).toBe(modules)
|
||||
// The injection survives resetChunks (shell state, not chunk state).
|
||||
resetChunks()
|
||||
setChunkScriptLoaderForTests(async () => {
|
||||
simulateScript('editor', () => ({ TextEditor: 'editor-view' }))
|
||||
})
|
||||
await loadChunk('editor')
|
||||
expect(modules.import).toHaveBeenCalledTimes(CHUNK_EXTERNALS.length * 2)
|
||||
})
|
||||
|
||||
it('injects the chunk script, then materializes the factory with externals from the module table', async () => {
|
||||
const modules = installModuleSystem()
|
||||
const loaded: string[] = []
|
||||
@@ -192,6 +223,190 @@ describe('production path (script injection + global registry + externals requir
|
||||
})
|
||||
})
|
||||
|
||||
describe('revalidateChunksOnReactivate (HMR re-activation keeps unchanged chunks)', () => {
|
||||
/** Stub the bundle route's HEAD endpoint with per-name ETags (Error = unreachable). */
|
||||
function stubBundleHead(etagFor: (name: string) => string | null | Error): void {
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: unknown) => {
|
||||
const url = String(input)
|
||||
const name = url.endsWith('terminal.js') ? 'terminal' : 'editor'
|
||||
const etag = etagFor(name)
|
||||
if (etag instanceof Error) throw etag
|
||||
return {
|
||||
headers: { get: (key: string) => (key.toLowerCase() === 'etag' ? etag : null) },
|
||||
} as unknown as Response
|
||||
}))
|
||||
}
|
||||
|
||||
/** Let the fire-and-forget ETag recorder (recordEtag) settle. */
|
||||
const settleEtag = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 5))
|
||||
|
||||
it('keeps the resolved exports of an unchanged chunk — no re-inject / re-execute', async () => {
|
||||
installModuleSystem()
|
||||
let scriptCalls = 0
|
||||
setChunkScriptLoaderForTests(async () => {
|
||||
scriptCalls += 1
|
||||
simulateScript('editor', () => ({ TextEditor: 'editor-view' }))
|
||||
})
|
||||
stubBundleHead(() => '"v1"')
|
||||
const first = await loadChunk('editor')
|
||||
expect(scriptCalls).toBe(1)
|
||||
await settleEtag()
|
||||
await revalidateChunksOnReactivate()
|
||||
const second = await loadChunk('editor')
|
||||
expect(second).toBe(first)
|
||||
expect(scriptCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('drops a chunk whose ETag changed on disk — the next open re-injects and re-executes', async () => {
|
||||
installModuleSystem()
|
||||
let scriptCalls = 0
|
||||
setChunkScriptLoaderForTests(async () => {
|
||||
scriptCalls += 1
|
||||
simulateScript('editor', () => ({ TextEditor: `editor-view:${scriptCalls}` }))
|
||||
})
|
||||
let etag = '"v1"'
|
||||
stubBundleHead(() => etag)
|
||||
const first = await loadChunk('editor')
|
||||
await settleEtag()
|
||||
etag = '"v2"'
|
||||
await revalidateChunksOnReactivate()
|
||||
const second = await loadChunk('editor')
|
||||
expect(second).not.toBe(first)
|
||||
expect(scriptCalls).toBe(2)
|
||||
})
|
||||
|
||||
it('fails open when revalidation cannot reach the bundle route', async () => {
|
||||
installModuleSystem()
|
||||
let scriptCalls = 0
|
||||
setChunkScriptLoaderForTests(async () => {
|
||||
scriptCalls += 1
|
||||
simulateScript('terminal', () => ({ TerminalView: 'tv' }))
|
||||
})
|
||||
stubBundleHead(() => new Error('HEAD unreachable'))
|
||||
await loadChunk('terminal')
|
||||
await settleEtag()
|
||||
await revalidateChunksOnReactivate()
|
||||
await loadChunk('terminal')
|
||||
expect(scriptCalls).toBe(2)
|
||||
})
|
||||
|
||||
it('loadChunk is a barrier: a pending revalidation never serves stale exports (CR P1)', async () => {
|
||||
installModuleSystem()
|
||||
let scriptCalls = 0
|
||||
setChunkScriptLoaderForTests(async () => {
|
||||
scriptCalls += 1
|
||||
simulateScript('editor', () => ({ TextEditor: `editor-view:${scriptCalls}` }))
|
||||
})
|
||||
// First load: etag "v1", recorded after settlement.
|
||||
let etag = '"v1"'
|
||||
stubBundleHead(() => etag)
|
||||
const first = await loadChunk('editor')
|
||||
await settleEtag()
|
||||
expect(scriptCalls).toBe(1)
|
||||
// The chunk changed on disk; the revalidation HEAD is gated (pending).
|
||||
etag = '"v2"'
|
||||
let release: (() => void) | undefined
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
vi.stubGlobal('fetch', vi.fn(async () => {
|
||||
await gate
|
||||
return { headers: { get: () => etag } } as unknown as Response
|
||||
}))
|
||||
const revalidating = revalidateChunksOnReactivate()
|
||||
// A lazy open DURING the pending revalidation must NOT get the old cache.
|
||||
let resolved = false
|
||||
let pendingLoad: Promise<ChunkExports> | null = null
|
||||
pendingLoad = loadChunk('editor').then((exports) => { resolved = true; return exports })
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
expect(resolved, 'loadChunk must await the pending revalidation').toBe(false)
|
||||
// Release the HEAD; the barrier lifts and the load re-injects fresh exports.
|
||||
release?.()
|
||||
await revalidating
|
||||
const after = await pendingLoad
|
||||
expect(after).not.toBe(first)
|
||||
expect(scriptCalls).toBe(2)
|
||||
})
|
||||
|
||||
it('a HEAD that never answers is bounded by a timeout signal and fails open (barrier cannot wedge loads forever)', async () => {
|
||||
installModuleSystem()
|
||||
let scriptCalls = 0
|
||||
setChunkScriptLoaderForTests(async () => {
|
||||
scriptCalls += 1
|
||||
simulateScript('editor', () => ({ TextEditor: `editor-view:${scriptCalls}` }))
|
||||
})
|
||||
// First load settles an ETag under a healthy HEAD route.
|
||||
stubBundleHead(() => '"v1"')
|
||||
const first = await loadChunk('editor')
|
||||
await settleEtag()
|
||||
expect(scriptCalls).toBe(1)
|
||||
// The revalidation HEAD now hangs. The loader must hand the fetch a
|
||||
// timeout signal and, when it fires, fail open (drop + re-fetch) — the
|
||||
// barrier must never block lazy loads indefinitely.
|
||||
const controller = new AbortController()
|
||||
const timeoutSpy = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(controller.signal)
|
||||
vi.stubGlobal('fetch', vi.fn(async (_input, init?: RequestInit) => {
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => reject(new DOMException('timed out', 'AbortError')))
|
||||
})
|
||||
}))
|
||||
const revalidating = revalidateChunksOnReactivate()
|
||||
let resolved = false
|
||||
const pendingLoad = loadChunk('editor').then((exports) => { resolved = true; return exports })
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
expect(resolved, 'loadChunk must await the pending revalidation').toBe(false)
|
||||
controller.abort() // the timeout fires
|
||||
await revalidating
|
||||
const after = await pendingLoad
|
||||
expect(after).not.toBe(first)
|
||||
expect(scriptCalls).toBe(2)
|
||||
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Number))
|
||||
timeoutSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('resetChunks clears a pending revalidation barrier (the next load does not wait on the orphaned task)', async () => {
|
||||
installModuleSystem()
|
||||
let scriptCalls = 0
|
||||
setChunkScriptLoaderForTests(async () => {
|
||||
scriptCalls += 1
|
||||
simulateScript('editor', () => ({ TextEditor: `editor-view:${scriptCalls}` }))
|
||||
})
|
||||
let etag = '"v1"'
|
||||
stubBundleHead(() => etag)
|
||||
await loadChunk('editor')
|
||||
await settleEtag()
|
||||
// Gate the revalidation HEAD so the barrier stays pending.
|
||||
let release: (() => void) | undefined
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
vi.stubGlobal('fetch', vi.fn(async () => {
|
||||
await gate
|
||||
return { headers: { get: () => etag } } as unknown as Response
|
||||
}))
|
||||
const revalidating = revalidateChunksOnReactivate()
|
||||
// resetChunks mid-revalidation: the barrier guarded only the state being
|
||||
// dropped, so the next load must proceed without waiting for the HEAD.
|
||||
resetChunks()
|
||||
const after = await loadChunk('editor')
|
||||
expect(after).toEqual({ TextEditor: 'editor-view:2' })
|
||||
expect(scriptCalls).toBe(2)
|
||||
// The orphaned task still settles; its identity-guarded finally no-ops.
|
||||
// (Its sweep may later drop the freshly loaded entry — chunkEtags was
|
||||
// cleared by resetChunks, so the old ETag no longer matches. That is the
|
||||
// fail-safe direction: never serve stale, at worst one redundant fetch.)
|
||||
release?.()
|
||||
await revalidating
|
||||
})
|
||||
|
||||
it('clears test-registry fixtures on re-activation (per-test stubs never leak)', async () => {
|
||||
let testCalls = 0
|
||||
registerChunkForTests('editor', async () => { testCalls += 1; return { TextEditor: 'test-view' } })
|
||||
await loadChunk('editor')
|
||||
expect(testCalls).toBe(1)
|
||||
await revalidateChunksOnReactivate()
|
||||
// Test fixture gone: the production path runs and fails fast (no module system).
|
||||
await expect(loadChunk('editor')).rejects.toThrow('client module system unavailable')
|
||||
expect(testCalls).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('externals contract', () => {
|
||||
it('the loader resolves exactly the platform externals the chunk builds keep external', () => {
|
||||
expect(CHUNK_EXTERNALS).toEqual([
|
||||
@@ -201,9 +416,7 @@ describe('externals contract', () => {
|
||||
'react-dom/client',
|
||||
'cordis',
|
||||
'@deepseek-ai/dsh-client-ui-slots',
|
||||
'@deepseek-ai/dsh-client-web-react',
|
||||
'@deepseek-ai/dsh-client-ui-primitives',
|
||||
'@deepseek-ai/dsh-client-schema-form',
|
||||
'@deepseek-ai/dsh-client-runtime/client',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Desktop-shell detection tests: URL stamps (dsh-desktop-mode/platform)
|
||||
* from the official Electron shell, the preload marker
|
||||
* (__DSH_DESKTOP_FILE_PATH__), and the win32 advanced overlay adaptation.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import './browser-globals.ts'
|
||||
import { parseDesktopEnv, resetDesktopEnvForTests } from '../src/client/desktop-env.ts'
|
||||
|
||||
function setSearch(search: string): void {
|
||||
;(window.location as { search: string }).search = search
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetDesktopEnvForTests()
|
||||
delete (window as unknown as Record<string, unknown>).__DSH_DESKTOP_FILE_PATH__
|
||||
setSearch('/')
|
||||
})
|
||||
|
||||
describe('parseDesktopEnv', () => {
|
||||
it('reports a plain browser page as non-desktop', () => {
|
||||
expect(parseDesktopEnv()).toEqual({ desktop: false, mode: null, platform: null, win32OverlayTop: 0 })
|
||||
})
|
||||
|
||||
it('parses win32 advanced stamps with the 32px window-control overlay', () => {
|
||||
setSearch('?dsh-desktop-mode=advanced&dsh-desktop-platform=win32')
|
||||
const env = parseDesktopEnv()
|
||||
expect(env.desktop).toBe(true)
|
||||
expect(env.mode).toBe('advanced')
|
||||
expect(env.platform).toBe('win32')
|
||||
expect(env.win32OverlayTop).toBe(32)
|
||||
})
|
||||
|
||||
it('parses darwin advanced stamps without an overlay (traffic lights are top-left)', () => {
|
||||
setSearch('?dsh-desktop-mode=advanced&dsh-desktop-platform=darwin')
|
||||
const env = parseDesktopEnv()
|
||||
expect(env.desktop).toBe(true)
|
||||
expect(env.platform).toBe('darwin')
|
||||
expect(env.win32OverlayTop).toBe(0)
|
||||
})
|
||||
|
||||
it('parses compatibility mode as desktop with the native frame (no adaptation)', () => {
|
||||
setSearch('?dsh-desktop-mode=compatibility&dsh-desktop-platform=win32')
|
||||
const env = parseDesktopEnv()
|
||||
expect(env.desktop).toBe(true)
|
||||
expect(env.mode).toBe('compatibility')
|
||||
expect(env.win32OverlayTop).toBe(0)
|
||||
})
|
||||
|
||||
it('detects the desktop preload marker even without URL stamps', () => {
|
||||
;(window as unknown as Record<string, unknown>).__DSH_DESKTOP_FILE_PATH__ = { getPathForFile: () => '' }
|
||||
const env = parseDesktopEnv()
|
||||
expect(env.desktop).toBe(true)
|
||||
expect(env.mode).toBeNull()
|
||||
expect(env.win32OverlayTop).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores unknown mode values (exotic shells keep plain-browser semantics)', () => {
|
||||
setSearch('?dsh-desktop-mode=weird&dsh-desktop-platform=win32')
|
||||
expect(parseDesktopEnv().mode).toBeNull()
|
||||
expect(parseDesktopEnv().desktop).toBe(false)
|
||||
})
|
||||
|
||||
it('memoizes across calls until the test hook resets', () => {
|
||||
setSearch('?dsh-desktop-mode=advanced&dsh-desktop-platform=win32')
|
||||
const first = parseDesktopEnv()
|
||||
setSearch('/')
|
||||
expect(parseDesktopEnv()).toBe(first)
|
||||
resetDesktopEnvForTests()
|
||||
expect(parseDesktopEnv().desktop).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
|
||||
test('a very fast width drag still commits the dragged position (no rollback on quick release)', 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
|
||||
// main lane) so the pointer can reach the strip.
|
||||
try {
|
||||
await expect
|
||||
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
|
||||
.toBeGreaterThan(0)
|
||||
} catch {
|
||||
console.warn('[e2e-drag-fast] 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
|
||||
}
|
||||
|
||||
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()
|
||||
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)
|
||||
|
||||
const readWidth = (): Promise<number> => page.evaluate(() => {
|
||||
const value = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
|
||||
return Number.isNaN(value) ? 0 : value
|
||||
})
|
||||
const widthBefore = await readWidth()
|
||||
|
||||
const stripBox = await page.evaluate(() => {
|
||||
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,
|
||||
}
|
||||
})
|
||||
expect(stripBox, 'the width drag strip must be present').not.toBeNull()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await page.evaluate(() => {
|
||||
const host = document.querySelector('[data-dsh-better-sidebar]')
|
||||
const el = [...host!.querySelectorAll('*')]
|
||||
.filter(e => getComputedStyle(e).cursor === 'col-resize')
|
||||
.map(e => e.getBoundingClientRect())
|
||||
.filter(r => r.width > 0 && r.height > 0)
|
||||
.sort((a, b) => a.x - b.x)[0]
|
||||
return el === undefined ? null : { x: el.x + el.width, innerWidth: window.innerWidth }
|
||||
})
|
||||
if (box === null) return false
|
||||
const varWidth = await page.evaluate(() => parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width')) || 0)
|
||||
return Math.abs(box.x - (box.innerWidth - varWidth)) <= 8
|
||||
}, { timeout: 30_000 })
|
||||
.toBe(true)
|
||||
|
||||
const startX = stripBox!.x + stripBox!.width / 2
|
||||
const startY = stripBox!.y + Math.min(120, stripBox!.height / 2 + 60)
|
||||
|
||||
// FAST release: a single move and an immediate up — no per-frame waits,
|
||||
// so the last pointermove may never reach a requestAnimationFrame before
|
||||
// the release (the exact quick-flick that used to roll back to the
|
||||
// pre-drag width).
|
||||
await page.mouse.move(startX, startY)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(startX - 140, startY, { steps: 1 })
|
||||
await page.mouse.up()
|
||||
|
||||
// The committed width must reflect the drag — and stay there (no rollback
|
||||
// once React settles).
|
||||
await expect
|
||||
.poll(async () => await readWidth(), { timeout: 10_000 })
|
||||
.toBeGreaterThan(widthBefore + 100)
|
||||
await page.waitForTimeout(600)
|
||||
expect(await readWidth(), 'the fast drag must not roll back after settling').toBeGreaterThan(widthBefore + 100)
|
||||
})
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* Headless-render mount lane: prove the npm-packed plugin mounts into a real
|
||||
* `dsh web` instance and renders without crashing the shell.
|
||||
*
|
||||
* The server is NOT started here — `scripts/e2e-mount.sh` boots `dsh web`
|
||||
* (with the plugin mounted through the official `dsh plugin add` channel) and
|
||||
* injects the base URL via `DSH_E2E_URL`. This spec:
|
||||
*
|
||||
* 1. seeds one workspace + one session through the host's own RPC surface
|
||||
* (the same `workspace.create` / `session.create` calls the UI makes),
|
||||
* so the sidebar has a real session to render;
|
||||
* 2. loads the page in headless Chromium and asserts the shell and the
|
||||
* plugin's `[data-dsh-better-sidebar]` host mount;
|
||||
* 3. asserts the plugin's crash markers never appear (no RenderBoundary /
|
||||
* fail() strips, no `pageerror`, no plugin-prefixed console errors);
|
||||
* 4. expands the collapsed panel (openByDefault defaults off), sweeps every
|
||||
* built-in tab (Files / Source Control / Tasks / Terminal / Browser) —
|
||||
* including the lazily-fetched terminal chunk — and then opens seeded
|
||||
* files through the Files window's tree (separate mode: each file opens
|
||||
* its own new tab, the seeded home "Files" tab stays the explorer),
|
||||
* while response waits armed before goto prove the lazily-fetched editor
|
||||
* chunk (client-editor.js) and the mermaid chunk (client-mermaid.js,
|
||||
* rendered SVG diagram + zoom modal) loaded.
|
||||
*
|
||||
* 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
|
||||
* assertion.
|
||||
*/
|
||||
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)')
|
||||
}
|
||||
|
||||
/** Workspace the sidebar renders against (created by the lane's seeding). */
|
||||
const WORKSPACE_PATH = process.env.DSH_E2E_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-workspace')
|
||||
|
||||
/** A file seeded into the workspace, opened through the Files window's tree to
|
||||
* exercise the file-open path (editor chunk = client-editor.js). */
|
||||
const SEEDED_FILE = 'hello.txt'
|
||||
|
||||
/** A markdown file with a mermaid fence, opened through the Files window's
|
||||
* tree to force the lazily-packed mermaid chunk (client-mermaid.js) to load
|
||||
* and render a sanitized SVG diagram. */
|
||||
const SEEDED_MD_FILE = 'diagram.md'
|
||||
|
||||
/**
|
||||
* The plugin's crash markers. The client mounts inside an error boundary that
|
||||
* renders a strip whose text starts with these prefixes instead of crashing
|
||||
* (see src/client/index.tsx `fail()` and src/client/RenderBoundary.tsx).
|
||||
*/
|
||||
const CRASH_STRIP_PATTERNS = [/^dsh-better-sidebar:/, /^\[dsh-better-sidebar\]/]
|
||||
|
||||
/** Built-in tab titles the sweep drives (en-US copy; follows DSH locale). */
|
||||
const BUILTIN_TABS = ['Files', 'Source Control', 'Tasks', 'Terminal', 'Browser']
|
||||
|
||||
let api: APIRequestContext
|
||||
|
||||
/** Seed one workspace + one session (plus files for the editor/mermaid-chunk
|
||||
* probes) through the host's unary RPC surface. */
|
||||
async function seedSession(): Promise<void> {
|
||||
mkdirSync(WORKSPACE_PATH, { recursive: true })
|
||||
writeFileSync(join(WORKSPACE_PATH, SEEDED_FILE), 'hello from the mount lane\n')
|
||||
// The mermaid-chunk probe file: a markdown doc whose preview must fetch
|
||||
// client-mermaid.js and render the fence into an SVG diagram. The
|
||||
// reference-style link's definition sits AFTER the fence: it only
|
||||
// resolves when the preview is one single markdown parse (the mermaid
|
||||
// path must not split the document into independent MarkdownText blocks).
|
||||
writeFileSync(join(WORKSPACE_PATH, SEEDED_MD_FILE), [
|
||||
'# Diagram',
|
||||
'',
|
||||
'[before][shared]',
|
||||
'',
|
||||
'```mermaid',
|
||||
'graph TD',
|
||||
' A[Hello] --> B[World]',
|
||||
'```',
|
||||
'',
|
||||
'[shared]: https://example.com',
|
||||
'',
|
||||
'tail text',
|
||||
'',
|
||||
].join('\n'))
|
||||
const workspace = await api.post(`${BASE_URL}/api/workspace.create`, {
|
||||
data: { type: 'client-request', rpcId: 'e2e-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-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()
|
||||
})
|
||||
|
||||
test('plugin mounts into the DSH shell and survives a built-in tab sweep', async ({ page }) => {
|
||||
const pageErrors: string[] = []
|
||||
const consoleErrors: string[] = []
|
||||
page.on('pageerror', (error) => pageErrors.push(String(error)))
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') consoleErrors.push(message.text())
|
||||
})
|
||||
|
||||
// Load the shell. The app renders into #root; the plugin appends its own
|
||||
// [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 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 })
|
||||
// The unified panel host: the fixed containing block every panel lives in
|
||||
// (data-dsh-panel-host). Its presence is part of the injection contract.
|
||||
await expect(page.locator('[data-dsh-panel-host]')).toBeAttached({ timeout: 90_000 })
|
||||
|
||||
// A keyless boot stacks onboarding takeovers that mask the whole shell: a
|
||||
// versioned welcome notice ("Continue", persists its acknowledgement to
|
||||
// settings) and, once that is acknowledged, a provider-config dialog
|
||||
// ("Configure later", session-only — always present while no credential is
|
||||
// configured). Both mount only after the settings join resolves. Wait
|
||||
// (bounded) for one to appear; a DSH build without onboarding proceeds
|
||||
// straight to the sweep.
|
||||
try {
|
||||
await expect
|
||||
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
|
||||
.toBeGreaterThan(0)
|
||||
} catch {
|
||||
console.warn('[e2e] no onboarding takeover appeared; proceeding without dismissal')
|
||||
}
|
||||
// Dismiss whatever takeover is present, in any stacking order, until none
|
||||
// remain — a masked click is retried next round instead of failing.
|
||||
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 the takeover stacked above it; the next round tries the
|
||||
// other button first.
|
||||
}
|
||||
}
|
||||
if (!dismissed) break
|
||||
}
|
||||
|
||||
// The seeded session must give the sidebar a session scope: without it the
|
||||
// shell renders a disabled toggle cluster and the tab sweep is impossible.
|
||||
const tabBar = sidebar.locator('[title]')
|
||||
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.
|
||||
const assertNoCrash = async (): Promise<void> => {
|
||||
await expect
|
||||
.poll(async () => pageErrors, { timeout: 5_000 })
|
||||
.toEqual([])
|
||||
// Fail with the actual strip text so a regression is diagnosable from
|
||||
// the test report alone (a strip renders the client fail() message).
|
||||
const stripTexts = await sidebar.locator('div').evaluateAll(
|
||||
(nodes, patterns) => nodes.filter((node) => {
|
||||
const text = (node.textContent ?? '').trim()
|
||||
return patterns.some((pattern) => pattern.test(text))
|
||||
}).map((node) => (node.textContent ?? '').trim()),
|
||||
CRASH_STRIP_PATTERNS,
|
||||
)
|
||||
expect(stripTexts, 'a dsh-better-sidebar error strip is present in the sidebar').toEqual([])
|
||||
}
|
||||
|
||||
// Sweep every built-in tab through the "+" menu (the sidebar's own open-tab
|
||||
// affordance, reachable from any pane state). Each open may fetch a lazy
|
||||
// chunk (/sidebar/bundle/client-terminal.js / client-editor.js) and mount a
|
||||
// real viewer — the highest-risk crash surfaces. The pinned plugin must
|
||||
// offer every listed built-in: a missing or renamed descriptor is a real
|
||||
// regression and fails the lane loudly instead of silently narrowing the
|
||||
// sweep. A failure anywhere surfaces as a pageerror or a console error,
|
||||
// both of which the next assertion sees.
|
||||
const newTabButton = sidebar.getByRole('button', { name: 'New tab' }).first()
|
||||
for (const title of BUILTIN_TABS) {
|
||||
await newTabButton.click()
|
||||
const item = page.getByRole('menuitem', { name: title }).first()
|
||||
await expect(item, `built-in tab "${title}" is not offered by the + menu — descriptor removed or its label changed`).toHaveCount(1)
|
||||
await item.click()
|
||||
// Let the activation commit (including any lazy-chunk fetch) before the
|
||||
// crash assertions run.
|
||||
await page.waitForTimeout(1_500)
|
||||
await assertNoCrash()
|
||||
}
|
||||
|
||||
// The editor chunk (client-editor.js) only loads when a files-window tab
|
||||
// renders. Exercise the file-open path explicitly through the Files window's
|
||||
// own tree: the seeded home tab ("Files") is already open with its tree
|
||||
// panel pinned — activate it from the tab strip, open the seeded file, and
|
||||
// require the chunk round-trip (armed before goto), so a missing/corrupt
|
||||
// editor chunk fails the lane.
|
||||
// Tab-strip tabs carry `draggable="true"`; the always-mounted (hidden)
|
||||
// bottom panel's empty-pane welcome cards repeat the + menu labels with
|
||||
// `title="Files"`, so a bare `[title="Files"]` match is ambiguous.
|
||||
const filesTab = sidebar.locator('[title="Files"][draggable="true"]').first()
|
||||
await expect(filesTab, 'the seeded files-window home tab must be in the tab strip').toHaveCount(1)
|
||||
await filesTab.click()
|
||||
// Inactive tabs stay mounted (display:none); only the ACTIVE files
|
||||
// window's tree is visible — match the visible row.
|
||||
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
|
||||
// Separate-mode default (editorExplorer off): the tree click OPENS A NEW
|
||||
// file tab (openSidebarFile, id `editor:<path>`) instead of rewriting the
|
||||
// home tab in place. The seeded "Files" home tab stays put — it is the
|
||||
// standalone explorer now, not a file window.
|
||||
await expect(
|
||||
sidebar.locator(`[title="${SEEDED_FILE}"][draggable="true"]`),
|
||||
'separate mode opens a new file tab for the tree click',
|
||||
).toHaveCount(1)
|
||||
// The seeded home tab survives (separate mode never rewrites it). The
|
||||
// sweep's + menu opened a SECOND path-less Files window (each is its own
|
||||
// explorer in separate mode), so assert presence, not an exact count.
|
||||
await expect(
|
||||
sidebar.locator('[title="Files"][draggable="true"]').first(),
|
||||
'the seeded files-window home tab must survive the file open',
|
||||
).toHaveCount(1)
|
||||
const pathInput = sidebar.locator('input[placeholder^="File path"]:visible')
|
||||
await expect(pathInput, 'the file tab header path input shows the opened file').toHaveValue(new RegExp(`${SEEDED_FILE}$`))
|
||||
await page.waitForTimeout(1_500)
|
||||
await assertNoCrash()
|
||||
|
||||
// The mermaid chunk (client-mermaid.js) only loads when a previewed
|
||||
// markdown file contains a mermaid fence. Open the seeded diagram file
|
||||
// from the files window's tree and require the full round-trip: chunk
|
||||
// fetch + sanitized SVG diagram in the preview, so a missing/corrupt
|
||||
// mermaid chunk or a broken render fails the lane. In separate mode the
|
||||
// tree click above activated the hello.txt tab, so switch back to the
|
||||
// Files explorer first (its tree is the only one visible while active).
|
||||
const mermaidChunk = page.waitForResponse(
|
||||
(response) => response.url().includes('/sidebar/bundle/mermaid.js'),
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
await sidebar.locator('[title="Files"][draggable="true"]').first().click()
|
||||
const mdRow = sidebar.locator(`[role="button"][title$="${SEEDED_MD_FILE}"]:visible`)
|
||||
await expect(mdRow, `the seeded "${SEEDED_MD_FILE}" file must appear in the files window's tree`).toHaveCount(1, { timeout: 30_000 })
|
||||
await mdRow.click({ position: { x: 8, y: 8 } })
|
||||
// Separate mode: the md file opens its own tab (like hello.txt above).
|
||||
await expect(
|
||||
sidebar.locator(`[title="${SEEDED_MD_FILE}"][draggable="true"]`),
|
||||
'separate mode opens a new tab for the markdown file',
|
||||
).toHaveCount(1, { timeout: 30_000 })
|
||||
// The markdown PREVIEW must render before the mermaid chunk can be
|
||||
// requested — this assertion separates a preview/render regression from a
|
||||
// chunk-loading one. (sidebar is already scoped to [data-dsh-better-sidebar].)
|
||||
await expect(
|
||||
sidebar.getByText('tail text'),
|
||||
'the markdown preview must render the seeded document',
|
||||
).toHaveCount(1, { timeout: 30_000 })
|
||||
await mermaidChunk
|
||||
await expect(
|
||||
sidebar.locator('[data-mermaid-diagram] svg'),
|
||||
'the mermaid fence must render into an SVG diagram in the markdown preview',
|
||||
).toHaveCount(1, { timeout: 30_000 })
|
||||
// Labels must survive as real SVG <text> (htmlLabels stays off so the
|
||||
// sanitizer's foreignObject strip cannot eat the node text).
|
||||
await expect(
|
||||
sidebar.locator('[data-mermaid-diagram]').first(),
|
||||
'the diagram node labels must render inside the SVG',
|
||||
).toContainText('Hello', { timeout: 30_000 })
|
||||
// Cross-fence semantics: the reference-style link [before][shared] must
|
||||
// resolve to the definition that sits AFTER the fence — proof that the
|
||||
// preview is a single markdown parse and not per-fence fragments.
|
||||
await expect(
|
||||
sidebar.locator('a[href="https://example.com"]').first(),
|
||||
'reference-style links with definitions across a mermaid fence must resolve',
|
||||
).toContainText('before', { timeout: 30_000 })
|
||||
// Click-to-enlarge: clicking the diagram opens the zoom modal (portalled
|
||||
// to document.body), Esc closes it again.
|
||||
const modal = page.locator('[data-mermaid-modal]')
|
||||
await sidebar.locator('[data-mermaid-diagram] svg').first().click()
|
||||
await expect(modal, 'clicking the diagram must open the zoom modal').toHaveCount(1, { timeout: 10_000 })
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(modal, 'Esc must close the zoom modal').toHaveCount(0, { timeout: 10_000 })
|
||||
// The preview/edit toggle is mutually exclusive: in preview mode the
|
||||
// CodeMirror surface must be hidden (regression guard — a stale css copy
|
||||
// in the page made the editor stay visible under the preview, breaking
|
||||
// the toggle semantics).
|
||||
await expect(
|
||||
sidebar.locator('.cm-editor').first(),
|
||||
'preview mode must hide the CodeMirror editor (mutually exclusive toggle)',
|
||||
).toBeHidden()
|
||||
await assertNoCrash()
|
||||
|
||||
// The plugin's own console prefix must never appear in errors, and no
|
||||
// unhandled rejection may escape the sweep.
|
||||
const pluginErrors = consoleErrors.filter((text) => /dsh-better-sidebar|Unhandled/.test(text))
|
||||
expect(pluginErrors, 'plugin-prefixed or unhandled console errors during the sweep').toEqual([])
|
||||
expect(pageErrors, 'pageerrors during the sweep').toEqual([])
|
||||
|
||||
// Final screenshot: the rendered panel with a session is the lane's proof.
|
||||
await page.screenshot({ path: 'test-results/mount-final.png' })
|
||||
})
|
||||
|
||||
test('desktop shell stamps auto-enable the win32 title-bar compatibility mode', async ({ page }) => {
|
||||
// The official DSH Desktop shell stamps every render URL with
|
||||
// dsh-desktop-mode / dsh-desktop-platform (and exposes a preload marker).
|
||||
// The win32 advanced shell reserves a 32px overlay for the window
|
||||
// controls where the toggle cluster sits — the sidebar must auto-drop
|
||||
// below it (body[data-dsh-title-bar-compat]) without any manual pref.
|
||||
await page.goto(`${BASE_URL}?dsh-desktop-mode=advanced&dsh-desktop-platform=win32`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 })
|
||||
await expect(
|
||||
page.locator('body[data-dsh-title-bar-compat]'),
|
||||
'win32 advanced shell must auto-enable title-bar compatibility',
|
||||
).toBeAttached({ timeout: 90_000 })
|
||||
// The strip variable must be sized to the shell's 32px overlay.
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-title-bar-strip')))
|
||||
.toBe('32px')
|
||||
})
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* 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: separate mode;
|
||||
* merged-mode scenarios re-enable editorExplorer explicitly). */
|
||||
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()
|
||||
store.setPrefs({ ...store.getPrefs(), editorExplorer: true })
|
||||
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()
|
||||
store.setPrefs({ ...store.getPrefs(), editorExplorer: true })
|
||||
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()
|
||||
store.setPrefs({ ...store.getPrefs(), editorExplorer: true })
|
||||
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()
|
||||
store.setPrefs({ ...store.getPrefs(), editorExplorer: true })
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
# Fixture aggregate bundle: mounts dsh-better-sidebar under its own entry id,
|
||||
# the way real aggregate bundles (e.g. @linxin666/dsh-web-ui-all) do. Used by
|
||||
# scripts/e2e-aggregate-mount.sh to prove the plugin's own bundle patch backs
|
||||
# off when another enabled entry already mounts the same package.
|
||||
- insert:
|
||||
- id: aggregate-better-sidebar
|
||||
name: 'dsh-better-sidebar'
|
||||
@@ -0,0 +1,4 @@
|
||||
// No-op plugin body. This fixture only carries a patch layer (see
|
||||
// cordis.patch.yml); no entry ever references this package by name, but the
|
||||
// main file keeps npm pack's manifest valid.
|
||||
export function apply() {}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "fixture-aggregate-better-sidebar",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"files": [
|
||||
"lib",
|
||||
"cordis.patch.yml"
|
||||
],
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolve } from 'node:path'
|
||||
import { decodeHtmlUrl, encodeHtmlUrl } from '../src/html-route.ts'
|
||||
import { isWin32 } from './platform.ts'
|
||||
|
||||
describe('encodeHtmlUrl', () => {
|
||||
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')
|
||||
})
|
||||
|
||||
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', () => {
|
||||
expect(encodeHtmlUrl('s-1', '/a b/中文/100%.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,
|
||||
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
|
||||
// '/C:/...' form node's resolve() produced 'C:\C:\Users\...' (drive
|
||||
// doubled) on Windows and the isWithin(cwd) fence rejected every drive
|
||||
// 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', () => {
|
||||
@@ -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('../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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* External-link interception policy tests: the pure decision (which http(s)
|
||||
* external links are taken over into the sidebar browser) and the click
|
||||
* shape (plain left clicks only — modified clicks always bypass).
|
||||
* external links are taken over into the sidebar browser), the click shape
|
||||
* (plain left clicks only — modified clicks always bypass), and the
|
||||
* registered click capture (the caller's `takeoverEnabled(url)` gate).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isPlainLeftClick, shouldInterceptLink } from '../src/client/link-intercept.ts'
|
||||
import { isPlainLeftClick, registerLinkInterception, shouldInterceptLink } from '../src/client/link-intercept.ts'
|
||||
|
||||
const SELF = 'http://127.0.0.1:3080'
|
||||
|
||||
@@ -55,3 +57,66 @@ describe('isPlainLeftClick', () => {
|
||||
expect(isPlainLeftClick({ ...plain, button: 2 })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerLinkInterception', () => {
|
||||
/** Dispatch one click on an anchor with the given absolute href. */
|
||||
const clickAnchor = (href: string, init: MouseEventInit = {}): void => {
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = href
|
||||
document.body.appendChild(anchor)
|
||||
anchor.dispatchEvent(new MouseEvent('click', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
...init,
|
||||
}))
|
||||
anchor.remove()
|
||||
}
|
||||
|
||||
it('passes the parsed URL to takeoverEnabled and only takes over when it returns true', () => {
|
||||
const seen: string[] = []
|
||||
const opened: string[] = []
|
||||
const dispose = registerLinkInterception({
|
||||
takeoverEnabled: (url) => {
|
||||
seen.push(url.protocol)
|
||||
return url.protocol === 'http:'
|
||||
},
|
||||
openInSidebar: (url) => { opened.push(url) },
|
||||
selfOrigin: SELF,
|
||||
})
|
||||
// https → the gate refuses (the sidebar-takeover defaults: https off).
|
||||
clickAnchor('https://example.com/page')
|
||||
// http → the gate passes, the click is taken over with the absolute href.
|
||||
clickAnchor('http://example.com/page')
|
||||
expect(seen).toEqual(['https:', 'http:'])
|
||||
expect(opened).toEqual(['http://example.com/page'])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('never consults the gate for same-origin or non-http(s) links', () => {
|
||||
let gateCalls = 0
|
||||
const dispose = registerLinkInterception({
|
||||
takeoverEnabled: () => { gateCalls++; return true },
|
||||
openInSidebar: () => { throw new Error('must not open') },
|
||||
selfOrigin: SELF,
|
||||
})
|
||||
clickAnchor(`${SELF}/settings`)
|
||||
clickAnchor('mailto:a@b.c')
|
||||
expect(gateCalls).toBe(0)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('bypasses the takeover on modified clicks even when the gate passes', () => {
|
||||
let gateCalls = 0
|
||||
const dispose = registerLinkInterception({
|
||||
takeoverEnabled: () => { gateCalls++; return true },
|
||||
openInSidebar: () => { throw new Error('must not open') },
|
||||
selfOrigin: SELF,
|
||||
})
|
||||
clickAnchor('http://example.com/', { ctrlKey: true })
|
||||
clickAnchor('http://example.com/', { metaKey: true })
|
||||
clickAnchor('http://example.com/', { button: 1 })
|
||||
expect(gateCalls).toBe(0)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -50,9 +50,7 @@ const CLIENT_REQUIRE_ALLOWED = new Set([
|
||||
'react-dom/client',
|
||||
'cordis',
|
||||
'@deepseek-ai/dsh-client-ui-slots',
|
||||
'@deepseek-ai/dsh-client-web-react',
|
||||
'@deepseek-ai/dsh-client-ui-primitives',
|
||||
'@deepseek-ai/dsh-client-schema-form',
|
||||
'@deepseek-ai/dsh-client-runtime/client',
|
||||
])
|
||||
|
||||
@@ -65,7 +63,7 @@ function bundleId(file: string): string {
|
||||
}
|
||||
|
||||
/** The lazy chunk bundle names (mirror of src/bundle-route.ts CHUNK_NAMES). */
|
||||
const CHUNK_FILES = ['terminal', 'editor'].map(name => `lib/client-${name}.js`)
|
||||
const CHUNK_FILES = ['terminal', 'editor', 'mermaid'].map(name => `lib/client-${name}.js`)
|
||||
|
||||
/** The global registry slot a built chunk script assigns (its factory key). */
|
||||
function chunkSlot(file: string): string {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user