Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef8d3a9050 | ||
|
|
9a40164d1a | ||
|
|
29b8615bff | ||
|
|
b7aab48deb | ||
|
|
7dbd9b75e2 | ||
|
|
cdda072973 | ||
|
|
646c91cf70 | ||
|
|
55ad7eec8d | ||
|
|
d38a411f6d | ||
|
|
940d0a8928 | ||
|
|
f0965e1d61 | ||
|
|
f268d35f8b | ||
|
|
ea70dc97eb | ||
|
|
b8e0840f3f | ||
|
|
3004604a2d | ||
|
|
2d2b42722a | ||
|
|
c62ba4a565 | ||
|
|
0fdf63b081 | ||
|
|
6cea4bdd6f | ||
|
|
a9d3451415 | ||
|
|
0da2812dd3 | ||
|
|
7f4f84c4cf | ||
|
|
50a888845f | ||
|
|
4e52923970 | ||
|
|
bb2456bea9 | ||
|
|
5cc1a4b52e | ||
|
|
e8d04980b4 | ||
|
|
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 | ||
|
|
4e275aa321 | ||
|
|
ef8a707da8 | ||
|
|
83a658efd4 | ||
|
|
cba7e77896 | ||
|
|
e484556c1e | ||
|
|
7d524724ae | ||
|
|
c0aeefc936 | ||
|
|
ea250c9e87 | ||
|
|
9b87c1da16 | ||
|
|
001e86b1e1 | ||
|
|
277e7d3b40 | ||
|
|
58999c2f3b | ||
|
|
2bcfdfce87 | ||
|
|
cbc28473a0 | ||
|
|
1b0b382edc | ||
|
|
0995a58454 | ||
|
|
319d34ed09 | ||
|
|
ced3a1eade | ||
|
|
82a69546d2 | ||
|
|
288a499c7f | ||
|
|
c70d868c58 | ||
|
|
61e31321d6 | ||
|
|
9aeb0464ce | ||
|
|
dff1595e77 | ||
|
|
28d20a46c0 | ||
|
|
10fdeae409 | ||
|
|
562bc96f7e | ||
|
|
9c8bdc4aa5 | ||
|
|
bd49fcc3cd | ||
|
|
c8fc6c7cbf | ||
|
|
6b4fed1010 | ||
|
|
20bb60dd03 | ||
|
|
df1ab1c65f | ||
|
|
131398efe1 | ||
|
|
09de13c7bc | ||
|
|
77ed1ba437 | ||
|
|
d94465c28d | ||
|
|
387992a13f | ||
|
|
52341a9d60 | ||
|
|
cde107ce9e | ||
|
|
9642e95e89 | ||
|
|
0c254a2197 | ||
|
|
c5fda3716d | ||
|
|
37d91befdb | ||
|
|
d83490c93e | ||
|
|
96064c31a9 | ||
|
|
047f48337d | ||
|
|
1f9af78c3c | ||
|
|
efb2e2bd58 | ||
|
|
97e52b650e | ||
|
|
3cbf43854b | ||
|
|
e128c303bc | ||
|
|
8bf6d1d7ba | ||
|
|
0f9af851ae | ||
|
|
3d88752eb1 | ||
|
|
1b7c8ba497 | ||
|
|
5a93f610d8 | ||
|
|
9fdb44d9f9 | ||
|
|
e515d74f77 | ||
|
|
0a07cff6a3 | ||
|
|
a85d1ab004 | ||
|
|
b9f8eea56d | ||
|
|
72ba010ab7 | ||
|
|
1b7014b2d4 | ||
|
|
64c6aa477e | ||
|
|
5f6d4c97d5 | ||
|
|
58d6167b69 | ||
|
|
23b1b564d7 | ||
|
|
b31a9a601f | ||
|
|
2e9db44a71 | ||
|
|
1bfabbbfc2 | ||
|
|
ce052faee8 | ||
|
|
a2bb04bce0 | ||
|
|
422d5e3c10 | ||
|
|
9c5293c94b | ||
|
|
983d2b2d71 | ||
|
|
6e4474cd2e | ||
|
|
88f116338a | ||
|
|
b90251cce5 | ||
|
|
8c521be26e | ||
|
|
27bc49830e | ||
|
|
96b83ae3b8 | ||
|
|
cb9a7cf751 | ||
|
|
ffc2270d77 | ||
|
|
b391ebffed | ||
|
|
bab659f8bd |
@@ -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.1-rc.1 + Chrome 151(macOS)。
|
||||
Example: DSH 0.1.1-rc.1 + Chrome 151 (macOS).
|
||||
placeholder: "DSH 版本 / version + 浏览器 / browser(如 0.1.1-rc.1 + 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
|
||||
@@ -0,0 +1,114 @@
|
||||
name: CI
|
||||
|
||||
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:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
# Tests run git log against this repository and need the full history.
|
||||
fetch-depth: 0
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 11
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
# The plugin's git revert/cherry-pick spawn git without setting an
|
||||
# identity (deliberate, see tests/smoke.spec.ts) and rely on ambient
|
||||
# config; 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: Typecheck
|
||||
run: pnpm typecheck
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
# The consumer-facing declaration surface guard: a browser-only plugin
|
||||
# (no @types/node, skipLibCheck: false) must type-check against the
|
||||
# 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.1-rc.2
|
||||
|
||||
# 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
|
||||
@@ -146,3 +146,11 @@ lib/
|
||||
# plugin-registry install staging (assembled by scripts/package-registry.mjs)
|
||||
registry/
|
||||
.npmrc
|
||||
|
||||
# stray npm cache scratch dir
|
||||
.npm-cache-tmp/
|
||||
|
||||
# Playwright headless-render lane (tests/e2e) artifacts
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
|
||||
@@ -6,6 +6,34 @@ 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.1-rc.2`(挂载冒烟验证基线;peer 下限保持 `^0.1.0-rc.8`,rc.8 与 0.1.1-rc.x 双向兼容)。`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. 服务定位
|
||||
|
||||
- **服务名**:`betterSidebar`(即 `ctx.betterSidebar`)
|
||||
@@ -25,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": {
|
||||
@@ -78,7 +106,16 @@ export function apply(ctx: Context): void {
|
||||
import type { TabDescriptor, FileViewerDescriptor, BetterSidebarService } from 'dsh-better-sidebar'
|
||||
```
|
||||
|
||||
类型定义在 `lib/types/client/service.d.ts`,通过 `package.json` 的 `./client/service`(别名 `./client/api`)exports 子路径暴露。
|
||||
类型定义在 `lib/types/client/service.d.ts`,通过 `package.json` 的 `./client/service`(别名 `./client/api`)exports 子路径暴露。v0.12.0 起服务模块还 re-export 了完整的状态词汇表,消费者可以直接命名(不再只能靠推断):
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SidebarTab, SidebarState, SidebarStore, SidebarSnapshot, SidebarDiffRef, TabType,
|
||||
SessionScope, SidebarPrefs, OpenTabSeed, SidebarSettingsRenderProps,
|
||||
} from 'dsh-better-sidebar/client/service'
|
||||
```
|
||||
|
||||
> 💡 **类型合并触发路径**:`import type {} from 'dsh-better-sidebar/client/service'` 同样会加载 `Context` 的 augmentation(`declare module 'cordis'` 在 context-types.d.ts 中,service 声明会拉入它)——纯浏览器侧插件建议走 `client/service` 路径,避免拉进宿主半的 Node 类型图(主入口 `dsh-better-sidebar` 的声明面含宿主代码,宿主消费者本就处于 Node 环境)。client 可达声明图(`client/*` + context-types + html-route + prefs-shared)自 v0.12.0 起**零 Node 依赖**(`scripts/check-consumer-types.sh` 守护),无 `@types/node`、`skipLibCheck: false` 也能编译。
|
||||
|
||||
---
|
||||
|
||||
@@ -94,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
|
||||
@@ -108,28 +145,117 @@ 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
|
||||
dedupeKey?: (tab: SidebarTab) => string | undefined // 必须保持纯函数:每次 open 会求值两次,抛错会向外传播
|
||||
/**
|
||||
* 自定义 tab 创建(minting SidebarTab + 状态 patch)。
|
||||
* 返回 null 拒绝创建。terminal 用它生成 terminal:<n> id 并递增 nextTerminal。
|
||||
* 省略时用默认 { 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 字段。嵌套开关仅父级启用时显示。
|
||||
* 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套设置行,
|
||||
* 绑定 SidebarPrefs 字段。嵌套设置仅父级启用时显示(v0.11.0 起行控件不限于
|
||||
* 布尔开关:`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' / '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' | 'select'
|
||||
/** number 行的提交钳制下限。 */
|
||||
min?: number
|
||||
/** number 行的提交钳制上限。 */
|
||||
max?: number
|
||||
/** text 行的输入占位符。 */
|
||||
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 字段。 */
|
||||
pluginToggles?: readonly {
|
||||
key: string
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
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、
|
||||
* updatePluginSetting(key, value) 与 close()。抛错会被吞掉并显示内联错误。 */
|
||||
render?: (props: {
|
||||
store: SidebarStore
|
||||
service: BetterSidebarService
|
||||
prefs: SidebarPrefs
|
||||
pluginSettings: Record<string, unknown>
|
||||
updatePluginSetting: (key: string, value: unknown) => void
|
||||
close: () => void
|
||||
}) => ReactNode
|
||||
}
|
||||
/**
|
||||
* tab 角标(v0.12.0+):tab 图标旁的小圆角 pill。number 渲染计数(99+ 封顶),
|
||||
* string 原样文本,null/undefined 不显示。每次 tab 栏渲染都会调用——保持廉价;
|
||||
* 抛错会被吞掉(不显示角标,不影响渲染)。
|
||||
*/
|
||||
badge?: (ctx: Context, scope: SessionScope, state: SidebarState) => string | number | null | undefined
|
||||
/**
|
||||
* 生命周期回调(v0.12.0+),只由 SERVICE 路径触发:
|
||||
* - onOpen:openTab 真正**新建** tab 后(dedupe/id 安全网聚焦**不算**打开);
|
||||
* - onActivate:tab 被聚焦时(dedupe 聚焦、id 安全网聚焦、tab 栏点击激活);
|
||||
* - onClose:closeTab 关闭 tab 后。
|
||||
* 内置专属流程(diff 拆分放置、agent 终端 reconcile)直接改 state,不触发回调——
|
||||
* 但它们只作用于内置类型(diff/terminal),外部插件的 tab 永远走 service 路径。
|
||||
* 回调抛错只 console.error,绝不打断打开/关闭流程。openTab 的回调 scope
|
||||
* 携带调用者传入的 { sessionId, cwd? }(+ 菜单路径带 cwd;无 scope 的自动
|
||||
* 打开路径只有 sessionId);closeTab/activateTab 的回调 scope 只在显式传入
|
||||
* scope 参数时带 cwd。
|
||||
*/
|
||||
onOpen?: (tab: SidebarTab, scope: SessionScope) => void
|
||||
onActivate?: (tab: SidebarTab, scope: SessionScope) => void
|
||||
onClose?: (tab: SidebarTab, scope: SessionScope) => void
|
||||
/** 渲染函数 */
|
||||
component: (props: TabComponentProps) => ReactNode
|
||||
}
|
||||
@@ -145,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
|
||||
@@ -194,21 +320,40 @@ ctx.effect(() =>
|
||||
title: 'Commits',
|
||||
icon: <CommitIcon />,
|
||||
order: 70,
|
||||
available: (state) => hasGitRepo(state), // 返回 false 时 + 菜单显示为 disabled
|
||||
available: (ctx, scope, state) => hasGitRepo(state), // 三参 (ctx, scope, state);返回 false 时 + 菜单显示为 disabled
|
||||
// 注意:available 只影响 + 菜单的 disabled 状态,不会拒绝 openTab(只有设置页的禁用开关会)。
|
||||
dedupeKey: () => 'my-plugin:commits',
|
||||
component: ({ scope }) => <CommitsView sessionId={scope.sessionId} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
**认领外链点击**(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 | 是 | 否 | 子代理拓扑 |
|
||||
| `sidechat` | 35 | 否(createTab 铸造 `sidechat:<uuid>`/按 `meta.threadId` 去重) | 否 | 侧边对话(Codex 风格,**每个对话一个独立 Tab**):打开 Tab 即自动创建空线程(composer 拥有首条消息,host 侧包裹边界提示 + 创建时停泊的进行中快照,首条消息赢得真实标签并同步 Tab 标题);线程 = 插件自建子会话(自定义种子继承父会话完整上下文,进行中回合以 `interrupted` 冻结诚实闭合;种子尾部带合法 `subagent/descriptor`——否则 cold 线程在宿主 subagents.list 里是 corrupt 诊断行——SubagentView/subagent-detect 按 `Side: ` 前缀过滤保持拓扑零噪音),`origin:'subagent'` 隐藏于主列表;生命周期走自有 `/sidebar/api/sidechat.*` 路由(`ctx.agents.create/resume` + `agent.followup/cancel` + `sidechat.info` 读 Agent 身份/状态);头部菜单可切换/重开既有线程(`parkSidechatReopen` + `sidechat:<threadId>` 确定性 id),关闭 Tab 释放 live agent(历史保留);重开 Tab 经 `collectOwnEvents` 大页回源到种子边界(cold 读会展开 chunk 压缩行,小窗口会丢早期 tool/call 行);「保存为新会话」= `session.fork` 提升顶层会话(必须方法调用形态,`this` 敏感)。UI 对齐主对话区(用户气泡 `--dsw-specific-bubble`、assistant 通栏 markdown、胶囊 composer + 圆形发送/停止钮、运行扫光状态行)。见 [设计文档](docs/plans/2026-08-20-sidechat-tab-design.md) |
|
||||
| `terminal` | 40 | 否 | 否 | 终端(nextTerminal 自增) |
|
||||
| `browser` | 50 | 否(createTab 铸造 browser:`<n>`,nextBrowser 自增) | 否 | 内嵌网页浏览器(沙箱 iframe;可设置关闭沙箱) |
|
||||
| `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(由 GitView 触发) |
|
||||
@@ -237,8 +382,9 @@ interface FileViewerDescriptor {
|
||||
fetchStrategy: 'none' | 'fsRead' | 'mediaUrl' | 'custom' | 'binary-download'
|
||||
/** 内容嗅探(覆盖 exts):head 字节可用时,第一个 detect 返回 true 的 viewer 命中 */
|
||||
detect?: (path: string, head: Uint8Array) => boolean
|
||||
/** fetchStrategy='custom' 时的加载函数 */
|
||||
load?: (path: string, scope: SessionScope) => Promise<unknown>
|
||||
/** fetchStrategy='custom' 时的加载函数;v0.12.0+ 第三参 signal 在 viewer
|
||||
* 卸载/重匹配时中止(忽略 signal 的 load 也照常工作) */
|
||||
load?: (path: string, scope: SessionScope, signal?: AbortSignal) => Promise<unknown>
|
||||
/** 声明式设置(v0.4.1+):形状同 TabDescriptor.settings */
|
||||
settings?: { toggles?: readonly { key: string; title: string | (() => string); desc?: string | (() => string) }[] }
|
||||
/** 渲染函数 */
|
||||
@@ -260,6 +406,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
|
||||
}
|
||||
```
|
||||
|
||||
@@ -269,7 +420,7 @@ interface FileViewerProps {
|
||||
|---|---|---|---|
|
||||
| `none` | 不需要字节 | (无) | 自渲染(如纯 UI) |
|
||||
| `fsRead` | `/sidebar/api` 的 `fs.read` | `content`, `truncated` | 文本类(CSV/JSON/XML) |
|
||||
| `mediaUrl` | `/sidebar/file` 媒体路由 URL | `mediaUrl` | 图片/PDF/Office(viewer 自己 fetch 字节) |
|
||||
| `mediaUrl` | `/sidebar/file` 媒体路由 URL | `mediaUrl` | 图片/PDF(viewer 自己 fetch 字节) |
|
||||
| `custom` | viewer 的 `load()` 函数 | `customData` | 自定义协议(如远程拉取) |
|
||||
| `binary-download` | 不预览,显示下载按钮 | (无) | 无客户端渲染器的二进制格式 |
|
||||
|
||||
@@ -284,7 +435,7 @@ interface FileViewerProps {
|
||||
|
||||
> **head 字节从哪来**:第一次匹配(纯扩展名)没有 head。`fsRead` 策略读取后若文件为二进制,host 的 `fs.read` 响应会带 `head` 字段(base64,前 4KB),编辑器会用它对 `detect` viewer **重匹配一次**——所以 detect 型 viewer 的实际触发场景是"扩展名匹配落空/二进制文件"。文本文件的 detect 嗅探不在内置流程内(用 `exts` 或 `custom` 策略替代)。
|
||||
|
||||
> **内置 viewer**(不可重复注册,全部 9 个):image(0) / pdf(0) / docx(0) / xlsx(0) / pptx(0) / markdown(0, fsRead) / html(0, fsRead, 沙箱 iframe 预览) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。
|
||||
> **内置 viewer**(不可重复注册,全部 6 个):image(0) / pdf(0) / markdown(0, fsRead) / html(0, fsRead, 沙箱 iframe 预览) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。Office 三件套预览(.docx/.xlsx/.pptx)**不再内置**——已迁至推荐插件(设置页「添加插件」→ 文件预览弹窗里的 Office 预览插件),该插件以相同 id 经 `ctx.betterSidebar.registerFileViewer` 注册。
|
||||
> code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配被 binary-download 的 NUL detect 认领(下载按钮)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。
|
||||
|
||||
### 4.5 注册示例
|
||||
@@ -361,19 +512,64 @@ interface BetterSidebarService {
|
||||
* 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。
|
||||
* title 可选:给出时优先于 descriptor.title(editor 显示文件名);
|
||||
* 有 createTab 的 descriptor(terminal)会忽略 title/path/id。
|
||||
* url 可选:落地后把 tab 的 path 预填为 URL(侧边栏浏览器导航种子,
|
||||
* 通常配合 hostname title;对 createTab 铸造的 tab 同样生效)。
|
||||
* url 可选:把**新建** tab 的 path 预填为 URL(侧边栏浏览器导航种子,
|
||||
* 通常配合 hostname title;对 createTab 铸造的 tab 同样生效)。聚焦既有
|
||||
* tab 时 url 不会覆写其 path。
|
||||
* 被设置禁用的类型是 no-op(console.warn 提示)。
|
||||
* scope(v0.12.0+)定向到指定 session:给出且非当前 session 时,打开落在
|
||||
* 该 session 的侧边栏状态里(没有则按 prefs 新建),不切换 UI 的激活 session;
|
||||
* 定向打开**不自动展开**目标 session 的面板(用户看不见,展开无意义);
|
||||
* 缺省或指向当前 session 时行为与之前完全一致。注意:available 不拦截 openTab。
|
||||
* 内容型打开(带 path/url seed)必须落在视野内:承载落点 pane 的面板
|
||||
* 折叠时自动展开(右侧面板;落点 pane 在底部树则展开底部面板;窄视口
|
||||
* 展开合并抽屉);类型型打开(+ 菜单、agent 终端自动补 tab)不展开。
|
||||
*/
|
||||
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void
|
||||
/** 关闭一个 tab */
|
||||
closeTab(tabId: string): void
|
||||
openTab(seed: OpenTabSeed, scope?: SessionScope): void
|
||||
/** 关闭一个 tab(未知 id 严格 no-op,无状态搅动);scope(v0.12.0+)
|
||||
* 随回调传递(含可选 cwd),缺省为 { sessionId: 当前 } */
|
||||
closeTab(tabId: string, scope?: SessionScope): void
|
||||
/** 订阅注册表变化(register/dispose 时触发) */
|
||||
subscribe(listener: () => void): () => void
|
||||
// ── v0.12.0+ ──────────────────────────────────────────────────────────
|
||||
/** 插件版本(如 '0.12.0';与 package.json 同步,测试守护) */
|
||||
readonly version: string
|
||||
/** 单调能力清单(只增不删):'badge' | 'tabLifecycle' | 'updateTab' |
|
||||
* 'openFile' | 'targetedOpen' | 'stateSubscription' | 'tabMeta' |
|
||||
* 'pluginSettings' | 'urlTarget' | 'settingSelect'——消费插件用 `features.includes('xxx')` 按能力 gate。 */
|
||||
readonly features: readonly string[]
|
||||
/** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。
|
||||
* session 未激活时 state/sessionId 为 undefined。 */
|
||||
getSnapshot(): SidebarSnapshot
|
||||
/** 订阅快照变化(会话切换/状态变更/prefs 写入);返回 disposer */
|
||||
subscribeState(listener: () => void): () => void
|
||||
/** 更新一个已打开 tab 的显示字段(title/path/meta);tab 不存在时 no-op */
|
||||
updateTab(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void
|
||||
/** 激活一个已打开的 tab(tab 栏点击路径;触发 descriptor.onActivate;
|
||||
* 未知 id 严格 no-op);scope(v0.12.0+)随回调传递,同 closeTab */
|
||||
activateTab(tabId: string, scope?: SessionScope): void
|
||||
/** 在 scope.sessionId 的侧边栏编辑器打开一个文件(title 缺省为文件名;
|
||||
* id 按路径派生(`editor:` + path),与内置 open-path 拦截一致,不同文件
|
||||
* 可并排打开。注意:path 派生 id 只对 openFile/openSidebarFile 的打开成立——
|
||||
* 文件窗口的原地切换(editorExplorer 开)经 updateTab 重写 path/title,
|
||||
* tab id 保持稳定、不再与 path 对应) */
|
||||
openFile(scope: SessionScope, path: string, title?: string): void
|
||||
}
|
||||
|
||||
/** openTab 的 seed(v0.12.0 起导出命名类型) */
|
||||
interface OpenTabSeed {
|
||||
type: string
|
||||
title?: string
|
||||
path?: string
|
||||
diff?: SidebarTab['diff']
|
||||
id?: string
|
||||
url?: string
|
||||
/** JSON 可序列化的自定义状态,随 tab 持久化(刷新后原样恢复);
|
||||
* updateTab/seed 传 undefined 表示「不改」,传 null 可显式清除 */
|
||||
meta?: unknown
|
||||
}
|
||||
```
|
||||
|
||||
> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑(复选框行),父级卡片关闭时齿轮隐藏;**key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks`),外部插件的自定义键会被 settings seam 丢弃。
|
||||
> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,启用卡片最右端渲染紧凑品牌开关滑块,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`、终端的 `terminalFontFamily`/`terminalFontSize`)通过卡片底部的「功能设置」设置条(齿轮图标 + 文字标签,hairline 分隔)在**原生弹窗**中编辑——`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 起也有设置条)。
|
||||
|
||||
---
|
||||
|
||||
@@ -396,10 +592,40 @@ interface BetterSidebarService {
|
||||
| **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`;推挤锚点是**复合选择器**(同一元素双保险,禁止退回 `nth-child` 位置锚定):`#root [data-dsh-frame] > [data-pane="conversation"]` **与** `#root :has(> [data-slot="conversation"])`(0.1.x 命名 / rc.8 时代命名,live 页面实测同元素;`drag-layout.e2e.ts` 断言两者同元素)。
|
||||
- **桌面信号与标题栏兼容**(v0.14.1+ 四选项模型,取代旧"win32 advanced 自动 32px"硬编码):
|
||||
- 壳信号(只读报告,不自动触发任何修改):URL `dsh-desktop-mode` / `dsh-desktop-platform`(DSH Desktop 壳注入;preload 另暴露 `__DSH_DESKTOP_FILE_PATH__`);可选契约参数 `dsh-desktop-titlebar-inset`(壳声明自己预留的顶栏像素,0–120,clamp;`parseDesktopEnv().titlebarInset`)。
|
||||
- **strip 取值链**(`src/client/titlebar-strip.ts` 唯一决策点,纯函数):⓪ `web` 方案强制 0(显式 DSH官方Web,连 WCO 也不适用);① `navigator.windowControlsOverlay` 标准 API 真实几何(`src/client/wco.ts`,反应式订阅 `geometrychange`,最大化/还原实时更新——**即使为 0 也权威**,`visible=false` 的幽灵 API 视为缺失,覆盖 #257 的 32/36px 差异);② URL `dsh-desktop-titlebar-inset`;③ 壳预设 `stripFor`(仅 `preset` 方案);④ 手动 `titleBarStripPx`(仅 `custom` 方案);⑤ 0(网页零修改)。结果驱动 `body[data-dsh-title-bar-compat]` + `--dsh-title-bar-strip`(语义与旧布尔一致)。
|
||||
- **四选项设置**(`SidebarPrefs.titleBarScheme`,设置页主行**下拉**:`auto` 默认 / `web` / `preset` / `custom`):`auto` = 只信标准信号(WCO),壳 stamp 不触发任何修改——**"为某壳做的兼容在另一个壳会再坏",核心不做壳专属分支**;`web` = 显式「DSH官方Web」,任何情况强制 0;`preset` = 数据驱动的内置壳预设(`src/client/shell-presets.ts`,准入规则:issue/PR 中被提及且 GitHub ⭐>100;v1 仅 anywhere-labs DSH Desktop:darwin advanced 20px、win32 无 WCO 时 32px 兜底;下拉选项命中环境时带「已检测」后缀,仅提示、绝不自动启用);`custom` = 用户自定义 CSS + 手动 strip px(下拉选中后出现齿轮按钮,弹窗含下移距离与 CSS 输入)。
|
||||
- **旧文档迁移**:schema 中 `titleBarScheme` 无默认值(缺失才可迁移);旧文档**已有值**(`titleBarCompat === true` 或 `titleBarStripPx` 非默认 40)→ 迁移到 `custom` 方案保留用户数值;干净文档 → `auto`。
|
||||
- **用户空间 CSS**:预设 css(`<style data-dsh-preset-css>`)与自定义 css(`<style data-dsh-custom-css>`)经 `Sidebar.tsx` effect 注入到 `document.head` 末尾(后写胜出;覆盖 JS 内联变量需 `!important`),fiber 卸载/变更即移除。稳定寻址面(皮肤/预设/自定义 CSS 共用):`[data-dsh-toggle-cluster]`、`[data-dsh-panel]`(右面板)、`[data-dsh-bottom-panel]`(底面板)。
|
||||
- **拖拽区退出**:插件交互 chrome(`.toggleCluster` / `.toggleButton` / `.tabBar`)统一 `-webkit-app-region: no-drag`——无边框壳的顶部拖拽带会吞点击(#103/#111),该属性在普通浏览器与无拖拽区壳中惰性无害。
|
||||
- `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` 文件预览器。
|
||||
|
||||
@@ -414,7 +640,7 @@ interface BetterSidebarService {
|
||||
"./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"
|
||||
@@ -471,19 +697,21 @@ function CsvGrid(props: { rows: string[][]; path: string }): React.ReactNode { /
|
||||
function parseCsv(text: string): string[][] { /* ... */ }
|
||||
```
|
||||
|
||||
**注册到 profile**:在 `~/.dsh/profiles/web/package.json` 的 `dependencies` 加 `"my-plugin": "link:<你的插件路径>"`,在 `cordis.patch.yml` 加挂载行,`pnpm install`,重启 `dsh web` + 浏览器硬刷新。
|
||||
**注册到 profile**:在 `~/.dsh/profiles/web/package.json` 的 `dependencies` 加 `"my-plugin": "link:<你的插件路径>"`,在 `cordis.patch.yml` 加挂载行,`pnpm install`,浏览器硬刷新即可(DSH 对 client 改动热加载,无需重启 `dsh web`;仅 host 半改动需要重启)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 参考实现
|
||||
## 10. 参考实现
|
||||
|
||||
better-sidebar 自己的内置 tab 和 viewer 就是参考实现("吃狗粮"):
|
||||
|
||||
- **`src/client/builtins/`**:7 个内置 tab(explorer/git/subagent/terminal/browser/editor/diff)+ 9 个内置 viewer(image/pdf/docx/xlsx/pptx/markdown/html/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.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` 嵌套开关 + 开关持久化)
|
||||
- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套设置行:switch/text/number + 持久化)
|
||||
- **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating 测试
|
||||
- **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 9 viewer + 声明式元数据)
|
||||
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`**:设计文档(含实施偏差记录)
|
||||
- **`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,22 +1,410 @@
|
||||
# dsh-better-sidebar
|
||||
|
||||
> DeepSeek Harness Web GUI 的 **VSCode 风格侧边栏**插件:文件管理、编辑预览、内嵌浏览器、真实终端、Git 面板、Subagent 页面——**右侧栏 + 底部面板双工作台**,一个插件全部搞定。
|
||||
<!-- Hero -->
|
||||
<div align="center">
|
||||
<b style="font-size: 1.15em;">一个服务化的侧边栏框架,一套开箱即用的完整工作台</b><br /><br />
|
||||
<a href="https://www.npmjs.com/package/dsh-better-sidebar"><img alt="npm version" src="https://img.shields.io/npm/v/dsh-better-sidebar" /></a>
|
||||
<a href="https://www.npmjs.com/package/dsh-better-sidebar"><img alt="npm downloads" src="https://img.shields.io/npm/dm/dsh-better-sidebar" /></a>
|
||||
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/omdsh-dev/DSH-better-sidebar/actions/workflows/ci.yml/badge.svg" /></a>
|
||||
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/omdsh-dev/DSH-better-sidebar" /></a>
|
||||
<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 />
|
||||
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本:0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2" src="https://img.shields.io/badge/DSH-0.1.0--rc.8_%C2%B7_0.1.1--rc.1_%C2%B7_0.1.1--rc.2-4d6bfe" /></a>
|
||||
<a href="https://github.com/topics/dsh-better-sidebar"><img alt="插件生态:GitHub topic dsh-better-sidebar" src="https://img.shields.io/badge/%E6%8F%92%E4%BB%B6%E7%94%9F%E6%80%81-topic%20dsh--better--sidebar-4d6bfe" /></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" /> <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>
|
||||
|
||||
<img width="4632" height="2720" alt="image" src="https://github.com/user-attachments/assets/39d86636-7654-412f-86ea-c60a2d5f20f0" />
|
||||
<div align="center">
|
||||
🌏 <a href="./README.md"><b>中文</b></a> · <a href="./README_EN.md">English</a>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
## 📑 目录
|
||||
|
||||
- [✨ 功能一览](#-功能一览)
|
||||
- [🚀 安装](#-安装)
|
||||
- [🖼️ 特性巡礼](#-特性巡礼)
|
||||
- [🌐 插件生态](#-插件生态)
|
||||
- [🆕 最近更新](#-最近更新)
|
||||
- [⌨️ 快捷键](#-快捷键)
|
||||
- [🔌 服务化扩展](#-服务化扩展)
|
||||
- [🛠️ 开发与构建](#-开发与构建)
|
||||
- [🔐 安全](#-安全) · [⚠️ 已知限制](#-已知限制) · [🖥️ 平台支持](#-平台支持)
|
||||
- [💬 社区](#-社区) · [🤝 参与贡献](#-参与贡献) · [⭐ Star History](#-star-history) · [🔗 友情链接](#-友情链接)
|
||||
|
||||
## ✨ 功能一览
|
||||
|
||||
- **🗂️ 资源管理器**:懒加载目录树(根 = 会话 cwd)、点击在侧边栏打开、行尾 `@文件` 引用到输入框、右键复制路径
|
||||
- **📝 编辑与预览**:CodeMirror 6 多语言高亮 + Ctrl/Cmd+S 原子保存;图片 / Markdown(预览/编辑切换)/ HTML(沙箱 iframe 预览,相对资源可加载)/ PDF / Word / Excel / PPT 内联预览,切换 Tab 不丢草稿
|
||||
- **🌐 浏览器**:内嵌网页浏览 tab(多开),后退/前进/刷新 +「在浏览器中打开」;页面在**沙箱 iframe** 中运行(不透明源:无法访问界面数据与本地文件,拒绝 localhost 等本机地址),界面实时显示沙箱状态、可临时解锁(关闭时红色警示);被站点拒绝嵌入(X-Frame-Options)时显示原因面板;聊天/界面里的 http(s) 外链默认在侧边栏打开
|
||||
- **💻 终端**:xterm.js + node-pty 真实 shell(每会话 3 个 UI 上限)、Tab 保活重连回放;可选为模型注入 8 个 `terminal_*` 工具
|
||||
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、懒加载历史、右键暂存/放弃/提交/还原/捡取
|
||||
- **🧩 Subagent 页面**:主会话完整 agent 拓扑、点击直达执行记录、实时工具调用轮询、新子代理自动展开
|
||||
- **🪟 底部面板**:独立的第二个工作台(与右侧栏同类的标签页),只挤占中间 Agent 输出区、不覆盖左右侧边栏;**首次展开自动开一个新终端**(终端卡片二级设置可关);右上角 x 一键折叠
|
||||
- **🔧 分栏工作台**:拖 Tab 拆分/合并分栏(可**跨面板拖 Tab**)、分隔线调比例;右上角持久按钮簇(底栏 + 侧拉 glyph)折叠/展开两个面板;两面板共享拐角双向拖动调节尺寸,拖动 rAF 直写 DOM 保持流畅
|
||||
- **🔁 会话隔离**:布局/分栏/Tab/两面板状态按会话持久化(localStorage),陈旧状态自动净化;聊天「产出文件」改在侧边栏打开
|
||||
- **⚙️ 声明式设置**:设置页「侧边卡片」分区按注册表渲染功能清单(小卡片网格,高亮 = 启用),每项可独立开/关;二级设置(子代理自动展开、终端工具、底部面板首展自动开终端、沙箱开关等)经齿轮按钮在原生弹窗中编辑
|
||||
- **🔌 服务化**:暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏 tab 与文件预览器(内置 7 tab + 9 viewer 也走同一服务,详见 [AGENTS.md](./AGENTS.md))
|
||||
- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大)/ HTML / PDF
|
||||
- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整)
|
||||
- **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具
|
||||
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原
|
||||
- **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止)
|
||||
- **💬 侧边对话(beta)**:Codex 风格的侧边线程——继承主会话完整上下文(含进行中的回合与工具调用)独立运行,不进入主会话;线程内可持续追问,一键「保存为新会话」提升为顶层会话
|
||||
- **🪟 双工作台**:右侧栏 + 底部面板;拖 Tab 拆分 / 合并分栏(可跨面板),移动端自动合并全宽抽屉
|
||||
- **🔁 会话隔离**:布局 / Tab / 面板按会话持久化,陈旧状态自动净化
|
||||
- **⚙️ 声明式设置**:设置页「侧边卡片」逐项独立开关,二级设置经齿轮弹窗
|
||||
- **⚡ 按需加载**:启动只拉 ~325KB 核心,终端 / 编辑器 / Mermaid 图表等重依赖用到才按需拉取([设计文档](docs/plans/2026-08-12-lazy-chunks-design.md))
|
||||
- **🌏 多语言**:界面文案跟随 DSH 语言(zh / en)实时切换
|
||||
|
||||
> 🔌 **核心理念**:服务优先——内置的 7 tab + 6 viewer 与第三方插件通过同一套 `ctx.betterSidebar` API 注册,能力完全对等;官方不再内置、可由生态提供的功能,交由生态插件实现(已有 **28+ 生态插件**,见下方「🌐 插件生态」)。接入文档见「🔌 服务化扩展」与 [外部插件接入指南](./docs/external-plugin-guide.md)。
|
||||
|
||||
## 🚀 安装
|
||||
|
||||
**前置**:已装好 DSH(`dsh web` 能正常运行),Node.js ≥ 20、pnpm ≥ 10。
|
||||
|
||||
**支持的 DSH 版本**:
|
||||
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本:0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2" src="https://img.shields.io/badge/DSH-0.1.0--rc.8_%C2%B7_0.1.1--rc.1_%C2%B7_0.1.1--rc.2-4d6bfe" /></a>
|
||||
|
||||
```sh
|
||||
dsh plugin --profile web add dsh-better-sidebar@latest # 首次会因 pnpm 11 拦截 node-pty 构建脚本而失败(依赖已写入)
|
||||
cd ~/.dsh/profiles/web && pnpm approve-builds --all # 放行构建脚本(自动重跑安装)
|
||||
dsh plugin --profile web add dsh-better-sidebar@latest # 重跑即成功
|
||||
```
|
||||
|
||||
装完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可看到侧边栏(DSH 对 client 改动热加载,无需重启;仅 host 半更新时需要重启)。
|
||||
|
||||
**方式二:让 DSH 自己装**——把下面这段提示词发给任意一个 DSH 会话:
|
||||
|
||||
```text
|
||||
帮我安装 dsh-better-sidebar 插件(DSH 侧边栏工作台),步骤:
|
||||
1. 执行 dsh plugin --profile web add dsh-better-sidebar@latest(首次会被 pnpm 11 拦截 node-pty 构建脚本而失败,属正常)
|
||||
2. 在 ~/.dsh/profiles/web 下执行 pnpm approve-builds --all(放行构建脚本,会自动重跑安装)
|
||||
3. 再次执行 dsh plugin --profile web add dsh-better-sidebar@latest
|
||||
4. 完成后提醒我硬刷新浏览器(Cmd/Ctrl+Shift+R)
|
||||
遇到报错先查 https://github.com/omdsh-dev/DSH-better-sidebar README 的常见问题表。
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>更新</b></summary>
|
||||
|
||||
```sh
|
||||
dsh plugin --profile web add dsh-better-sidebar@latest
|
||||
```
|
||||
|
||||
也可把 `~/.dsh/profiles/web/package.json` 里的版本号改高后 `pnpm install`。改完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可(client 改动无需重启 DSH)。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>常见问题</b></summary>
|
||||
|
||||
| 现象 | 原因与解决 |
|
||||
|---|---|
|
||||
| 报 `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 ...`,删掉那段(同 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 版本一般已有预编译。 |
|
||||
| 终端提示「node-pty 加载失败」 | `node-pty` 安装缺失/损坏(如 pnpm 拦截了构建脚本)。终端横幅会给出修复命令:复制到 DSH 所在环境的终端/cmd 执行(在 `~/.dsh/profiles/web` 下 `pnpm approve-builds --all && pnpm rebuild node-pty`),完成后重启 DSH 并点重试。插件与 DSH 核心使用同一 `node-pty@^1.1.0`,修复后两者同步恢复。 |
|
||||
| 提示 `dsh: command not found` | 先安装 DSH;或直接用 `npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar@latest`。 |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>从源码安装 / 开发(可选,替代 npm 方式)</b></summary>
|
||||
|
||||
调试本地改动或跟随开发分支时,把依赖指向本地克隆并自行构建:
|
||||
|
||||
```text
|
||||
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 追加挂载行(需要指定终端 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.15.0"` 再 `pnpm install`。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>通过 plugin-registry 安装(可选,与上述二选一)</b></summary>
|
||||
|
||||
前置:DSH 已集成 [plugin-registry](https://github.com/dsh-external/plugin-registry)(`dsh registry` 可用)。**同时启用两个通道会双挂载**(Node 半挂两次、页面两个侧边栏)。
|
||||
|
||||
```sh
|
||||
git clone https://github.com/omdsh-dev/DSH-better-sidebar.git && cd DSH-better-sidebar
|
||||
pnpm install && pnpm build
|
||||
node scripts/package-registry.mjs # 组装 registry/ 暂存(含清单 + 产物 + README,不入库)
|
||||
dsh registry install ./registry # 安装(默认禁用)
|
||||
dsh registry enable dsh-external/dsh-better-sidebar
|
||||
```
|
||||
|
||||
更新:`git pull && pnpm install && pnpm build` → `node scripts/package-registry.mjs` → `dsh registry uninstall/install/enable`。切换通道前先移除另一通道的挂载。
|
||||
|
||||
</details>
|
||||
|
||||
## 🖼️ 特性巡礼
|
||||
|
||||
> 以下均为真实界面实拍。
|
||||
|
||||
### 🗂️ 文件工作台:资源管理器
|
||||
|
||||
支持两种格式的资源管理器:内嵌在文件预览中 / 独立显示文件树。懒加载目录树、软链接按目标类型展示(目录软链接可展开、失效链接标红)、全局文件名搜索、上传文件/文件夹与拖放上传、右键菜单(在新 Tab 打开 / 在侧边打开 / 复制路径)、悬浮 `@文件` 一键引用进输入框。
|
||||
|
||||
<div align="center"><img width="880" alt="文件资源管理器" src="https://github.com/user-attachments/assets/a410bfd2-a8ba-43e6-873e-22417756e94d" /></div>
|
||||
<div align="center"><img width="880" alt="CodeMirror 代码编辑器" src="https://github.com/user-attachments/assets/b44b488e-568c-4ee0-b96c-e9c906598a77" /></div>
|
||||
|
||||
### 📝 Markdown · 图片 · PDF 内联预览
|
||||
|
||||
Markdown 预览支持 **Mermaid 图表**(`securityLevel: 'strict'` 安全渲染 + 二次清洗;点击图表弹窗放大、滚轮缩放、拖拽平移);图片 / PDF 走媒体路由内联展示;Office 三件套由生态插件补齐。
|
||||
|
||||
<div align="center"><img width="880" alt="Markdown + Mermaid 预览" src="https://github.com/user-attachments/assets/fe0e5182-55bb-45cc-b98b-a2877c2bdd38" /></div>
|
||||
<div align="center"><img width="880" alt="图片内联预览" src="https://github.com/user-attachments/assets/f9a58c30-5b7a-48b5-9e22-37d7e071f593" /></div>
|
||||
|
||||
### 💻 真实终端
|
||||
|
||||
xterm.js + node-pty 真实 shell(不是模拟器):断线重连 transcript 回放、shell / shellArgs 可配置(设置页或 `cordis.patch.yml`)、可选为模型注入 `terminal_*` 工具(agent 可直接开终端跑命令)。
|
||||
|
||||
<div align="center"><img width="880" alt="真实终端" src="https://github.com/user-attachments/assets/0dad6ad3-ff3f-4b5a-86d2-f832ce65323e" /></div>
|
||||
|
||||
### 🌿 Git 面板
|
||||
|
||||
暂存 / 取消暂存 / 提交(`Ctrl+Enter`)/ 还原,历史列表;点击改动文件打开 **VSCode 式 diff tab**(红绿行级对比)。
|
||||
|
||||
<div align="center"><img width="880" alt="Git 面板" src="https://github.com/user-attachments/assets/e7fc1220-305f-4bca-8583-e77ab4f4fa78" /></div>
|
||||
|
||||
### 🌐 内嵌浏览器
|
||||
|
||||
多开网页 tab:后退 / 前进 / 刷新 / 地址栏;内容运行在**不透明源沙箱 iframe**(界面实时显示沙箱状态,可按页面临时解锁);聊天里的外链点击可被接管到侧边栏打开(按协议分流,可配)。
|
||||
|
||||
<div align="center"><img width="880" alt="内嵌浏览器" src="https://github.com/user-attachments/assets/9bc6b65a-64fc-4942-a685-76e391e55606" /></div>
|
||||
|
||||
### 🧩 任务页:子代理拓扑 + 后台任务
|
||||
|
||||
子代理树实时拓扑(运行状态、批量实时预览)+ 后台任务清单(退出码 / 实时输出 / 强制终止);新子代理 / 新任务可自动展开侧边栏(可关)。
|
||||
|
||||
<div align="center"><img width="880" alt="任务页:子代理拓扑" src="https://github.com/user-attachments/assets/dcd8ed2f-59fa-405b-937b-2d250f5034dd" /></div>
|
||||
|
||||
### 💬 侧边对话(beta)
|
||||
|
||||
Codex 风格侧边线程:**每个对话一个独立 Tab**;线程继承主会话完整上下文(含进行中回合,以 interrupted 诚实冻结)独立运行,不污染主会话;可持续追问、重启冷恢复;一键「保存为新会话」提升为顶层会话。
|
||||
|
||||
<div align="center"><img width="880" alt="侧边对话(beta)" src="https://github.com/user-attachments/assets/3a338c36-f5de-4000-95f3-4b1cd04f60fc" /></div>
|
||||
|
||||
### 🪟 双工作台:右侧栏 + 底部面板 + 分栏
|
||||
|
||||
右侧栏与底部面板可同时展开;拖 Tab 到分栏边缘**拆分**、拖到中间**合并**(可跨面板);面板宽高左缘/上缘拖拽调节;移动端自动合并为全宽抽屉。
|
||||
|
||||
<div align="center"><img width="880" alt="双工作台(右侧栏 + 底部面板)" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" /></div>
|
||||
|
||||
### ⚙️ 声明式设置
|
||||
|
||||
设置页「侧边卡片」分区:每个 tab / 预览器一张小卡片,独立开关(高亮启用态 + 品牌开关滑块);二级设置经卡片底部「功能设置」条弹窗(开关 / 文本 / 数字 / 下拉);插件自有设置持久化在 `pluginSettings`。
|
||||
|
||||
<div align="center"><img width="880" alt="声明式设置:侧边卡片" src="https://github.com/user-attachments/assets/0800ca64-621e-48da-b7df-aecfddc3ec29" /></div>
|
||||
|
||||
### 📱 移动端
|
||||
|
||||
窄屏(<768px)自动切换为全宽抽屉:底栏 tab 一次性并入右侧栏,触屏拖拽可调。
|
||||
|
||||
<div align="center"><img width="360" alt="移动端全宽抽屉" src="https://github.com/user-attachments/assets/a82ba78a-f4cf-4d85-80e8-050a05beb144" /></div>
|
||||
|
||||
## 🌐 插件生态
|
||||
|
||||
`ctx.betterSidebar` 服务向所有插件开放两个扩展点:**`registerTab`(注册侧边栏页面)** 与 **`registerFileViewer`(注册文件预览器)**。内置的 7 tab + 6 viewer 与第三方插件走同一套 API,能力完全对等。
|
||||
|
||||
```ts
|
||||
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
|
||||
export const inject = ['betterSidebar']
|
||||
export function apply(ctx: Context) {
|
||||
ctx.effect(() => ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:db', title: 'Database', component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
|
||||
}))
|
||||
ctx.effect(() => ctx.betterSidebar.registerFileViewer({
|
||||
id: 'my-plugin:csv', exts: ['csv'], fetchStrategy: 'custom',
|
||||
load: async (path, scope) => parseCsv(await fetchText(scope, path)),
|
||||
component: ({ customData }) => <CsvGrid rows={customData} />,
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar) 下已有 **28+ 生态插件**(持续增长中):
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="66%" alt="设置页「添加插件」弹窗:推荐插件目录 + 一键复制安装命令" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a><br />
|
||||
<i>设置页「侧边卡片」内置「添加插件」弹窗:推荐目录 + 一键复制安装命令 + 直达 GitHub topic</i>
|
||||
</div>
|
||||
|
||||
### 📑 Tab 插件(注册侧边栏页面)
|
||||
|
||||
<details>
|
||||
<summary><b>23 个插件(点击展开)</b></summary>
|
||||
|
||||
| 插件 | ⭐ | 简介 |
|
||||
|---|---|---|
|
||||
| [ChenRuoT/dsh-sidebar-qa](https://github.com/ChenRuoT/dsh-sidebar-qa) | <img alt="stars" src="https://img.shields.io/github/stars/ChenRuoT/dsh-sidebar-qa?style=flat&color=4d6bfe" /> | 划选追问侧边页:类 Codex 侧边提问 / Claude Code `/btw` |
|
||||
| [fuhefei/dsh-sentinel](https://github.com/fuhefei/dsh-sentinel) | <img alt="stars" src="https://img.shields.io/github/stars/fuhefei/dsh-sentinel?style=flat&color=4d6bfe" /> | 条件驱动唤醒系统:文件 / 命令 / HTTP / 进程 / Webhook 监视,到点唤醒 agent;dock + 侧栏分支 + 全局仪表盘 |
|
||||
| [jiuge2467/dsh-studio](https://github.com/jiuge2467/dsh-studio) | <img alt="stars" src="https://img.shields.io/github/stars/jiuge2467/dsh-studio?style=flat&color=4d6bfe" /> | 全栈增强工作台:多源 MCP 可视化调试中枢、视觉思考引擎 |
|
||||
| [Iwctwbh/dsh-flowglass](https://github.com/Iwctwbh/dsh-flowglass) | <img alt="stars" src="https://img.shields.io/github/stars/Iwctwbh/dsh-flowglass?style=flat&color=4d6bfe" /> | 流镜 Flowglass:会话流程图实时可视化(消息 / 工具组 / 子代理分支) |
|
||||
| [FeatherHunter/dsh-mattpocock-skills-deck](https://github.com/FeatherHunter/dsh-mattpocock-skills-deck) | <img alt="stars" src="https://img.shields.io/github/stars/FeatherHunter/dsh-mattpocock-skills-deck?style=flat&color=4d6bfe" /> | mattpocock/skills 游戏化任务系统:地图拨迷雾、任务栏推进 |
|
||||
| [GULI-lab/DSH-element-source](https://github.com/GULI-lab/DSH-element-source) | <img alt="stars" src="https://img.shields.io/github/stars/GULI-lab/DSH-element-source?style=flat&color=4d6bfe" /> | 点击页面任意 UI 元素直达 Vue / React / Svelte / Angular 源码并送入会话 |
|
||||
| [Lzh3070/dsh-file-review-tab](https://github.com/Lzh3070/dsh-file-review-tab) | <img alt="stars" src="https://img.shields.io/github/stars/Lzh3070/dsh-file-review-tab?style=flat&color=4d6bfe" /> | 文件改动审查页:行级红绿 diff + 撤销 + chat 行深链 |
|
||||
| [yq04/dsh-git-remotes](https://github.com/yq04/dsh-git-remotes) | <img alt="stars" src="https://img.shields.io/github/stars/yq04/dsh-git-remotes?style=flat&color=4d6bfe" /> | Git 远程页:分支 / 上游 / ahead-behind,fetch 可 prune、ff-only pull、确认后 push |
|
||||
| [ztyhehe/dsh-better-sidebar-svn](https://github.com/ztyhehe/dsh-better-sidebar-svn) | <img alt="stars" src="https://img.shields.io/github/stars/ztyhehe/dsh-better-sidebar-svn?style=flat&color=4d6bfe" /> | SVN 源码管理页:status / diff / log / commit / update / revert / 冲突解决,与内置 Git 面板对称 |
|
||||
| [Melody-max114/dsh-excel-panel](https://github.com/Melody-max114/dsh-excel-panel) | <img alt="stars" src="https://img.shields.io/github/stars/Melody-max114/dsh-excel-panel?style=flat&color=4d6bfe" /> | Excel 编辑页:xlsx 预览 / 编辑、公式实时计算、合并单元格、保存回原文件 |
|
||||
| [v587d/dsh-anysearch-refs](https://github.com/v587d/dsh-anysearch-refs) | <img alt="stars" src="https://img.shields.io/github/stars/v587d/dsh-anysearch-refs?style=flat&color=4d6bfe" /> | AnySearch 搜索结果引用卡片:搜索词、来源摘要、关键词高亮 |
|
||||
| [mlosun/dsh-docs-panel](https://github.com/mlosun/dsh-docs-panel) | <img alt="stars" src="https://img.shields.io/github/stars/mlosun/dsh-docs-panel?style=flat&color=4d6bfe" /> | 全局文档面板:随身 Markdown 笔记,任何工作区随时可读 |
|
||||
| [lnyuqian/dsh-skill-sidebar](https://github.com/lnyuqian/dsh-skill-sidebar) | <img alt="stars" src="https://img.shields.io/github/stars/lnyuqian/dsh-skill-sidebar?style=flat&color=4d6bfe" /> | 技能面板:扫描本机技能目录,4-6 字功能短语 + 一键复制调用 + 置顶 |
|
||||
| [g-yixuan/dsh-sidechat](https://github.com/g-yixuan/dsh-sidechat) | <img alt="stars" src="https://img.shields.io/github/stars/g-yixuan/dsh-sidechat?style=flat&color=4d6bfe" /> | Codex 风格侧边对话 + 划选引用注释(轻量消费插件) |
|
||||
| [thirsty5034/dsh-ssh-tunnel](https://github.com/thirsty5034/dsh-ssh-tunnel) | <img alt="stars" src="https://img.shields.io/github/stars/thirsty5034/dsh-ssh-tunnel?style=flat&color=4d6bfe" /> | 多主机 SSH 隧道 + SSH 管理器页 |
|
||||
| [thirsty5034/dsh-git-forge](https://github.com/thirsty5034/dsh-git-forge) | <img alt="stars" src="https://img.shields.io/github/stars/thirsty5034/dsh-git-forge?style=flat&color=4d6bfe" /> | GitHub / Gitea 账号、项目授权与推送策略 |
|
||||
| [YesSanSan/dsh-conversation-outline](https://github.com/YesSanSan/dsh-conversation-outline) | <img alt="stars" src="https://img.shields.io/github/stars/YesSanSan/dsh-conversation-outline?style=flat&color=4d6bfe" /> | 对话大纲页:按轮次结构化展示、一键跳转、LLM 一句话标题 |
|
||||
| [Wulabalabo/dsh-sidebar-Explorer-Plus](https://github.com/Wulabalabo/dsh-sidebar-Explorer-Plus) | <img alt="stars" src="https://img.shields.io/github/stars/Wulabalabo/dsh-sidebar-Explorer-Plus?style=flat&color=4d6bfe" /> | 文件管理页:上传 / 移动 / 删除 / 重命名 / 新建文件夹(补全写操作) |
|
||||
| [yq04/dsh-turn-review](https://github.com/yq04/dsh-turn-review) | <img alt="stars" src="https://img.shields.io/github/stars/yq04/dsh-turn-review?style=flat&color=4d6bfe" /> | 本轮审查:逐回合审查 agent 改动 |
|
||||
| [Ghz114514/dsh-refpics](https://github.com/Ghz114514/dsh-refpics) | <img alt="stars" src="https://img.shields.io/github/stars/Ghz114514/dsh-refpics?style=flat&color=4d6bfe" /> | Pinterest 风格参考图搜索:瀑布流、侧栏画板、下载与 Eagle 收藏 |
|
||||
| [yzlin499/dsh-yzlin499-easy-plugins](https://github.com/yzlin499/dsh-yzlin499-easy-plugins) | <img alt="stars" src="https://img.shields.io/github/stars/yzlin499/dsh-yzlin499-easy-plugins?style=flat&color=4d6bfe" /> | 实用小工具集(毛坯房 DSH 友好) |
|
||||
| [dong-victor/dsh-better-sidebar-starter](https://github.com/dong-victor/dsh-better-sidebar-starter) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-better-sidebar-starter?style=flat&color=4d6bfe" /> | 运行配置页:IDEA 式 Run/Debug 配置(npm / springboot / python / custom)——一键启动、历史保存、WebSocket 实时日志(ANSI 彩色)、多实例并行、进程树跨平台杀死 |
|
||||
| [baosfeng/my-dsh-plugins](https://github.com/baosfeng/my-dsh-plugins) | <img alt="stars" src="https://img.shields.io/github/stars/baosfeng/my-dsh-plugins?style=flat&color=4d6bfe" /> | 个人多插件合集(`dsh-file-activity`):侧边栏文件活动页——记录文件读取 / 新增 / 修改历史与统计,按文件夹平铺,点击用原生预览打开 |
|
||||
|
||||
</details>
|
||||
|
||||
### 🖼️ 预览插件(注册文件预览器)
|
||||
|
||||
<details>
|
||||
<summary><b>3 个插件(点击展开)</b></summary>
|
||||
|
||||
| 插件 | ⭐ | 简介 |
|
||||
|---|---|---|
|
||||
| [HuanLinOTO/dsh-plugin-better-sidebar-plugin-office](https://github.com/HuanLinOTO/dsh-plugin-better-sidebar-plugin-office) | <img alt="stars" src="https://img.shields.io/github/stars/HuanLinOTO/dsh-plugin-better-sidebar-plugin-office?style=flat&color=4d6bfe" /> | Office 三件套预览(.docx / .xlsx / .pptx),独立 bundle 瘦身主体(官方推荐目录收录) |
|
||||
| [zemul/dsh-video-preview](https://github.com/zemul/dsh-video-preview) | <img alt="stars" src="https://img.shields.io/github/stars/zemul/dsh-video-preview?style=flat&color=4d6bfe" /> | 视频内联预览:.mp4 / .webm / .mov / .mkv / .avi,自带 /video 路由支持 HTTP Range 拖进度条 |
|
||||
| [dong-victor/dsh-better-sidebar-jupyter](https://github.com/dong-victor/dsh-better-sidebar-jupyter) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-better-sidebar-jupyter?style=flat&color=4d6bfe" /> | `.ipynb` 可运行 Notebook 视图:懒启动 Python kernel、流式输出、保存回写 |
|
||||
|
||||
</details>
|
||||
|
||||
### 🧰 增强与工具
|
||||
|
||||
<details>
|
||||
<summary><b>2 个插件(点击展开)</b></summary>
|
||||
|
||||
| 插件 | ⭐ | 简介 |
|
||||
|---|---|---|
|
||||
| [dong-victor/dsh-better-sidebar-terminal-plus](https://github.com/dong-victor/dsh-better-sidebar-terminal-plus) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-better-sidebar-terminal-plus?style=flat&color=4d6bfe" /> | 终端增强:内嵌 Nerd Font 图标字体、修复 xterm 图标渲染、稳定终端 cwd |
|
||||
| [Max-Null/dsh-sidebar-preview-select](https://github.com/Max-Null/dsh-sidebar-preview-select) | <img alt="stars" src="https://img.shields.io/github/stars/Max-Null/dsh-sidebar-preview-select?style=flat&color=4d6bfe" /> | 预览划选增强:侧边栏预览里划选文本 → 浮动「发送到会话」 |
|
||||
|
||||
</details>
|
||||
|
||||
> 📣 **上架你的插件**:给仓库打上 `dsh-better-sidebar` topic 即出现在 [topic 页](https://github.com/topics/dsh-better-sidebar);再向 [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) / [`src/client/plugins-viewers.ts`](./src/client/plugins-viewers.ts) 提一条 `PluginEntry` PR,即可进入设置页内置推荐目录(数据完整性由 `tests/plugin-list.spec.ts` 守护)。
|
||||
|
||||
## 🆕 最近更新
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
**支持的 DSH 版本**:<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本:0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2" src="https://img.shields.io/badge/DSH-0.1.0--rc.8_%C2%B7_0.1.1--rc.1_%C2%B7_0.1.1--rc.2-4d6bfe" /></a> · 完整发布历史见 [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)
|
||||
|
||||
### v0.15.1
|
||||
|
||||
自 v0.15.0 以来的全部更改:
|
||||
|
||||
**✨ 新功能**
|
||||
|
||||
- 💬 **侧边对话 Codex 风格转录重构**([#314](https://github.com/omdsh-dev/DSH-better-sidebar/pull/314)):转录改为**折叠行**——工具调用 / 思考 / 上下文注入统一为安静的单行 chrome(chevron + 标签 + 单行参数摘要,展开为 hairline 缩进正文,无卡片无填充),流式标签与创建 shimmer(shimmer = 生成中)、失败工具 danger、`prefers-reduced-motion` 停帧;**首条问题不再被边界提示吞掉**——上下文注入与首问拆分交付(边界 + 快照经 `agent.inject` 排队、问题唤醒驱动),转录把注入映射为可折叠注入行、真实用户消息(**含首问**)渲染为用户气泡,旧线程的首问同样拆分为独立气泡
|
||||
- 📖 **README 重写**:功能导览(逐特性实机截图)、用户视角 DSH 兼容徽章、简化安装流程(`add` → `approve-builds` → `add`、node-pty 安全构建、粘贴到 DSH 安装提示)、插件生态 28+ 与分类折叠展示
|
||||
|
||||
**🐛 修复**
|
||||
|
||||
- 🖥️ **终端跨会话切换保活**([#323](https://github.com/omdsh-dev/DSH-better-sidebar/pull/323)):切到其他会话不再被当作瞬时掉线——客户端卸载时发送 `park` 控制帧,主机跳过 30s 重连宽限倒计时;切回会话(`open()` 取消 parked)或显式关闭恢复正常生命周期;agent 终端保持无限期存活
|
||||
- 📂 **文件树上传遮罩不再拦截 Tab 拖拽**([#317](https://github.com/omdsh-dev/DSH-better-sidebar/pull/317)):拖拽 Tab(重排 / 跨 pane split)经过资源管理器时不再弹上传遮罩、不吞事件——统一按 `dataTransfer.types` 含 `Files` 门控(与面板宿主 shield 一致),Tab 正常落下;OS 文件拖拽行为不变
|
||||
- 💬 **子代理自动展开去抖**([#314](https://github.com/omdsh-dev/DSH-better-sidebar/pull/314)):Side Chat 线程创建不再误弹任务页——0→N 触发 500ms 重臂并对实时快照按原基线重评估,标题过滤器识别线程后才放行;真实子代理依然自动展开
|
||||
|
||||
### v0.15.0
|
||||
|
||||
自 v0.14.0 以来的全部更改:
|
||||
|
||||
**✨ 新功能**
|
||||
|
||||
- 💬 **侧边对话(beta) Tab**([#286](https://github.com/omdsh-dev/DSH-better-sidebar/pull/286)):Codex 风格的侧边线程,**每个对话一个独立 Tab**——子会话继承主会话完整上下文(已完成回合 + 未回答消息 + 进行中回合的 assistant 输出与工具调用,以「interrupted」冻结标记诚实继承);同组合创建(同 preset / provider / model)复用前缀输入缓存;线程对主会话列表不可见、零子代理目录噪音;线程内可持续追问(重启后自动冷恢复);一键「保存为新会话」提升为顶层会话([设计文档](docs/plans/2026-08-20-sidechat-tab-design.md))
|
||||
- 📤 **文件窗口上传**([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)):头部「上传文件 / 上传文件夹」按钮 + 拖放上传(拖到树区 = 工作区根,目录行 = 进该目录,文件行 = 进其所在目录,对齐 VSCode);上传时全屏模糊进度弹层(文件级进度 + 取消 / Esc);上传中按钮禁用、成功后文件树自动刷新
|
||||
- 🧩 **桌面兼容四选项**([#284](https://github.com/omdsh-dev/DSH-better-sidebar/pull/284)):位置兼容模式改为**主行下拉**——**自动检测**(默认,保守:仅使用标准的 Window Controls Overlay 几何,32/36px 等各壳差异自动跟随、最大化/还原实时更新,网页环境零修改)/ **DSH官方Web**(显式零适配)/ **壳兼容方案**(内置预设,手动启用;只收录 issue/PR 中出现过且 100+ star 的壳,命中环境带「已检测」提示)/ **自定义方案**(自定义 CSS + 下移距离)。旧版本已有兼容配置的用户自动落到自定义方案;交互控件统一退出桌面拖拽区(`no-drag`);底栏推挤锚点复合选择器双保险(`[data-pane]` 与 `:has(> [data-slot])`)
|
||||
- 🎛️ **设置页 UI/UX 现代化**([#300](https://github.com/omdsh-dev/DSH-better-sidebar/pull/300)):侧边卡片二级设置入口改为卡片底部「功能设置」设置条(替代右下角隐形齿轮,可发现性提升);协调双色启用态(brand 激活强调 + success 绿勾选徽标);全部颜色仍为 `--dsw-alias-*` 令牌派生,皮肤体系自动跟随
|
||||
- ➕ **推荐插件目录新增**:`dsh-docs-panel` 全局文档面板([#230](https://github.com/omdsh-dev/DSH-better-sidebar/pull/230))、`dsh-flowglass`([#261](https://github.com/omdsh-dev/DSH-better-sidebar/pull/261))、`dsh-git-forge` 与 `dsh-ssh-tunnel`([#204](https://github.com/omdsh-dev/DSH-better-sidebar/pull/204))、`dsh-turn-review`([#102](https://github.com/omdsh-dev/DSH-better-sidebar/pull/102))
|
||||
|
||||
**🐛 修复**
|
||||
|
||||
- ⚡ **子代理页实时预览批量接口**([#298](https://github.com/omdsh-dev/DSH-better-sidebar/pull/298)):旧实现每个 running 子代理独立轮询 `subagents.history`,host 侧每次触发全量子代理枚举形成 O(N²) 放大、多子代理并发时页面卡顿——改为单个批量接口 `subagents.live`(一次枚举整棵子代理树)+ 客户端单轮询、单在途请求;展示逻辑与文案不变
|
||||
- 🖱️ **拖拽中断 / 快速释放不再回滚**([#249](https://github.com/omdsh-dev/DSH-better-sidebar/pull/249),关闭 [#247](https://github.com/omdsh-dev/DSH-better-sidebar/issues/247) [#248](https://github.com/omdsh-dev/DSH-better-sidebar/issues/248)):中断 / 快速释放提交最后已知位置;HMR 后中心列重定位兜底(修复热更新后底栏空白)
|
||||
- 📐 **推挤变量挂载期持续有效**([#259](https://github.com/omdsh-dev/DSH-better-sidebar/pull/259),修复 [#258](https://github.com/omdsh-dev/DSH-better-sidebar/issues/258)):拖拽松手后底边栏不再闪全宽
|
||||
- 🔧 **适配 DSH 0.1.1-rc.1 / rc.2(@next)**([#297](https://github.com/omdsh-dev/DSH-better-sidebar/pull/297) [#305](https://github.com/omdsh-dev/DSH-better-sidebar/pull/305)):无代码逻辑改动
|
||||
- 🔒 **上传链路安全加固**([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)):`relativePath` 空段 / 绝对路径显式拒绝;临时文件唯一命名(并发上传互不干扰、崩溃不阻塞);写流错误监听(磁盘失败不崩溃进程);客户端错误码与服务端统一、413 本地化
|
||||
|
||||
<details>
|
||||
<summary><b>历史版本(v0.12.0 – v0.14.0)</b></summary>
|
||||
|
||||
### v0.14.0
|
||||
|
||||
> ⚠️ 本版起需要 DSH ≥ 0.1.0-rc.8。自 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);`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` 分组渲染,列表独立滚动(弹窗不再随条目数无限增长)
|
||||
|
||||
**🐛 修复**
|
||||
|
||||
- 🧩 **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` 崩溃);独立安装行为不变
|
||||
- 🔧 **适配 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)):修复 DSH 主框架升至 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 偶发失败修复
|
||||
|
||||
</details>
|
||||
|
||||
## ⌨️ 快捷键
|
||||
|
||||
@@ -29,73 +417,24 @@
|
||||
| 引用文件到输入框 | 悬浮行尾 `@文件` 按钮 |
|
||||
| 复制文件路径 | 右键行 → 复制相对/绝对地址 |
|
||||
|
||||
## 🚀 安装
|
||||
## 🔌 服务化扩展
|
||||
|
||||
前置:已安装 DSH(`dsh web` 可运行),Node.js ≥ 20、pnpm ≥ 10。把下面提示词**整段**发给 DSH 即可自动完成克隆、构建、注册与安装:
|
||||
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 7 tab + 6 viewer 亦通过同一服务注册)。v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、tab 角标、生命周期回调、定向打开、插件自有设置等)。
|
||||
|
||||
```text
|
||||
请帮我把 dsh-better-sidebar 插件安装到我的 web profile(插件 = VSCode 风格右侧侧边栏,仓库 https://github.com/dsh-external/DSH-better-sidebar):
|
||||
完整接入文档:
|
||||
- **[`AGENTS.md`](./AGENTS.md)**——仓库内维护的接入文档(全字段、匹配算法、HMR 陷阱、声明式设置、版本探测);
|
||||
- **[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)**——面向外部插件开发者的接入指南(含完整最小示例)。
|
||||
|
||||
1. 克隆并构建:
|
||||
git clone https://github.com/dsh-external/DSH-better-sidebar.git ~/Code/DSH-better-sidebar
|
||||
cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build
|
||||
(@deepseek-ai/* 从 npm 解析(0.0.1-rc.? 预发布版);若 401/404,用安装者自己的只读令牌配置 ~/.npmrc,或把 devDependencies 改回指向自己 ~/.dsh/source/current 的 link:,不要继续)
|
||||
2. 注册到 web profile:
|
||||
a. ~/.dsh/profiles/web/package.json 的 dependencies 加 "dsh-better-sidebar": "link:<第 1 步克隆目录的绝对路径>"
|
||||
b. ~/.dsh/profiles/web/cordis.patch.yml 追加:
|
||||
- insert:
|
||||
- id: better-sidebar
|
||||
name: 'dsh-better-sidebar'
|
||||
3. 在 ~/.dsh/profiles/web 执行 pnpm install
|
||||
4. 重启 DSH 并硬刷新(Cmd/Ctrl+Shift+R)验证
|
||||
```
|
||||
### ➕ 添加插件(推荐插件目录)
|
||||
|
||||
> 安装 = 依赖登记(等价 `dsh plugin --profile web add link:<路径>`)+ 一行挂载行。**DSH 以 npm 包启动(如 `npx -p @deepseek-ai/dsh@0.0.1-rc.1 dsh web`)同样可用**(v0.4.3 起实测验证)。
|
||||
设置页「侧边卡片」两个网格末尾的**虚线卡片**分别打开 Tab / 预览插件弹窗:声明扩展点、「**在 GitHub 上浏览更多插件**」按钮([GitHub topic `dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar))、推荐插件目录(名字 / 仓库 / 简介 / 安装脚本),每个条目「**跳转**」直达仓库、「**复制**」把安装命令写入剪贴板。
|
||||
|
||||
### 更新
|
||||
|
||||
```text
|
||||
1. cd ~/Code/DSH-better-sidebar && git pull && pnpm install && pnpm build(401/404 处理同上)
|
||||
2. 核对注册仍有效(缺失才补):profile package.json 的 link: 依赖 + cordis.patch.yml 挂载行
|
||||
3. 仅 client(src/client/*)→ 硬刷新即可;含 host(src/index.ts、src/config.ts 等)→ 重启 DSH + 硬刷新
|
||||
```
|
||||
|
||||
### 通过 plugin-registry 安装(可选,与上述二选一)
|
||||
|
||||
前置:DSH 已集成 [plugin-registry](https://github.com/dsh-external/plugin-registry)(`dsh registry` 可用)。**同时启用两个通道会双挂载**(Node 半挂两次、页面两个侧边栏)。
|
||||
|
||||
```sh
|
||||
git clone https://github.com/dsh-external/DSH-better-sidebar.git && cd DSH-better-sidebar
|
||||
pnpm install && pnpm build
|
||||
node scripts/package-registry.mjs # 组装 registry/ 暂存(含清单 + 产物 + README,不入库)
|
||||
dsh registry install ./registry # 安装(默认禁用)
|
||||
dsh registry enable dsh-external/dsh-better-sidebar
|
||||
```
|
||||
|
||||
更新:`git pull && pnpm install && pnpm build` → `node scripts/package-registry.mjs` → `dsh registry uninstall/install/enable`。切换通道前先移除另一通道的挂载。
|
||||
|
||||
## 🔌 服务化:注册 tab 与文件预览器
|
||||
|
||||
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 7 tab + 9 viewer 也走同一服务,吃自己的狗粮):
|
||||
|
||||
```ts
|
||||
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
|
||||
export const inject = ['betterSidebar']
|
||||
export function apply(ctx: Context) {
|
||||
ctx.effect(() => ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:db', title: 'Database', component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
完整接入文档(`TabDescriptor` / `FileViewerDescriptor` 全字段、匹配算法、HMR 陷阱、声明式设置):见 [`AGENTS.md`](./AGENTS.md)。
|
||||
**收录新插件**:向 [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts)(Tab 注册)或 [`src/client/plugins-viewers.ts`](./src/client/plugins-viewers.ts)(文件预览注册)追加一条 `PluginEntry`,并把仓库打上 `dsh-better-sidebar` topic;数据完整性由 `tests/plugin-list.spec.ts` 守护。
|
||||
|
||||
## 🛠️ 开发与构建
|
||||
|
||||
```sh
|
||||
pnpm install # @deepseek-ai/* 从 npm 解析(^0.0.1-rc.1),需安装者自己的只读令牌:
|
||||
# ~/.npmrc 配 //registry.npmjs.org/:_authToken=<自己的令牌>(勿写进仓库),
|
||||
# 或改 devDependencies 为 link: 指向自己的 ~/.dsh/source/current
|
||||
pnpm install # @deepseek-ai/* devDependencies 已发布 0.1.1-rc.1,直接解析、无需令牌
|
||||
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)
|
||||
@@ -114,10 +453,61 @@ pnpm watch # tsdown --watch
|
||||
|
||||
- Git 无 push/pull/fetch;无文件 watcher(手动刷新);工具行内文件打开按钮不可拦截
|
||||
- 终端 Tab 拖到另一分栏会重挂载(shell 重开)
|
||||
- `.xlsx` 预览不保留单元格样式(SheetJS 社区版限制);Office/PPTX 预览内联进 client bundle(约 23MB),首次加载较慢
|
||||
- Office 三件套预览(.docx/.xlsx/.pptx)已移至「推荐插件」(Office 预览插件,见设置页「添加插件」弹窗);未安装时此类文件走代码/下载查看兜底
|
||||
- 浏览器沙箱无登录态/第三方 Cookie 受限,部分站点登录需走弹窗;被 `X-Frame-Options`/`frame-ancestors` 拒绝嵌入的站点(如 arxiv.org)显示原因面板(含「在浏览器中打开」);iframe 内部跳转不进后退栈
|
||||
- HTML 预览渲染的是已保存文件(不反映未保存草稿)
|
||||
- 移动端(<768px)无底部面板:进入窄屏时其标签页一次性并入右侧栏(迁移后回桌面仍保留在右侧栏),桌面端的底部面板只在宽视口下可用;移动端底部首展自动开终端不触发
|
||||
|
||||
## 🖥️ 平台支持
|
||||
|
||||
Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元测试覆盖);`node-pty` 优先预编译二进制,失败需编译工具链(Windows VS Build Tools / Linux make+g+++python3 / macOS Xcode CLT)。
|
||||
|
||||
## 💬 社区
|
||||
|
||||
微信 / QQ 交流群二维码将在此处展示。上传二维码图片(拖入任意 issue / comment 即可获得 `user-attachments` 链接)后,替换下方 `src` 并取消注释:
|
||||
|
||||
<div align="center">
|
||||
<!-- 微信群二维码
|
||||
<img width="220" alt="微信群二维码" src="https://github.com/user-attachments/assets/REPLACE_ME" />
|
||||
-->
|
||||
<!-- QQ 群二维码
|
||||
<img width="220" alt="QQ群二维码" src="https://github.com/user-attachments/assets/REPLACE_ME" />
|
||||
-->
|
||||
</div>
|
||||
|
||||
## 🤝 参与贡献
|
||||
|
||||
- **代码改动走 PR**:`feat/*` / `fix/*` 分支开发 → `gh pr create`;纯文档改动可直接推 main
|
||||
- **收录生态插件**:给仓库打 `dsh-better-sidebar` topic + 向 [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) / [`plugins-viewers.ts`](./src/client/plugins-viewers.ts) 提 PR
|
||||
- **提交前自检**:`pnpm typecheck && pnpm build && pnpm test`(CI 另有 npm 打包 → 真实挂载 → 无头渲染门禁 `pnpm test:mount`)
|
||||
- 仓库工作规范见 [`AGENTS.md`](./AGENTS.md)(含仓库硬约束与 CI 说明)
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
<a href="https://star-history.com/#omdsh-dev/DSH-better-sidebar&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=omdsh-dev/DSH-better-sidebar&type=Date&theme=dark" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=omdsh-dev/DSH-better-sidebar&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## 👥 贡献者
|
||||
|
||||
感谢每一位贡献者:
|
||||
|
||||
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=omdsh-dev/DSH-better-sidebar" alt="贡献者" />
|
||||
</a>
|
||||
|
||||
## 🔗 友情链接
|
||||
|
||||
- [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)
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<sub>MIT License · Built for the <a href="https://github.com/deepseek-ai/deepseek-harness">DeepSeek Harness</a> ecosystem · 在 <a href="https://github.com/topics/dsh-better-sidebar">topic dsh-better-sidebar</a> 发现更多生态插件</sub>
|
||||
</div>
|
||||
|
||||
+513
@@ -0,0 +1,513 @@
|
||||
# dsh-better-sidebar
|
||||
|
||||
<!-- Hero -->
|
||||
<div align="center">
|
||||
<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://www.npmjs.com/package/dsh-better-sidebar"><img alt="npm version" src="https://img.shields.io/npm/v/dsh-better-sidebar" /></a>
|
||||
<a href="https://www.npmjs.com/package/dsh-better-sidebar"><img alt="npm downloads" src="https://img.shields.io/npm/dm/dsh-better-sidebar" /></a>
|
||||
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/omdsh-dev/DSH-better-sidebar/actions/workflows/ci.yml/badge.svg" /></a>
|
||||
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/omdsh-dev/DSH-better-sidebar" /></a>
|
||||
<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 />
|
||||
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions: 0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2" src="https://img.shields.io/badge/DSH-0.1.0--rc.8_%C2%B7_0.1.1--rc.1_%C2%B7_0.1.1--rc.2-4d6bfe" /></a>
|
||||
<a href="https://github.com/topics/dsh-better-sidebar"><img alt="Plugin ecosystem: GitHub topic dsh-better-sidebar" src="https://img.shields.io/badge/plugin%20ecosystem-topic%20dsh--better--sidebar-4d6bfe" /></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="Side Chat" src="https://img.shields.io/badge/-Side%20Chat-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>
|
||||
|
||||
<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>
|
||||
|
||||
## 📑 Contents
|
||||
|
||||
- [✨ Features](#-features)
|
||||
- [🚀 Installation](#-installation)
|
||||
- [🖼️ Feature Tour](#-feature-tour)
|
||||
- [🌐 Plugin Ecosystem](#-plugin-ecosystem)
|
||||
- [🆕 Recent Updates](#-recent-updates)
|
||||
- [⌨️ Keyboard Shortcuts](#-keyboard-shortcuts)
|
||||
- [🔌 Service API](#-service-api)
|
||||
- [🛠️ Development & Build](#-development--build)
|
||||
- [🔐 Security](#-security) · [⚠️ Known Limitations](#-known-limitations) · [🖥️ Platform Support](#-platform-support)
|
||||
- [💬 Community](#-community) · [🤝 Contributing](#-contributing) · [⭐ Star History](#-star-history) · [🔗 Friends](#-friends)
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **🗂️ 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
|
||||
- **🌐 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)
|
||||
- **💬 Side Chat (beta)**: Codex-style side threads — the child inherits the parent's FULL context (completed turns + the pending question + the in-progress turn's assistant output and tool activity, honestly frozen as "interrupted") and runs independently without entering the main conversation; threads support continuous follow-ups (auto-resumed after a DSH restart) and one-click "Save as new session" promotion to a top-level session
|
||||
- **🪟 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 (**28+ ecosystem plugins** already — see "🌐 Plugin Ecosystem" below). See "🔌 Service API" and the [external plugin guide](./docs/external-plugin-guide.md).
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
**Prerequisites**: DSH installed (`dsh web` boots), Node.js ≥ 20, pnpm ≥ 10.
|
||||
|
||||
**Supported DSH versions**:
|
||||
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions: 0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2" src="https://img.shields.io/badge/DSH-0.1.0--rc.8_%C2%B7_0.1.1--rc.1_%C2%B7_0.1.1--rc.2-4d6bfe" /></a>
|
||||
|
||||
```sh
|
||||
dsh plugin --profile web add dsh-better-sidebar@latest # first run fails: pnpm 11 blocks node-pty build scripts (the dependency is still written)
|
||||
cd ~/.dsh/profiles/web && pnpm approve-builds --all # allow the build scripts (re-runs the install automatically)
|
||||
dsh plugin --profile web add dsh-better-sidebar@latest # re-run succeeds
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
**Or let DSH install it for you** — paste this prompt into any DSH session:
|
||||
|
||||
```text
|
||||
Install the dsh-better-sidebar plugin (a sidebar workbench for DSH):
|
||||
1. Run: dsh plugin --profile web add dsh-better-sidebar@latest (the first run fails because pnpm 11 blocks node-pty build scripts — that's expected)
|
||||
2. In ~/.dsh/profiles/web run: pnpm approve-builds --all (allows the build scripts and re-runs the install)
|
||||
3. Run the add command again: dsh plugin --profile web add dsh-better-sidebar@latest
|
||||
4. When done, remind me to hard-refresh the browser (Cmd/Ctrl+Shift+R)
|
||||
If anything fails, check the troubleshooting table in the README at https://github.com/omdsh-dev/DSH-better-sidebar
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>Updating</b></summary>
|
||||
|
||||
```sh
|
||||
dsh plugin --profile web add dsh-better-sidebar@latest
|
||||
```
|
||||
|
||||
or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.15.0"`) and run `pnpm install`. Then hard-refresh the browser (Cmd/Ctrl+Shift+R) — client changes do not need a DSH restart.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Troubleshooting</b></summary>
|
||||
|
||||
| Symptom | Cause & fix |
|
||||
|---|---|
|
||||
| `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. 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. |
|
||||
| Terminal shows "node-pty failed to load" | The `node-pty` install is missing or broken (e.g. pnpm skipped its build script). The terminal banner shows a repair command — copy it into a terminal/cmd on the DSH machine and run it (in `~/.dsh/profiles/web`: `pnpm approve-builds --all && pnpm rebuild node-pty`), then restart DSH and click Retry. The plugin and DSH core share the same `node-pty@^1.1.0`, so the repair restores both. |
|
||||
| `dsh: command not found` | Install DSH first, or run `npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar@latest`. |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Install from source / develop (optional — alternative to the npm flow)</b></summary>
|
||||
|
||||
To debug local changes or track the dev branch, point the dependency at a local clone and build it yourself:
|
||||
|
||||
```text
|
||||
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 (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.15.0"` and re-run `pnpm install`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Install via plugin-registry (optional — use either this or the main flow)</b></summary>
|
||||
|
||||
Prerequisite: DSH with [plugin-registry](https://github.com/dsh-external/plugin-registry) integrated (`dsh registry` available). **Enabling both channels double-mounts** (the Node half loads twice, the page gets two sidebars).
|
||||
|
||||
```sh
|
||||
git clone https://github.com/omdsh-dev/DSH-better-sidebar.git && cd DSH-better-sidebar
|
||||
pnpm install && pnpm build
|
||||
node scripts/package-registry.mjs # assemble the registry/ staging (manifest + artifacts + README, not committed)
|
||||
dsh registry install ./registry # install (disabled by default)
|
||||
dsh registry enable dsh-external/dsh-better-sidebar
|
||||
```
|
||||
|
||||
Update: `git pull && pnpm install && pnpm build` → `node scripts/package-registry.mjs` → `dsh registry uninstall/install/enable`. Remove the other channel's mount before switching.
|
||||
|
||||
</details>
|
||||
|
||||
## 🖼️ Feature Tour
|
||||
|
||||
> Below are real UI screenshots.
|
||||
|
||||
### 🗂️ File Workbench: Explorer
|
||||
|
||||
Two explorer modes: embedded in the file preview / standalone file tree. Lazy-loading directory tree, symlinks classified by target kind (directory links expand, dangling links flagged), global filename search, file/folder upload buttons plus drag-drop upload, context menu (open in new tab / open to the side / copy paths), and a hover `@file` button that references a file straight into the composer.
|
||||
|
||||
<div align="center"><img width="880" alt="File explorer" src="https://github.com/user-attachments/assets/a410bfd2-a8ba-43e6-873e-22417756e94d" /></div>
|
||||
<div align="center"><img width="880" alt="CodeMirror editor" src="https://github.com/user-attachments/assets/b44b488e-568c-4ee0-b96c-e9c906598a77" /></div>
|
||||
|
||||
### 📝 Inline Preview: Markdown · Images · PDF
|
||||
|
||||
The Markdown preview renders **Mermaid diagrams** (strict-mode safe rendering + a second sanitize pass; click a diagram for a zoom modal with wheel-zoom and drag-pan); images / PDFs display inline via the media route; the Office suite is covered by an ecosystem plugin.
|
||||
|
||||
<div align="center"><img width="880" alt="Markdown + Mermaid preview" src="https://github.com/user-attachments/assets/fe0e5182-55bb-45cc-b98b-a2877c2bdd38" /></div>
|
||||
<div align="center"><img width="880" alt="Inline image preview" src="https://github.com/user-attachments/assets/f9a58c30-5b7a-48b5-9e22-37d7e071f593" /></div>
|
||||
|
||||
### 💻 Real Terminal
|
||||
|
||||
xterm.js + node-pty real shell (not an emulator): transcript replay on reconnect, configurable shell / shellArgs (settings page or `cordis.patch.yml`), and optional `terminal_*` model tools so the agent can open terminals and run commands itself.
|
||||
|
||||
<div align="center"><img width="880" alt="Real terminal" src="https://github.com/user-attachments/assets/0dad6ad3-ff3f-4b5a-86d2-f832ce65323e" /></div>
|
||||
|
||||
### 🌿 Git Panel
|
||||
|
||||
Stage / unstage / commit (`Ctrl+Enter`) / revert, plus a history list; clicking a changed file opens a **VSCode-style diff tab** (line-level red/green).
|
||||
|
||||
<div align="center"><img width="880" alt="Git panel" src="https://github.com/user-attachments/assets/e7fc1220-305f-4bca-8583-e77ab4f4fa78" /></div>
|
||||
|
||||
### 🌐 Embedded Browser
|
||||
|
||||
Multiple web tabs with back / forward / reload / address bar; content runs in an **opaque-origin sandboxed iframe** (live sandbox status in the UI, per-page temporary unlock available); external-link clicks in the chat can be taken over into the sidebar (protocol-based routing, configurable).
|
||||
|
||||
<div align="center"><img width="880" alt="Embedded browser" src="https://github.com/user-attachments/assets/9bc6b65a-64fc-4942-a685-76e391e55606" /></div>
|
||||
|
||||
### 🧩 Tasks: Agent Topology + Background Jobs
|
||||
|
||||
Live subagent-tree topology (run states, batched live previews) plus the background-jobs list (exit codes / live output / force-kill); new subagents / jobs can auto-expand the sidebar (configurable).
|
||||
|
||||
<div align="center"><img width="880" alt="Tasks: subagent topology" src="https://github.com/user-attachments/assets/dcd8ed2f-59fa-405b-937b-2d250f5034dd" /></div>
|
||||
|
||||
### 💬 Side Chat (beta)
|
||||
|
||||
Codex-style side threads: **one independent tab per conversation**; the thread inherits the parent's full context (including the in-progress turn, honestly frozen as "interrupted") and runs independently without polluting the main session; follow-ups survive restarts; one click promotes the thread to a top-level session.
|
||||
|
||||
<div align="center"><img width="880" alt="Side Chat (beta)" src="https://github.com/user-attachments/assets/3a338c36-f5de-4000-95f3-4b1cd04f60fc" /></div>
|
||||
|
||||
### 🪟 Dual Workbench: Sidebar + Bottom Panel + Split Panes
|
||||
|
||||
The right sidebar and the bottom panel can stay open together; drag a tab to a pane edge to **split**, to the middle to **merge** (works across panels); panel width/height drag from the left/top edge; on mobile everything merges into a full-width drawer.
|
||||
|
||||
<div align="center"><img width="880" alt="Dual workbench (right sidebar + bottom panel)" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" /></div>
|
||||
|
||||
### ⚙️ Declarative Settings
|
||||
|
||||
The "Side card" section in DSH settings: one small card per tab / viewer with an independent toggle (highlighted enabled state + brand switch); secondary settings open from the "Feature settings" strip at the card bottom (switch / text / number / select rows); plugin-owned settings persist under `pluginSettings`.
|
||||
|
||||
<div align="center"><img width="880" alt="Declarative settings: side cards" src="https://github.com/user-attachments/assets/0800ca64-621e-48da-b7df-aecfddc3ec29" /></div>
|
||||
|
||||
### 📱 Mobile
|
||||
|
||||
On narrow screens (<768px) the panels become a full-width drawer: bottom-panel tabs merge into the sidebar once, with touch-friendly dragging.
|
||||
|
||||
<div align="center"><img width="360" alt="Mobile full-width drawer" src="https://github.com/user-attachments/assets/a82ba78a-f4cf-4d85-80e8-050a05beb144" /></div>
|
||||
|
||||
## 🌐 Plugin Ecosystem
|
||||
|
||||
The `ctx.betterSidebar` service opens two extension points to every plugin: **`registerTab` (sidebar pages)** and **`registerFileViewer` (file previewers)**. The 7 built-in tabs + 6 viewers register through the exact same API — fully equal capabilities.
|
||||
|
||||
```ts
|
||||
import type {} from 'dsh-better-sidebar' // triggers the ctx.betterSidebar type merge
|
||||
export const inject = ['betterSidebar']
|
||||
export function apply(ctx: Context) {
|
||||
ctx.effect(() => ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:db', title: 'Database', component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
|
||||
}))
|
||||
ctx.effect(() => ctx.betterSidebar.registerFileViewer({
|
||||
id: 'my-plugin:csv', exts: ['csv'], fetchStrategy: 'custom',
|
||||
load: async (path, scope) => parseCsv(await fetchText(scope, path)),
|
||||
component: ({ customData }) => <CsvGrid rows={customData} />,
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar) already hosts **28+ ecosystem plugins** (and growing):
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="66%" alt="The built-in Add Plugins modal: recommended catalog + one-click install command" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a><br />
|
||||
<i>The built-in "Add plugins" modal in settings: recommended catalog + one-click install command + a direct link to the GitHub topic</i>
|
||||
</div>
|
||||
|
||||
### 📑 Tab Plugins (sidebar pages)
|
||||
|
||||
<details>
|
||||
<summary><b>23 plugins (click to expand)</b></summary>
|
||||
|
||||
| Plugin | ⭐ | Description |
|
||||
|---|---|---|
|
||||
| [ChenRuoT/dsh-sidebar-qa](https://github.com/ChenRuoT/dsh-sidebar-qa) | <img alt="stars" src="https://img.shields.io/github/stars/ChenRuoT/dsh-sidebar-qa?style=flat&color=4d6bfe" /> | Selection-based side Q&A — Codex-style side questions / Claude Code `/btw` |
|
||||
| [fuhefei/dsh-sentinel](https://github.com/fuhefei/dsh-sentinel) | <img alt="stars" src="https://img.shields.io/github/stars/fuhefei/dsh-sentinel?style=flat&color=4d6bfe" /> | Condition-driven wakeup: file / command / HTTP / process / webhook watches that wake the agent; dock + sidebar branch + global dashboard |
|
||||
| [jiuge2467/dsh-studio](https://github.com/jiuge2467/dsh-studio) | <img alt="stars" src="https://img.shields.io/github/stars/jiuge2467/dsh-studio?style=flat&color=4d6bfe" /> | Full-stack enhancement workbench: multi-source MCP visual debugging hub, visual thinking engine |
|
||||
| [Iwctwbh/dsh-flowglass](https://github.com/Iwctwbh/dsh-flowglass) | <img alt="stars" src="https://img.shields.io/github/stars/Iwctwbh/dsh-flowglass?style=flat&color=4d6bfe" /> | Flowglass: live session flowgraph (messages / tool groups / subagent branches) |
|
||||
| [FeatherHunter/dsh-mattpocock-skills-deck](https://github.com/FeatherHunter/dsh-mattpocock-skills-deck) | <img alt="stars" src="https://img.shields.io/github/stars/FeatherHunter/dsh-mattpocock-skills-deck?style=flat&color=4d6bfe" /> | Game-like mission system for mattpocock/skills: fog-of-war map + task bar |
|
||||
| [GULI-lab/DSH-element-source](https://github.com/GULI-lab/DSH-element-source) | <img alt="stars" src="https://img.shields.io/github/stars/GULI-lab/DSH-element-source?style=flat&color=4d6bfe" /> | Click any UI element on your dev page to jump to its Vue / React / Svelte / Angular source, straight into the chat |
|
||||
| [Lzh3070/dsh-file-review-tab](https://github.com/Lzh3070/dsh-file-review-tab) | <img alt="stars" src="https://img.shields.io/github/stars/Lzh3070/dsh-file-review-tab?style=flat&color=4d6bfe" /> | File-change review tab: line-level red/green diffs + undo + chat-line deep links |
|
||||
| [yq04/dsh-git-remotes](https://github.com/yq04/dsh-git-remotes) | <img alt="stars" src="https://img.shields.io/github/stars/yq04/dsh-git-remotes?style=flat&color=4d6bfe" /> | Git remotes tab: branches / upstream / ahead-behind, fetch with prune, ff-only pull, confirm-before-push |
|
||||
| [ztyhehe/dsh-better-sidebar-svn](https://github.com/ztyhehe/dsh-better-sidebar-svn) | <img alt="stars" src="https://img.shields.io/github/stars/ztyhehe/dsh-better-sidebar-svn?style=flat&color=4d6bfe" /> | SVN source-control tab: status / diff / log / commit / update / revert / conflict resolution — symmetric to the built-in Git panel |
|
||||
| [Melody-max114/dsh-excel-panel](https://github.com/Melody-max114/dsh-excel-panel) | <img alt="stars" src="https://img.shields.io/github/stars/Melody-max114/dsh-excel-panel?style=flat&color=4d6bfe" /> | Excel editing: xlsx preview/edit, live formula evaluation, merged cells, save back to the original file |
|
||||
| [v587d/dsh-anysearch-refs](https://github.com/v587d/dsh-anysearch-refs) | <img alt="stars" src="https://img.shields.io/github/stars/v587d/dsh-anysearch-refs?style=flat&color=4d6bfe" /> | AnySearch results as sidebar cards: query, source snippets, highlighted keywords |
|
||||
| [mlosun/dsh-docs-panel](https://github.com/mlosun/dsh-docs-panel) | <img alt="stars" src="https://img.shields.io/github/stars/mlosun/dsh-docs-panel?style=flat&color=4d6bfe" /> | Global docs panel: portable Markdown notes, readable from any workspace |
|
||||
| [lnyuqian/dsh-skill-sidebar](https://github.com/lnyuqian/dsh-skill-sidebar) | <img alt="stars" src="https://img.shields.io/github/stars/lnyuqian/dsh-skill-sidebar?style=flat&color=4d6bfe" /> | Skills panel: scans local skill directories, one-click invocation copy, pinning |
|
||||
| [g-yixuan/dsh-sidechat](https://github.com/g-yixuan/dsh-sidechat) | <img alt="stars" src="https://img.shields.io/github/stars/g-yixuan/dsh-sidechat?style=flat&color=4d6bfe" /> | Codex-style side chat + selection annotations (a thin consumer plugin) |
|
||||
| [thirsty5034/dsh-ssh-tunnel](https://github.com/thirsty5034/dsh-ssh-tunnel) | <img alt="stars" src="https://img.shields.io/github/stars/thirsty5034/dsh-ssh-tunnel?style=flat&color=4d6bfe" /> | Multi-host SSH tunnels + SSH manager tab |
|
||||
| [thirsty5034/dsh-git-forge](https://github.com/thirsty5034/dsh-git-forge) | <img alt="stars" src="https://img.shields.io/github/stars/thirsty5034/dsh-git-forge?style=flat&color=4d6bfe" /> | GitHub / Gitea accounts, project grants and push policy |
|
||||
| [YesSanSan/dsh-conversation-outline](https://github.com/YesSanSan/dsh-conversation-outline) | <img alt="stars" src="https://img.shields.io/github/stars/YesSanSan/dsh-conversation-outline?style=flat&color=4d6bfe" /> | Conversation outline tab: per-turn structure, quick jump, one-line LLM titles |
|
||||
| [Wulabalabo/dsh-sidebar-Explorer-Plus](https://github.com/Wulabalabo/dsh-sidebar-Explorer-Plus) | <img alt="stars" src="https://img.shields.io/github/stars/Wulabalabo/dsh-sidebar-Explorer-Plus?style=flat&color=4d6bfe" /> | File-manager tab: upload / move / delete / rename / new folder (write operations) |
|
||||
| [yq04/dsh-turn-review](https://github.com/yq04/dsh-turn-review) | <img alt="stars" src="https://img.shields.io/github/stars/yq04/dsh-turn-review?style=flat&color=4d6bfe" /> | Turn review: review agent changes turn by turn |
|
||||
| [Ghz114514/dsh-refpics](https://github.com/Ghz114514/dsh-refpics) | <img alt="stars" src="https://img.shields.io/github/stars/Ghz114514/dsh-refpics?style=flat&color=4d6bfe" /> | Pinterest-style reference-image search: masonry wall, sidebar board, downloads, save-to-Eagle |
|
||||
| [yzlin499/dsh-yzlin499-easy-plugins](https://github.com/yzlin499/dsh-yzlin499-easy-plugins) | <img alt="stars" src="https://img.shields.io/github/stars/yzlin499/dsh-yzlin499-easy-plugins?style=flat&color=4d6bfe" /> | A handy utility bundle for a bare-bones DSH |
|
||||
| [dong-victor/dsh-better-sidebar-starter](https://github.com/dong-victor/dsh-better-sidebar-starter) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-better-sidebar-starter?style=flat&color=4d6bfe" /> | Run-configurations tab: IDEA-style Run/Debug configs (npm / springboot / python / custom) — one-click launch, history, WebSocket live logs (ANSI colors), parallel instances, cross-platform process-tree kill |
|
||||
| [baosfeng/my-dsh-plugins](https://github.com/baosfeng/my-dsh-plugins) | <img alt="stars" src="https://img.shields.io/github/stars/baosfeng/my-dsh-plugins?style=flat&color=4d6bfe" /> | Personal multi-plugin collection (`dsh-file-activity`): a sidebar file-activity tab recording read / added / modified history and stats, flat-browsed by folder, opened with the native preview |
|
||||
|
||||
</details>
|
||||
|
||||
### 🖼️ Viewer Plugins (file previewers)
|
||||
|
||||
<details>
|
||||
<summary><b>3 plugins (click to expand)</b></summary>
|
||||
|
||||
| Plugin | ⭐ | Description |
|
||||
|---|---|---|
|
||||
| [HuanLinOTO/dsh-plugin-better-sidebar-plugin-office](https://github.com/HuanLinOTO/dsh-plugin-better-sidebar-plugin-office) | <img alt="stars" src="https://img.shields.io/github/stars/HuanLinOTO/dsh-plugin-better-sidebar-plugin-office?style=flat&color=4d6bfe" /> | Office-suite preview (.docx / .xlsx / .pptx) as a separate bundle to slim the core (in the official recommended catalog) |
|
||||
| [zemul/dsh-video-preview](https://github.com/zemul/dsh-video-preview) | <img alt="stars" src="https://img.shields.io/github/stars/zemul/dsh-video-preview?style=flat&color=4d6bfe" /> | Inline video preview: .mp4 / .webm / .mov / .mkv / .avi with a /video host route supporting HTTP Range scrubbing |
|
||||
| [dong-victor/dsh-better-sidebar-jupyter](https://github.com/dong-victor/dsh-better-sidebar-jupyter) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-better-sidebar-jupyter?style=flat&color=4d6bfe" /> | Runnable `.ipynb` notebook view: lazy-start Python kernel, streaming outputs, save-back |
|
||||
|
||||
</details>
|
||||
|
||||
### 🧰 Enhancements & Tools
|
||||
|
||||
<details>
|
||||
<summary><b>2 plugins (click to expand)</b></summary>
|
||||
|
||||
| Plugin | ⭐ | Description |
|
||||
|---|---|---|
|
||||
| [dong-victor/dsh-better-sidebar-terminal-plus](https://github.com/dong-victor/dsh-better-sidebar-terminal-plus) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-better-sidebar-terminal-plus?style=flat&color=4d6bfe" /> | Terminal enhancement: bundled Nerd Font icons, xterm glyph fixes, stable terminal cwd |
|
||||
| [Max-Null/dsh-sidebar-preview-select](https://github.com/Max-Null/dsh-sidebar-preview-select) | <img alt="stars" src="https://img.shields.io/github/stars/Max-Null/dsh-sidebar-preview-select?style=flat&color=4d6bfe" /> | Preview selection boost: select text in any sidebar preview → floating "send to session" |
|
||||
|
||||
</details>
|
||||
|
||||
> 📣 **List your plugin**: tag your repo with the `dsh-better-sidebar` topic to appear on the [topic page](https://github.com/topics/dsh-better-sidebar); then PR one `PluginEntry` into [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) / [`src/client/plugins-viewers.ts`](./src/client/plugins-viewers.ts) to join the built-in recommended catalog (data integrity is guarded by `tests/plugin-list.spec.ts`).
|
||||
|
||||
## 🆕 Recent Updates
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/user-attachments/assets/d2aea86b-a776-4f01-a6b8-b26b27314336"><img width="33%" alt="Sidebar" 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="Service API base screenshot" src="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0" /></a>
|
||||
</div>
|
||||
|
||||
**Supported DSH versions**: <a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions: 0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2" src="https://img.shields.io/badge/DSH-0.1.0--rc.8_%C2%B7_0.1.1--rc.1_%C2%B7_0.1.1--rc.2-4d6bfe" /></a> · full release history on the [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) page
|
||||
|
||||
### v0.15.1
|
||||
|
||||
All changes since v0.15.0:
|
||||
|
||||
**✨ New features**
|
||||
|
||||
- 💬 **Codex-style transcript rework for Side Chat** ([#314](https://github.com/omdsh-dev/DSH-better-sidebar/pull/314)): transcript rows became **collapsible** — tool calls, thinking and context injections share one quiet single-line chrome (chevron + label + one-line argument summary) that expands into an indented body on a hairline thread, no cards or fills; streaming labels and a creating hero shimmer (shimmer = generating), failed tools go danger, `prefers-reduced-motion` stills every loop; **the first question is no longer swallowed by the boundary prompt** — context injection and first contact are delivered as separate events (boundary + parked snapshot ride `agent.inject`, the question wakes the driver), so the transcript maps injections onto a collapsible injection row while genuine user messages — the first one included — render as user bubbles; legacy threads' first message is split out into its own bubble too
|
||||
- 📖 **README rewrite**: feature tour (real UI screenshots per feature), user-facing DSH compatibility badges, simplified install (`add` → `approve-builds` → `add`, node-pty-safe build, paste-to-DSH install prompt), 28+ plugin ecosystem with per-category collapsed listings
|
||||
|
||||
**🐛 Fixes**
|
||||
|
||||
- 🖥️ **Terminal pty stays alive across conversation switches** ([#323](https://github.com/omdsh-dev/DSH-better-sidebar/pull/323)): switching sessions is no longer treated as a transient drop — the client sends a `park` control frame on unmount and the host skips the 30s reconnect-grace countdown; switching back (`open()` cancels parked) or explicitly closing the tab resumes the normal lifecycle; agent terminals keep their indefinite lifetime
|
||||
- 📂 **File-tree upload overlay no longer intercepts Tab drags** ([#317](https://github.com/omdsh-dev/DSH-better-sidebar/pull/317)): dragging tabs (reorder / cross-pane split) across the explorer no longer shows the upload overlay or swallows the event — gated on `dataTransfer.types` containing `Files` (consistent with the panel-host shield), so tabs land normally; OS file drags behave as before
|
||||
- 💬 **Subagent auto-open debounced** ([#314](https://github.com/omdsh-dev/DSH-better-sidebar/pull/314)): Side Chat thread creation no longer pops the task page — the 0→N trigger rearms for 500ms and re-evaluates the original baseline against the live snapshot, by which time the title filter recognizes the thread; genuine subagents still auto-open
|
||||
|
||||
### v0.15.0
|
||||
|
||||
All changes since v0.14.0:
|
||||
|
||||
**✨ New features**
|
||||
|
||||
- 💬 **Side Chat (beta) tab** ([#286](https://github.com/omdsh-dev/DSH-better-sidebar/pull/286)): Codex-style side threads, **one independent tab per conversation** — the child inherits the parent's full context (completed turns + pending messages + the in-progress turn's assistant output and tool activity, honestly frozen with an "interrupted" marker); created with an identical composition (same preset / provider / model) so the first request reuses the parent's input prefix cache; threads stay invisible in the main session list with zero subagent-catalog noise; follow-ups survive DSH restarts (auto cold-resume); one-click "Save as new session" promotes the thread to a top-level session ([design](docs/plans/2026-08-20-sidechat-tab-design.md))
|
||||
- 📤 **Upload into the files window** ([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)): header "upload file / upload folder" buttons plus drag-drop (drop on the tree body = workspace root, on a directory row = that directory, on a file row = its parent directory, VSCode semantics); full-window blurred progress overlay while uploading (per-file progress + cancel / Esc); buttons disabled while busy, tree refreshes after the upload settles
|
||||
- 🧩 **Desktop compatibility in four options** ([#284](https://github.com/omdsh-dev/DSH-better-sidebar/pull/284)): "Position compatibility mode" is now a main-row dropdown — **Auto-detect** (default, conservative: only the standard Window Controls Overlay geometry contributes; real 32/36px caption-overlay heights per shell, live on maximize/restore; zero modification on plain web) / **DSH official web** (explicitly no adaptation) / **Shell preset** (built-in, opt-in; only shells that appeared in this repo's issues/PRs with 100+ stars, "detected" badge when the environment matches) / **Custom** (free-form CSS + shift distance). Documents that already carried compatibility values migrate to the custom scheme; interactive chrome opts out of desktop drag regions (`no-drag`); the bottom-push anchor is a composite selector (`[data-pane]` and `:has(> [data-slot])`)
|
||||
- 🎛️ **Settings page UI/UX modernization** ([#300](https://github.com/omdsh-dev/DSH-better-sidebar/pull/300)): the side-card secondary-settings entry is now a full-width "Feature settings" strip at the card bottom (replacing the invisible corner gear — much easier to discover); coordinated two-tone enabled state (brand activation accent + success-green check badge); every color is still `--dsw-alias-*` token-derived so skins follow automatically
|
||||
- ➕ **New entries in the recommended-plugin catalog**: `dsh-docs-panel` (global docs, [#230](https://github.com/omdsh-dev/DSH-better-sidebar/pull/230)), `dsh-flowglass` ([#261](https://github.com/omdsh-dev/DSH-better-sidebar/pull/261)), `dsh-git-forge` and `dsh-ssh-tunnel` ([#204](https://github.com/omdsh-dev/DSH-better-sidebar/pull/204)), `dsh-turn-review` ([#102](https://github.com/omdsh-dev/DSH-better-sidebar/pull/102))
|
||||
|
||||
**🐛 Fixes**
|
||||
|
||||
- ⚡ **Batched live preview for the subagent page** ([#298](https://github.com/omdsh-dev/DSH-better-sidebar/pull/298)): the old implementation polled `subagents.history` per running subagent, each poll triggering a full host-side enumeration — an O(N²) amplification that stalled the page with many concurrent subagents; now a single batch route `subagents.live` (one enumeration of the whole tree) plus one client poller with a single in-flight request; display logic and copy unchanged
|
||||
- 🖱️ **Interrupted / fast-release drags no longer roll back** ([#249](https://github.com/omdsh-dev/DSH-better-sidebar/pull/249), closes [#247](https://github.com/omdsh-dev/DSH-better-sidebar/issues/247) [#248](https://github.com/omdsh-dev/DSH-better-sidebar/issues/248)): interrupted or fast-released drags commit the last known position; HMR re-activation re-locates the center column (fixes the blank bottom panel after a hot reload)
|
||||
- 📐 **Push variables stay effective while mounted** ([#259](https://github.com/omdsh-dev/DSH-better-sidebar/pull/259), fixes [#258](https://github.com/omdsh-dev/DSH-better-sidebar/issues/258)): the bottom panel no longer flashes full-width after a drag is released
|
||||
- 🔧 **Adapted to DSH 0.1.1-rc.1 / rc.2 (@next)** ([#297](https://github.com/omdsh-dev/DSH-better-sidebar/pull/297) [#305](https://github.com/omdsh-dev/DSH-better-sidebar/pull/305)): no code changes needed
|
||||
- 🔒 **Upload-chain hardening** ([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)): empty and absolute `relativePath` segments are refused outright; uniquely named temp files (concurrent uploads stay independent, crashed processes never block later uploads); write-stream error listeners (a failing disk can no longer crash the host); client error codes unified with the wire (`too-large`), 413s localized
|
||||
|
||||
<details>
|
||||
<summary><b>Older releases (v0.12.0 – v0.14.0)</b></summary>
|
||||
|
||||
### v0.14.0
|
||||
|
||||
> ⚠️ This release requires DSH ≥ 0.1.0-rc.8. 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); `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**
|
||||
|
||||
- 🧩 **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
|
||||
|
||||
**✨ 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
|
||||
- 🔧 **Adapted 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)): fixes the `agent-presets: refusing to compose an unscoped context` error when picking a model / sending a message after DSH moved to rc.7
|
||||
|
||||
### 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
|
||||
|
||||
### 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
|
||||
|
||||
</details>
|
||||
|
||||
## ⌨️ Keyboard Shortcuts
|
||||
|
||||
| Action | Keys |
|
||||
|---|---|
|
||||
| Save edits | `Ctrl/Cmd + S` |
|
||||
| Git commit | `Ctrl + Enter` |
|
||||
| Close tab | Middle mouse button |
|
||||
| Split / merge panes | Drag tab to pane edge / middle |
|
||||
| Reference file to input | Hover the `@file` button at end of line |
|
||||
| Copy file path | Right-click row → copy relative/absolute path |
|
||||
|
||||
## 🔌 Service API
|
||||
|
||||
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). v0.12.1 completed the base capabilities (complete type exports, capability detection, state subscription, tab badges, lifecycle callbacks, targeted open, plugin-owned settings, etc.).
|
||||
|
||||
Full integration docs:
|
||||
- **[`AGENTS.md`](./AGENTS.md)** — the in-repo integration doc (full fields, matching algorithm, HMR pitfalls, declarative settings, version detection);
|
||||
- **[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)** — the external-plugin guide (with a complete minimal example).
|
||||
|
||||
### ➕ Add Plugins (recommended plugin catalog)
|
||||
|
||||
The dashed cards at the end of the "Sidebar content" / "File viewers" grids in the "Side Cards" settings section open the **Add tab plugins** / **Add preview plugins** modals: each declares its open extension point, offers a "**Browse more plugins on GitHub**" button (the [GitHub topic `dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar)), and lists the recommended catalog (name / repo / description / install script) — "**Open**" jumps to the repo, "**Copy**" writes the install command to the clipboard.
|
||||
|
||||
**Curating a new plugin**: append a `PluginEntry` to [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) (tab registrations) or [`src/client/plugins-viewers.ts`](./src/client/plugins-viewers.ts) (file-previewer registrations) and tag your repo with the `dsh-better-sidebar` topic; data integrity is guarded by `tests/plugin-list.spec.ts`.
|
||||
|
||||
## 🛠️ Development & Build
|
||||
|
||||
```sh
|
||||
pnpm install # @deepseek-ai/* devDependencies resolve to 0.1.1-rc.1 (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)
|
||||
pnpm watch # tsdown --watch
|
||||
```
|
||||
|
||||
**Architecture**: a single npm package with host/client halves — host (`src/index.ts`): `/sidebar/api/*` JSON API, `/sidebar/file` media route, `/sidebar/html` preview route, `/sidebar/ws/terminal` WebSocket (fs / git / pty / preview, all session-scoped with a trust fence); client (`src/client/index.tsx`): portal sidebar + views + interception; state persisted per session in localStorage. Organized per DSH official conventions (no default export, dual client bundles); no dependency on npm / checkout at runtime (`@deepseek-ai/*` provided by the web profile).
|
||||
|
||||
## 🔐 Security
|
||||
|
||||
- Routes protected by a Host-header trust fence (same as `/api`); `fs.write` is atomic; media/preview routes only serve files inside the session cwd; git only shells out to the CLI and never sets identity
|
||||
- HTML preview and browser tab content render in **opaque-origin sandboxed iframes** (no `allow-same-origin`/`allow-top-navigation`, `no-referrer`, all permission policies disabled); the `/sidebar/html` route carries a CSP `sandbox` + size/path bounds; the address bar rejects `javascript:`/`data:`/`file:` and local addresses like localhost
|
||||
- The UI shows the sandbox status live (red warning when off) and can temporarily unlock the current page; the settings page can disable the sandbox per feature (disabled by default, with a warning) — when off, content shares the origin with the UI; only recommended for fully trusted content
|
||||
|
||||
## ⚠️ Known Limitations
|
||||
|
||||
- Git has no push/pull/fetch; no file watcher (manual refresh); tool inline file-open buttons cannot be intercepted
|
||||
- Dragging a terminal tab to another pane remounts it (shell restarts)
|
||||
- Office-suite preview (.docx/.xlsx/.pptx) moved to the recommended office plugin (see the "Add plugins" modals in settings); without it these files fall through to the code/download fallbacks
|
||||
- Browser sandbox has no login state / third-party cookies are restricted; some sites need popup login; sites that refuse embedding via `X-Frame-Options`/`frame-ancestors` (e.g. arxiv.org) show a reason panel (with "Open in browser"); in-iframe navigation does not enter the back stack
|
||||
- HTML preview renders the saved file (not unsaved drafts)
|
||||
- No bottom panel on mobile (<768px): on narrow screens its tabs merge into the right sidebar once (after migrating back to desktop they stay in the right sidebar); the desktop bottom panel is only available on wide viewports; auto-open terminal on first bottom-panel expand does not trigger on mobile
|
||||
|
||||
## 🖥️ Platform Support
|
||||
|
||||
Windows / Linux / macOS (macOS validated daily; the rest covered by unit tests); `node-pty` prefers prebuilt binaries, otherwise a build toolchain is required (Windows VS Build Tools / Linux make+g+++python3 / macOS Xcode CLT).
|
||||
|
||||
## 💬 Community
|
||||
|
||||
WeChat / QQ group QR codes will live here. After uploading the QR images (drag them into any issue/comment to get a `user-attachments` link), replace `src` below and uncomment:
|
||||
|
||||
<div align="center">
|
||||
<!-- WeChat group QR code
|
||||
<img width="220" alt="WeChat group QR code" src="https://github.com/user-attachments/assets/REPLACE_ME" />
|
||||
-->
|
||||
<!-- QQ group QR code
|
||||
<img width="220" alt="QQ group QR code" src="https://github.com/user-attachments/assets/REPLACE_ME" />
|
||||
-->
|
||||
</div>
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
- **Code changes go through PRs**: develop on a `feat/*` / `fix/*` branch, then `gh pr create`; docs-only changes may be pushed to main directly
|
||||
- **Curate an ecosystem plugin**: tag your repo with `dsh-better-sidebar` + PR a `PluginEntry` into [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) / [`plugins-viewers.ts`](./src/client/plugins-viewers.ts)
|
||||
- **Before submitting**: `pnpm typecheck && pnpm build && pnpm test` (CI additionally gates on npm-pack → real-mount → headless-render via `pnpm test:mount`)
|
||||
- See [`AGENTS.md`](./AGENTS.md) for the repository rules (hard constraints, CI lanes, release flow)
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
<a href="https://star-history.com/#omdsh-dev/DSH-better-sidebar&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=omdsh-dev/DSH-better-sidebar&type=Date&theme=dark" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=omdsh-dev/DSH-better-sidebar&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## 👥 Contributors
|
||||
|
||||
Thanks to everyone who contributed:
|
||||
|
||||
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=omdsh-dev/DSH-better-sidebar" alt="Contributors" />
|
||||
</a>
|
||||
|
||||
## 🔗 Friends
|
||||
|
||||
- [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)
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<sub>MIT License · Built for the <a href="https://github.com/deepseek-ai/deepseek-harness">DeepSeek Harness</a> ecosystem · discover more on the <a href="https://github.com/topics/dsh-better-sidebar">dsh-better-sidebar topic</a></sub>
|
||||
</div>
|
||||
@@ -0,0 +1,49 @@
|
||||
# dsh-better-sidebar bundle patch
|
||||
#
|
||||
# This file is the `dsh.bundle.patch` layer of the published npm package: when
|
||||
# the plugin is installed through the official CLI —
|
||||
#
|
||||
# dsh plugin --profile <name> add dsh-better-sidebar@<version>
|
||||
#
|
||||
# — the command reconciles `dsh.profile.bundles` against installed packages
|
||||
# and, seeing this declaration, appends `dsh-better-sidebar` to the bundle
|
||||
# stack. The profile boot then merges THIS patch (a single `insert` of the
|
||||
# plugin row) exactly like the manual cordis.patch.yml mount line used
|
||||
# before. No profile file edits needed — one command installs and mounts.
|
||||
#
|
||||
# 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)"
|
||||
+202
-32
@@ -2,8 +2,8 @@
|
||||
|
||||
> 面向 **消费插件开发者**:如何让你的插件向 better-sidebar 注册新的侧边栏页面(tab)和文件类型预览器。
|
||||
>
|
||||
> 适用版本:**v0.4.0+**(`ctx.betterSidebar` 服务);声明式设置部分需要 **v0.4.1+**。当前版本 v0.4.3。
|
||||
> 权威代码:`src/client/service.ts`(服务实现)、`src/client/builtins/`(内置 6 tab + 8 viewer 参考实现)、`lib/types/client/service.d.ts`(类型声明)。
|
||||
> 适用版本:**v0.4.0+**(`ctx.betterSidebar` 服务);声明式设置 **v0.4.1+**;text/number 设置行 **v0.11.0+**;本文档其余新 API(badge/生命周期/定向打开/插件设置/版本探测等)**v0.12.0+**。当前版本 v0.12.0。
|
||||
> 权威代码:`src/client/service.ts`(服务实现)、`src/client/builtins/`(内置 7 tab + 9 viewer 参考实现)、`lib/types/client/service.d.ts`(类型声明)。
|
||||
|
||||
---
|
||||
|
||||
@@ -14,7 +14,7 @@ better-sidebar 从 v0.4.0 起把自己改造成一个**注册表服务**:
|
||||
- **新页面(tab)**:注册一种新的侧边栏 tab 类型,出现在侧边栏 `+` 菜单里,用户点击后在自己的分栏里打开你的 React 页面;
|
||||
- **文件预览器(file viewer)**:注册一种文件类型预览器,让用户在侧边栏打开文件时走你的渲染组件(覆盖或补充内置的 image/pdf/code 等)。
|
||||
|
||||
内置的 6 个 tab(explorer / git / subagent / terminal / editor / diff)和 8 个 viewer(image / pdf / docx / xlsx / pptx / markdown / code / binary-download)**自己也是通过同一套 API 注册的**(吃自己的狗粮),所以外部插件的能力与内置功能完全对等。
|
||||
内置的 7 个 tab(explorer / git / subagent / terminal / browser / editor / diff)和 6 个 viewer(image / pdf / markdown / html / code / binary-download)**自己也是通过同一套 API 注册的**(吃自己的狗粮),所以外部插件的能力与内置功能完全对等。
|
||||
|
||||
关键机制一句话:better-sidebar 的 client half 在 `apply()` 开头执行 `ctx.provide('betterSidebar', service)`(`src/client/index.tsx`),消费插件在 `inject` 里声明 `'betterSidebar'`,Cordis 保证服务就绪后才激活你的插件,然后你调用 `ctx.betterSidebar.registerTab(...)` / `registerFileViewer(...)` 完成注册,返回的 disposer 由 Cordis fiber 在卸载(HMR / 禁用)时自动调用。
|
||||
|
||||
@@ -32,7 +32,7 @@ better-sidebar 从 v0.4.0 起把自己改造成一个**注册表服务**:
|
||||
import type {} from 'dsh-better-sidebar' // 触发 declare module 'cordis' 类型合并
|
||||
```
|
||||
|
||||
这个 **type-only import** 在编译时被擦除,不产生任何运行时依赖,也不会触发构建纯度门(见 §9)。
|
||||
这个 **type-only import** 在编译时被擦除,不产生任何运行时依赖,也不会触发构建纯度门(见 §10)。
|
||||
|
||||
### 2.2 package.json 声明
|
||||
|
||||
@@ -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": {
|
||||
@@ -69,6 +69,17 @@ import type {
|
||||
import type { TabDescriptor } from 'dsh-better-sidebar/client/service' // 别名 ./client/api
|
||||
```
|
||||
|
||||
v0.12.0 起,服务模块还 re-export 了完整的状态词汇表,消费者可以直接命名(不再只能靠推断):
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SidebarTab, SidebarState, SidebarStore, SidebarSnapshot, SidebarDiffRef, TabType,
|
||||
SessionScope, SidebarPrefs, OpenTabSeed, SidebarSettingsRenderProps,
|
||||
} from 'dsh-better-sidebar/client/service'
|
||||
```
|
||||
|
||||
> 💡 **类型合并触发路径**:`import type {} from 'dsh-better-sidebar/client/service'` 同样会加载 `Context` 的 augmentation(`declare module 'cordis'` 在 context-types.d.ts 中)——**纯浏览器侧插件建议走 `client/service` 路径**,避免拉进宿主半的 Node 类型图(主入口 `dsh-better-sidebar` 的声明面含宿主代码;宿主消费者本就处于 Node 环境则无所谓)。client 可达声明图(`client/*` + context-types + html-route + prefs-shared)自 v0.12.0 起**零 Node 依赖**(`scripts/check-consumer-types.sh` 守护),没有 `@types/node`、`skipLibCheck: false` 也能编译。
|
||||
|
||||
---
|
||||
|
||||
## 3. 最小骨架(client half)
|
||||
@@ -149,18 +160,73 @@ interface TabDescriptor {
|
||||
*/
|
||||
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
|
||||
/**
|
||||
* 声明式设置(v0.4.1+):见 §7。
|
||||
* 声明式设置(v0.4.1+):见 §8。v0.12.0 起增加 `pluginToggles`(插件自有
|
||||
* 设置行,key 无需宿主 schema 字段)与 `render`(自定义设置面板)。
|
||||
*/
|
||||
settings?: {
|
||||
toggles?: readonly {
|
||||
key: string
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
}[]
|
||||
}
|
||||
settings?: SidebarSettingsDeclaration
|
||||
/**
|
||||
* tab 角标(v0.12.0+):tab 图标旁的小圆角 pill。number 渲染计数(99+ 封顶),
|
||||
* string 原样文本,null/undefined 不显示。每次 tab 栏渲染都会调用——保持廉价;
|
||||
* 抛错会被吞掉(不显示角标,不影响渲染)。
|
||||
*/
|
||||
badge?: (ctx: Context, scope: SessionScope, state: SidebarState) => string | number | null | undefined
|
||||
/**
|
||||
* 生命周期回调(v0.12.0+),只由 SERVICE 路径触发:
|
||||
* - onOpen:openTab 真正**新建** tab 后(dedupe/id 安全网聚焦不算打开);
|
||||
* - onActivate:tab 被聚焦时(dedupe 聚焦、id 安全网聚焦、tab 栏点击激活);
|
||||
* - onClose:closeTab 关闭 tab 后。
|
||||
* 内置专属流程(diff 拆分放置、agent 终端 reconcile)直接改 state,不触发
|
||||
* 回调——但它们只作用于内置类型(diff/terminal),外部插件的 tab 永远走
|
||||
* service 路径。回调抛错只 console.error,绝不打断打开/关闭流程。
|
||||
*/
|
||||
onOpen?: (tab: SidebarTab, scope: SessionScope) => void
|
||||
onActivate?: (tab: SidebarTab, scope: SessionScope) => void
|
||||
onClose?: (tab: SidebarTab, scope: SessionScope) => void
|
||||
/** 渲染函数 */
|
||||
component: (props: TabComponentProps) => ReactNode
|
||||
}
|
||||
|
||||
/** 声明式设置声明(v0.12.0 完整形状;行控件见 §8)。 */
|
||||
interface SidebarSettingsDeclaration {
|
||||
/** 宿主 prefs 字段行(key 必须是宿主 PrefsSchema 的字段)。 */
|
||||
toggles?: readonly {
|
||||
key: string
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
type?: 'switch' | 'text' | 'number' // v0.11.0+;缺省 'switch'
|
||||
min?: number
|
||||
max?: number
|
||||
placeholder?: string
|
||||
unit?: string
|
||||
}[]
|
||||
/** 插件自有设置行(v0.12.0+):形状同 toggles,key 插件局部,
|
||||
* 持久化在 pluginSettings[<descriptor id>],无需宿主 schema 字段。 */
|
||||
pluginToggles?: readonly {
|
||||
key: string
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
type?: 'switch' | 'text' | 'number'
|
||||
min?: number
|
||||
max?: number
|
||||
placeholder?: string
|
||||
unit?: string
|
||||
}[]
|
||||
/** 自定义设置面板(v0.12.0+):给出时齿轮弹窗渲染它而非行列表。 */
|
||||
render?: (props: SidebarSettingsRenderProps) => ReactNode
|
||||
}
|
||||
|
||||
/** settings.render 收到的 props(v0.12.0+)。 */
|
||||
interface SidebarSettingsRenderProps {
|
||||
store: SidebarStore
|
||||
service: BetterSidebarService
|
||||
prefs: SidebarPrefs
|
||||
/** 本 descriptor 自己的持久化设置 blob(pluginSettings[id])。 */
|
||||
pluginSettings: Record<string, unknown>
|
||||
/** 持久化一条本 descriptor 的插件设置(值须 JSON 可序列化)。 */
|
||||
updatePluginSetting(key: string, value: unknown): void
|
||||
/** 关闭设置弹窗。 */
|
||||
close(): void
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 `TabComponentProps`(你的页面组件收到的 props)
|
||||
@@ -263,6 +329,7 @@ ctx.effect(() =>
|
||||
| `git` | 20 | 是 | 否 | Git 面板 |
|
||||
| `subagent` | 30 | 是 | 否 | 子代理拓扑 |
|
||||
| `terminal` | 40 | 否(createTab 自增) | 否 | 终端(配额 3) |
|
||||
| `browser` | 50 | 否(createTab 铸造 browser:`<n>`,nextBrowser 自增) | 否 | 内嵌网页浏览器(沙箱 iframe;可设置关闭沙箱) |
|
||||
| `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(由 GitView 触发) |
|
||||
|
||||
你的 `id` 不可与上述重复,否则 `registerTab` 抛 `"tab type \"X\" already registered"`。
|
||||
@@ -289,10 +356,12 @@ interface FileViewerDescriptor {
|
||||
fetchStrategy: 'none' | 'fsRead' | 'mediaUrl' | 'custom' | 'binary-download'
|
||||
/** 内容嗅探(覆盖 exts):head 字节可用时,第一个 detect 返回 true 的 viewer 命中 */
|
||||
detect?: (path: string, head: Uint8Array) => boolean
|
||||
/** fetchStrategy='custom' 时的加载函数 */
|
||||
load?: (path: string, scope: SessionScope) => Promise<unknown>
|
||||
/** 声明式设置(v0.4.1+):形状同 TabDescriptor.settings */
|
||||
settings?: { toggles?: readonly { key: string; title: string | (() => string); desc?: string | (() => string) }[] }
|
||||
/** fetchStrategy='custom' 时的加载函数;v0.12.0+ 第三参 signal 在 viewer
|
||||
* 卸载/重匹配时中止(忽略 signal 的 load 也照常工作) */
|
||||
load?: (path: string, scope: SessionScope, signal?: AbortSignal) => Promise<unknown>
|
||||
/** 声明式设置(v0.4.1+):形状同 TabDescriptor.settings(v0.12.0 起 viewer
|
||||
* 卡片也有齿轮按钮,pluginToggles/render 同样可用) */
|
||||
settings?: SidebarSettingsDeclaration
|
||||
/** 渲染函数 */
|
||||
component: (props: FileViewerProps) => ReactNode
|
||||
}
|
||||
@@ -321,7 +390,7 @@ interface FileViewerProps {
|
||||
|---|---|---|---|
|
||||
| `none` | 不需要字节 | (无) | 自渲染(如纯 UI) |
|
||||
| `fsRead` | `/sidebar/api` 的 `fs.read` | `content`, `truncated` | 文本类(CSV/JSON/XML) |
|
||||
| `mediaUrl` | `/sidebar/file` 媒体路由 URL | `mediaUrl` | 图片/PDF/Office(viewer 自己 fetch 字节) |
|
||||
| `mediaUrl` | `/sidebar/file` 媒体路由 URL | `mediaUrl` | 图片/PDF(viewer 自己 fetch 字节) |
|
||||
| `custom` | viewer 的 `load()` 函数 | `customData` | 自定义协议(如远程拉取) |
|
||||
| `binary-download` | 不预览,显示下载按钮 | (无) | 无客户端渲染器的二进制格式 |
|
||||
|
||||
@@ -336,7 +405,7 @@ interface FileViewerProps {
|
||||
|
||||
> **head 字节从哪来**:第一次匹配(纯扩展名)没有 head。`fsRead` 策略读取后若文件为二进制,host 的 `fs.read` 响应会带 `head` 字段(base64,前 4KB,`src/index.ts` 的 `READ_HEAD_LIMIT`),编辑器会用它对 `detect` viewer **重匹配一次**——所以 detect 型 viewer 的实际触发场景是"扩展名匹配落空/二进制文件"。文本文件的 detect 嗅探不在内置流程内(用 `exts` 或 `custom` 策略替代)。
|
||||
|
||||
> **内置 viewer**(不可重复注册,全部 8 个):image(0) / pdf(0) / docx(0) / xlsx(0) / pptx(0) / markdown(0, fsRead) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。
|
||||
> **内置 viewer**(不可重复注册,全部 6 个):image(0) / pdf(0) / markdown(0, fsRead) / html(0, fsRead, 沙箱 iframe 预览) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。Office 三件套预览(.docx/.xlsx/.pptx)**不再内置**——已迁至推荐插件(设置页「添加插件」→ 文件预览弹窗里的 Office 预览插件)。
|
||||
> code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配被 binary-download 的 NUL detect 认领(下载按钮)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。
|
||||
|
||||
### 5.5 注册示例
|
||||
@@ -411,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 只读 + 写操作 |
|
||||
@@ -453,14 +522,79 @@ interface BetterSidebarService {
|
||||
* 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。
|
||||
* title 可选:给出时优先于 descriptor.title(editor 显示文件名);
|
||||
* 有 createTab 的 descriptor(terminal)会忽略 title/path/id。
|
||||
* url 可选:把**新建** tab 的 path 预填为 URL(侧边栏浏览器导航种子);
|
||||
* 聚焦既有 tab 时 url 不会覆写其 path。
|
||||
* 被设置禁用的类型是 no-op(console.warn 提示)。
|
||||
* scope(v0.12.0+)定向到指定 session:给出且非当前 session 时,打开落在
|
||||
* 该 session 的侧边栏状态里(没有则按 prefs 新建),不切换 UI 的激活 session;
|
||||
* 定向打开不自动展开目标 session 的面板;缺省或指向当前 session 时行为
|
||||
* 与之前完全一致。注意:available 不拦截 openTab。
|
||||
* 内容型打开(带 path/url seed)自动展开承载面板,保证落点可见。
|
||||
*/
|
||||
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void
|
||||
/** 关闭一个 tab */
|
||||
closeTab(tabId: string): void
|
||||
openTab(seed: OpenTabSeed, scope?: SessionScope): void
|
||||
/** 关闭一个 tab(未知 id 严格 no-op,无状态搅动);scope(v0.12.0+)
|
||||
* 随回调传递(含可选 cwd),缺省为 { sessionId: 当前 } */
|
||||
closeTab(tabId: string, scope?: SessionScope): void
|
||||
/** 订阅注册表变化(register/dispose 时触发) */
|
||||
subscribe(listener: () => void): () => void
|
||||
// ── v0.12.0+ ──────────────────────────────────────────────────────────
|
||||
/** 插件版本(如 '0.12.0';与 package.json 同步,测试守护) */
|
||||
readonly version: string
|
||||
/** 单调能力清单(只增不删):'badge' | 'tabLifecycle' | 'updateTab' |
|
||||
* 'openFile' | 'targetedOpen' | 'stateSubscription' | 'tabMeta' |
|
||||
* 'pluginSettings'——用 `features.includes('xxx')` 按能力 gate。 */
|
||||
readonly features: readonly string[]
|
||||
/** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。
|
||||
* session 未激活时 state/sessionId 为 undefined。 */
|
||||
getSnapshot(): SidebarSnapshot
|
||||
/** 订阅快照变化(会话切换/状态变更/prefs 写入);返回 disposer */
|
||||
subscribeState(listener: () => void): () => void
|
||||
/** 更新一个已打开 tab 的显示字段(title/path/meta);tab 不存在时 no-op */
|
||||
updateTab(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void
|
||||
/** 激活一个已打开的 tab(tab 栏点击路径;触发 descriptor.onActivate;
|
||||
* 未知 id 严格 no-op);scope(v0.12.0+)随回调传递,同 closeTab */
|
||||
activateTab(tabId: string, scope?: SessionScope): void
|
||||
/** 在 scope.sessionId 的侧边栏编辑器打开一个文件(title 缺省为文件名;
|
||||
* id 按路径派生,与内置 open-path 拦截一致,不同文件可并排打开) */
|
||||
openFile(scope: SessionScope, path: string, title?: string): void
|
||||
}
|
||||
|
||||
/** openTab 的 seed(v0.12.0 起导出命名类型)。 */
|
||||
interface OpenTabSeed {
|
||||
type: string
|
||||
title?: string
|
||||
path?: string
|
||||
diff?: SidebarTab['diff']
|
||||
id?: string
|
||||
url?: string
|
||||
/** JSON 可序列化的自定义状态,随 tab 持久化(刷新后原样恢复) */
|
||||
meta?: unknown
|
||||
}
|
||||
```
|
||||
|
||||
**版本与能力探测**(v0.12.0+):消费插件先查能力再使用新 API,老版本(或旧 DSH)下优雅降级:
|
||||
|
||||
```ts
|
||||
if (ctx.betterSidebar.features.includes('badge')) {
|
||||
// 使用 TabDescriptor.badge
|
||||
}
|
||||
if (ctx.betterSidebar.version >= '0.12.0') { /* 字符串比较即可:minor 只增 */ }
|
||||
```
|
||||
|
||||
**生命周期示例**(v0.12.0+):打开时启动资源、关闭时释放——组件卸载 ≠ tab 关闭(会话切换也会卸载),所以释放资源要用 `onClose`:
|
||||
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:db',
|
||||
title: 'Database',
|
||||
single: true,
|
||||
badge: (_ctx, _scope, state) => /* 比如打开的连接数,每次 tab 栏渲染调用,保持廉价 */,
|
||||
onOpen: (tab, scope) => { startWatcher(scope.sessionId) },
|
||||
onClose: (tab, scope) => { stopWatcher(scope.sessionId) },
|
||||
component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -472,7 +606,9 @@ interface BetterSidebarService {
|
||||
- 展示:小卡片网格(图标 + 标题 + 类型 id),**高亮 = 启用**,勾选徽标钉在卡片最右端;viewer 卡片额外显示扩展名。
|
||||
- 持久化:开关写入 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,**缺省 = 启用**,显式 `false` 才禁用)。
|
||||
- 关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开(`console.warn`)、派生流程(子代理自动展开、agent 终端自动补 tab)停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。
|
||||
- `settings.toggles`(可选):在卡片行下追加**嵌套开关**(仅父级启用时显示),绑定 `SidebarPrefs` 字段;通过卡片右下角齿轮按钮在原生弹窗中编辑(复选框行)。内置示例:subagent tab 的 `autoOpenSubagent`、terminal tab 的 `agentTerminalTools`。
|
||||
- `settings.toggles`(可选):在卡片行下追加**嵌套设置行**(仅父级启用时显示),绑定 `SidebarPrefs` 字段;通过卡片右下角齿轮按钮在原生弹窗中编辑。行控件 v0.11.0 起不限于布尔:`type: 'switch' | 'text' | 'number'`(缺省 'switch';text/number 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀)。内置示例:subagent tab 的 `autoOpenSubagent`、terminal tab 的 `agentTerminalTools` + 自定义字体行。
|
||||
- `settings.pluginToggles`(可选,v0.12.0+):**插件自有设置行**,行控件与 toggles 相同,但 key 是插件局部的——持久化在 prefs 文档的 `pluginSettings[<descriptor id>]`(开放 map,无需宿主 schema 字段)。tab 与 viewer 都可用(v0.12.0 起 viewer 卡片也有齿轮按钮)。
|
||||
- `settings.render`(可选,v0.12.0+):**自定义设置面板**——给出时齿轮弹窗渲染它而非行列表。props 含 store/service/prefs、本 descriptor 的 `pluginSettings` blob、`updatePluginSetting(key, value)` 与 `close()`;抛错会被吞掉并显示内联错误。
|
||||
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
@@ -481,18 +617,36 @@ ctx.effect(() =>
|
||||
title: 'Database',
|
||||
order: 50,
|
||||
settings: {
|
||||
// 宿主 prefs 字段行:key 必须是宿主 PrefsSchema 的字段(仅此限制)
|
||||
toggles: [{
|
||||
key: 'myPluginAutoConnect', // ⚠️ 必须是宿主 PrefsSchema 的字段,见下
|
||||
title: 'Auto connect',
|
||||
desc: 'Connect on open',
|
||||
key: 'autoOpenSubagent', // 宿主内置键
|
||||
title: 'Auto-open',
|
||||
desc: 'Open when a subagent appears',
|
||||
}],
|
||||
// 插件自有设置行:key 插件局部,持久化在 pluginSettings['my-plugin:db']
|
||||
pluginToggles: [{
|
||||
key: 'pageSize',
|
||||
title: 'Page size',
|
||||
type: 'number',
|
||||
min: 1,
|
||||
max: 100,
|
||||
unit: 'rows',
|
||||
}],
|
||||
// 或完全自定义面板(给定时代替行列表)
|
||||
render: ({ store, service, prefs, pluginSettings, updatePluginSetting, close }) => (
|
||||
<MySettingsPanel
|
||||
values={pluginSettings}
|
||||
onChange={(key, value) => { updatePluginSetting(key, value) }}
|
||||
onDone={close}
|
||||
/>
|
||||
),
|
||||
},
|
||||
component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
> ⚠️ **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools`)。外部插件的自定义键会被 settings seam 丢弃——当前版本嵌套开关只支持宿主预置的布尔偏好;想给用户暴露你自己的设置,走 DSH 自己的 settings 服务(`ctx.settings`),不要依赖这里的 `toggles`。
|
||||
> ⚠️ **`toggles` 的 key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks`)。**v0.12.0 起设置 seam 已开放**:你自己的设置走 `pluginToggles`(声明式行)或 `render`(自定义面板),值持久化在 `pluginSettings[id]`——不再需要宿主 schema 字段,也不再被 seam 丢弃。值须 JSON 可序列化(行控件只产出 string/number/boolean;自定义面板自行负责)。
|
||||
|
||||
---
|
||||
|
||||
@@ -516,6 +670,7 @@ ctx.effect(() =>
|
||||
| **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换整个面板 |
|
||||
| **id 冲突** | `registerTab` / `registerFileViewer` 对重复 id 抛错;建议用包前缀(`my-plugin:xxx`) |
|
||||
| **不要 value-import `dsh-better-sidebar`** | 即使是 `./client/service` 子路径,运行时落点也是 client bundle;只做 type-only import |
|
||||
| **client 声明图零 Node 依赖(v0.12.0+)** | `dsh-better-sidebar/client/service` 的可达声明面(含 `Context`)不引用 `node:*` / `Buffer`——纯浏览器侧插件无需 `@types/node`,`skipLibCheck: false` 也能编译(`scripts/check-consumer-types.sh` 守护;主入口含宿主声明,宿主消费者本就处于 Node 环境) |
|
||||
|
||||
---
|
||||
|
||||
@@ -535,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"
|
||||
@@ -598,7 +753,7 @@ function parseCsv(text: string): string[][] { /* ... */ }
|
||||
1. `~/.dsh/profiles/web/package.json` 的 `dependencies` 加 `"my-plugin": "link:<你的插件路径>"`;
|
||||
2. `~/.dsh/profiles/web/cordis.patch.yml` 追加挂载行(`- insert: - id: my-plugin / name: 'my-plugin'`);
|
||||
3. 在 profile 目录 `pnpm install`;
|
||||
4. 重启 `dsh web` + 浏览器硬刷新(Cmd/Ctrl+Shift+R)。
|
||||
4. 浏览器硬刷新(Cmd/Ctrl+Shift+R)即可看到效果(DSH 对 client 改动热加载,无需重启 `dsh web`;仅 host 半改动需要重启)。
|
||||
|
||||
---
|
||||
|
||||
@@ -606,13 +761,28 @@ function parseCsv(text: string): string[][] { /* ... */ }
|
||||
|
||||
better-sidebar 的内置 tab 和 viewer 就是参考实现("吃狗粮"):
|
||||
|
||||
- **`src/client/builtins/`**:6 个内置 tab(tabs.tsx)+ 8 个内置 viewer(viewers.tsx)的注册代码 + 聚合与 disposer 生命周期(index.ts)
|
||||
- **`src/client/builtins/`**:7 个内置 tab(tabs.tsx)+ 6 个内置 viewer(viewers.tsx)的注册代码 + 聚合与 disposer 生命周期(index.ts)
|
||||
- **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现(含匹配算法、dedupe、createTab、启用态 gating)
|
||||
- **`src/client/Sidebar.tsx`**:`TabContent` 分发(查 `getTab` → 调 descriptor.component;未注册 → `<OrphanedTab/>`)、`+` 菜单构建(order 排序 + available disabled + 禁用过滤)
|
||||
- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套开关 + 开关持久化)
|
||||
- **`src/client/api.ts`**:`/sidebar` API 的封装(复制其 fetch 模式到你的插件)
|
||||
- **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating 测试
|
||||
- **`tests/builtins.spec.ts`**:内置注册清单断言(6 tab + 8 viewer + 声明式元数据)
|
||||
- **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 9 viewer + 声明式元数据)
|
||||
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`**:设计文档(§17 含实施偏差记录,以现状为准)
|
||||
|
||||
调试时直接读这些文件即可看到所有 API 的真实用法。
|
||||
|
||||
---
|
||||
|
||||
## 11. 真实接入案例
|
||||
|
||||
第一个通过 `ctx.betterSidebar` 接入的三方插件:[dsh-sentinel](https://github.com/fuhefei/dsh-sentinel) —— 条件驱动的 agent 唤醒系统(文件/进程/端口/HTTP/命令/webhook 传感器,条件达成自动唤醒休眠会话)。
|
||||
|
||||
- **接入方式**:可选软依赖——client half 本地重述最小服务契约(`registerTab`),未安装 better-sidebar 时注册静默跳过,插件原有表面不受影响;
|
||||
- **注册内容**:`dsh-sentinel:watches` tab(order 60,单实例):全服务器监控表 + 最近触发历史;
|
||||
- **类型处理**:未 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 调整
|
||||
|
||||
|
||||
@@ -135,3 +135,28 @@ isViewerEnabled(id: string): boolean
|
||||
4. **清单排序**:tabs 按 `hidden` 置后 + `order` 升序(editor/diff 殿后);viewers 按 priority 降序(code 兜底最后)。
|
||||
5. **开关改为卡片点击(v0.4.1 追加)**:清单项与嵌套相关设置、`openByDefault` 全部从"复选框行"改为**可点击卡片**——`<button aria-pressed>`,整卡即开关,视觉状态即状态(启用 = 品牌描边 + 交互选中填充 + `IconCheckOutline16` 勾选徽标;禁用 = 中性 hairline + 文字降级)。嵌套相关设置渲染为更小的缩进卡片;宽度输入保持原生设置行(它是数值不是开关)。设置导航齿轮图标为宿主 shell 硬编码(`navIcon` 按 section id 映射,无插件注入点),用户选择不改宿主,保持现状。
|
||||
6. **小卡片响应式网格 + 二级设置弹窗(v0.4.1 再追加)**:清单项改为**小卡片**,置于 `repeat(auto-fill, minmax(148px, 1fr))` 响应式网格(一行自适应多个,随宽度换行);卡片结构 = 顶行(图标 + 标题省略 + **勾选徽标钉在最右端**)+ 类型/扩展名描述行(省略号截断,`title` 属性给出完整值)。声明了 `settings.toggles` 的卡片右下角渲染**齿轮角标按钮**(`IconSettingsOutline16`,仅父级启用时显示),点击打开**原生 `Modal` 弹窗**,内含 DSH 原生设置行(标题/描述 + 品牌强调原生复选框 + hairline 分隔,末行去分隔线)——二级设置不再内嵌于网格。实现细节:Modal 采用条件挂载(`settingsFor !== null && <Modal open …>`),因为 Modal 原语无条件调用 hooks,常闭挂载在测试的 dual-react(react 18.2 server / 18.3 bundled)分裂环境下会崩;弹窗行体抽成 `FeatureSettingsRows` 供 renderToString 直测。
|
||||
|
||||
---
|
||||
|
||||
## 8. v0.12.0 设置 seam 开放记录(2026-08-14,feat/plugin-api-v012)
|
||||
|
||||
设计正文「key 必须是宿主 PrefsSchema 的字段」的限制在 v0.12.0 被打开(纯增量,`toggles` 的宿主 key 语义不变):
|
||||
|
||||
- **持久化**:`SidebarPrefs` 增加 `pluginSettings: Record<string, Record<string, unknown>>`(开放嵌套 map,schema `z.dict(z.dict(z.any())).default({})`,按 descriptor id 分桶)——外部插件的自有设置不再需要宿主 schema 字段、不再被 settings seam 丢弃。`parsePrefs` 增加 `pluginSettingsMapOf` 校验(非对象整体回退 `{}`)。
|
||||
- **声明**:`SidebarSettingsDeclaration` 增加 `pluginToggles`(声明式行,控件同 toggles 的 switch/text/number,值须 JSON 可序列化)与 `render?: (props: SidebarSettingsRenderProps) => ReactNode`(自定义面板:store/service/prefs/pluginSettings/updatePluginSetting/close;抛错被吞并显示内联错误)。
|
||||
- **渲染**:齿轮弹窗体抽为 `SettingsBody`(`render` 存在时优先,否则 toggles 行 + pluginToggles 行,后者把 pluginSettings 投影到 prefs 面复用 `FeatureSettingsRows`);`settingsFor` 状态从 `TabDescriptor | null` 扩为 `TabDescriptor | FileViewerDescriptor | null`——viewer 卡片 v0.12.0 起同样有齿轮设置弹窗(§4.6 原仅 tab)。
|
||||
- **向后兼容**:旧设置文档无 `pluginSettings` 字段 → schema default `{}`;`toggles` 行为与 v0.11.0 完全一致。
|
||||
|
||||
---
|
||||
|
||||
## 9. 设置页 UI/UX 现代化(feat/settings-sidecard-uiux)
|
||||
|
||||
纯视觉/交互入口改造,无 API 与持久化语义变化(`settings.toggles` / `pluginToggles` / `render` / Modal 条件挂载契约全部不变):
|
||||
|
||||
- **二级设置入口:齿轮角标 → 卡片底部设置条**。原 `.cardGear`(16×16px 三级色透明幽灵按钮,`absolute; top:46px` 像素钉位 + `.cardWithGear` padding 耦合)可发现性不足;改为卡片底缘的**全宽设置条** `.cardSettings`(hairline 分隔、齿轮图标 12px + 「功能设置」文字标签 11px/500,hover 换 `--dsw-alias-interactive-bg-hover-accent` 品牌淡底 + brand 字色)。可见性条件不变(仅父级启用且声明 settings);aria-label 模式 `"${title} ${t('settingsPopup')}"` 不变(测试契约)。常规行齿轮 `.rowGear` 升级为 22×22 带 border-l2 边框的芯片按钮(hover 同样 accent 引导)。
|
||||
- **启用态指示(后续迭代:勾选 → 紧凑开关滑块)**:启用卡片描边从全强度品牌色柔化为 `color-mix(button-primary-fill 45%)`。第一版曾把勾选徽标改为 `--dsw-alias-state-success-primary` 实心绿(绿 = 已开启确认,brand = 激活强调);按反馈再迭代为**紧凑品牌开关滑块** `.cardSwitch`(30×16 track + 10px thumb,开 = brand 填充 + thumb 反色,关 = 中性且不渲染滑块)——纯视觉指示,卡片本身仍是开关(`aria-pressed` 语义不变),不再使用 success 色。全部颜色仍为 `--dsw-alias-*` 令牌派生,零硬编码。
|
||||
- **卡片等高**:`.card` 统一 `min-height: 106px` + `.cardMain { flex: 1 }` + `.cardTop { min-height: 28px }`——带设置条的卡片(天然更高)与不带设置条的卡片、无图标卡片全部等高;`prefers-reduced-motion` 覆盖新增的 `.cardSwitchTrack/Thumb` 过渡。
|
||||
- **二级弹窗长列表**:`.popupRows` 增加 `max-height: min(56vh, 480px)` + 内部 `overflow-y: auto`(终端等声明 6 行设置的场景下弹窗壳体与 Done 按钮固定,仅行区滚动);行密度收紧(padding 12/14、gap 8)。
|
||||
- **节奏**:section gap 16 / group padding 20 / 网格 `minmax(180px,1fr)` gap 12;计数徽章改为 `accent-soft` 淡底 pill(versionBadgeTag 同款令牌配方);开关关态 thumb 降一档(label-tertiary)+ track 底 layer-2。
|
||||
- **动效纪律**:全部过渡 ≤160ms 且仅 background/border-color/color;`prefers-reduced-motion` 扩展覆盖 `.cardSettings` / `.rowGear` / `.popupRow`。
|
||||
- **清理**:合并 `.pluginEntries` 重复定义(原 L598 与 L795 两处,保留带 max-height/overflow 的一处)。
|
||||
|
||||
@@ -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": {
|
||||
@@ -414,3 +414,19 @@ export function apply(ctx: Context): void {
|
||||
### 17.10 测试
|
||||
|
||||
新增 `tests/builtins.spec.ts`(内置注册清单)、`tests/orphaned-tab.spec.tsx`(sanitize 保留未注册类型 + 占位渲染)、`tests/editor-load.spec.ts`(策略分发纯函数);`tests/browser-globals.ts` 为拉入 xterm/CodeMirror 的 spec 提供模块求值期浏览器全局 mock。`matchFileViewer` 相关断言按 17.3 语义重写。
|
||||
|
||||
---
|
||||
|
||||
## 18. v0.12.0 基座化扩展记录(2026-08-14,feat/plugin-api-v012)
|
||||
|
||||
§5 的服务接口在 v0.12.0 做了**纯增量**扩展(无任何签名删除 / 语义变更,向上兼容),全部记录于此,文档以本节为准:
|
||||
|
||||
- **类型导出补齐**:`./client/service` 入口 re-export 公共状态词汇——`SidebarTab` / `SidebarState` / `SidebarStore` / `SidebarSnapshot` / `SidebarDiffRef` / `TabType` / `SessionScope` / `SidebarPrefs` / `OpenTabSeed` / `SidebarSettingsRenderProps`。此前消费者无法命名这些类型(TS2459),只能靠推断。
|
||||
- **client 声明图零 Node 依赖**:`context-types.ts` 的 `SidebarWebRoute` / `SidebarWebUpgradeRoute` 改用结构镜像类型(`SidebarHttpRequest` / `SidebarHttpResponse` / `SidebarUpgradeSocket` / `SidebarUpgradeHead`),宿主在 ws 边界显式转型(`src/index.ts` 两处)。外部消费插件无需 `@types/node`、`skipLibCheck: false` 也能编译(`scripts/check-consumer-types.sh` 双层校验守护)。
|
||||
- **新服务方法**:`version` / `features`(单调能力清单,只增不删);`getSnapshot` / `subscribeState`(状态订阅,委托 store);`updateTab(tabId, patch)`(title/path/meta);`activateTab(tabId)`(跨树激活,tab 栏点击路径);`openFile(scope, path, title?)`(editor 打开,id 按路径派生与内置拦截一致);`openTab(seed, scope?)`(定向到指定 session:`SidebarStore.reduceFor` 加载/变更/持久化目标 session 而不切换 snapshot,UI 不扰动;scope 指向当前 session 时走原 reduce 路径保证 notify)。
|
||||
- **新 descriptor 字段**:`badge`(tab 角标:number 计数 99+ 封顶 / string 文本 / null 不显示,每次 tab 栏渲染调用,抛错吞掉);`onOpen` / `onActivate` / `onClose`(生命周期回调,service 路径派发——dedupe/id 安全网聚焦触发 onActivate 而非 onOpen;内置 diff 拆分与 agent 终端 reconcile 直接改 state 不触发,但只作用于内置类型;回调抛错只 console.error);`SidebarTab.meta`(JSON 可序列化自定义状态,`sanitizeNode` 宽松保留,跨刷新原样恢复)。
|
||||
- **设置 seam 开放**:`PrefsSchema` 增加 `pluginSettings: z.dict(z.dict(z.any())).default({})` 开放嵌套 map;`settings.pluginToggles`(插件自有声明式行)与 `settings.render`(自定义面板,props 见 §5)替代「自定义 key 被 seam 丢弃」的限制;viewer 卡片 v0.12.0 起同样有齿轮设置弹窗。
|
||||
- **`FileViewerDescriptor.load` 第三参 `signal?: AbortSignal`**:EditorHost 在卸载/重匹配时 abort(忽略 signal 的 load 照常工作)。
|
||||
- 测试:`tests/consumer-types.ts`(消费面编译门)、`tests/api-surface.spec.ts`(client 图零 node 依赖守护 + 能力常量)、`tests/service.spec.ts` 新增生命周期 / 定向打开 / updateTab / activateTab / openFile / meta 持久化用例、`tests/unit.spec.ts` 新增 `reduceFor` 与 sanitize meta 用例。
|
||||
|
||||
实现偏差:§5.1 的 `openTab(seed)` 单参签名保留为 `openTab(seed, scope?)` 的可选参数(非破坏);`SidebarSnapshot` 类型 re-export 自 `./client/service` 而非新建独立文件。
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# 跟随 DSH 的 i18n(ctx.locale)设计
|
||||
|
||||
> 2026-08-12 · 随 v0.8.2 发布
|
||||
|
||||
## 背景
|
||||
|
||||
better-sidebar 的界面文案(约 260 处 `t()` 调用、28 个文件)此前自带 zh/en 词典并直接读
|
||||
`navigator.language`(`src/client/locales.ts` 头注释曾明言"renders outside the slot
|
||||
system's locale seat, so it reads the browser language directly")。问题:
|
||||
|
||||
- DSH 的语言偏好是 Host-backed(`locale.preference`,存于 `$DSH_HOME/settings.yaml`),
|
||||
用户设置与浏览器语言不一致时侧边栏不跟随(如偏好 zh、浏览器 en 时侧边栏仍为英文)。
|
||||
- 词典游离在 DSH 的 i18n 注册表之外,无法参与公共命名空间回退等机制。
|
||||
|
||||
DSH 的 i18n 系统(`@deepseek-ai/dsh-client-locale`):
|
||||
|
||||
- `ctx.locale` 服务(client-only):`getSnapshot()/subscribe()` 是 LocaleFace
|
||||
(uSES 安全,revision 递增),`locale/change` 事件在切换时触发;
|
||||
- `ctx.locale.register(ns, {zh, en})` 命名空间词典注册(typed 变体要求合并
|
||||
`LocaleNamespaceMap`);查询链 ns → common → zh → key;
|
||||
- 所有 DSH 客户端包以 `inject = [..., 'locale']` 消费。
|
||||
|
||||
## 设计决策
|
||||
|
||||
1. **结构镜像服务**(仓库惯例,`src/context-types.ts` 头注释):本插件解析在 DSH
|
||||
monorepo 单一 cordis 实例之外,上游 `declare module 'cordis'` augmentation 不达此
|
||||
Context。新增 `SidebarLocaleService` 结构脸(只镜像用到的切片:
|
||||
`getSnapshot` / `subscribe` / `register`),merge `Context.locale`。不引入
|
||||
`@deepseek-ai/dsh-client-locale` 依赖、不做 value import(client bundle 纯度门)。
|
||||
2. **保留模块级 `t()`**:`t()` 被 28 个文件使用,穿 props 不可行。改为模块级 holder:
|
||||
`apply()` 调 `attachLocale(ctx.locale)`,`t()`/`isZh()` 从 holder 读活跃 locale;
|
||||
无 holder 时回退既有 `navigator.language` 检测(行为与改动前完全一致,也是 jsdom
|
||||
测试与非标准组合的降级路径)。
|
||||
3. **根级重渲染触发**:侧边栏自行 `createRoot` 挂在 slot 系统之外,拿不到 `t` seat。
|
||||
在 `Sidebar.tsx` 根组件加 `useSyncExternalStore(ctx.locale.getSnapshot/subscribe)`
|
||||
(该文件已用此 API 订阅 sessions/store);树内无 `React.memo`,根重渲染即整树
|
||||
重渲染,所有渲染期 `t()` 求值读到新语言。事件期调用(intercept、api、subagent-tasks)
|
||||
在调用时刻读当前语言,天然正确。SideCardSection 由 settings shell 渲染,shell 自身
|
||||
订阅 locale revision(ui-settings-general 的 section rows 缓存含 locale revision),
|
||||
自动跟随。
|
||||
4. **命名空间 `'betterSidebar'`**:`'sidebar'` 已被 DSH 的 ui-sidebar 占用。注册走
|
||||
非类型化单语言重载 `register(ns, locale, dict)`(DSH 内 ui-permission 同款先例);
|
||||
键型安全由本地 `const en: Record<keyof typeof zh, string>` + 测试键集相等断言保证。
|
||||
5. **词典注册进 DSH 注册表**:`ctx.effect` 内注册 zh/en 并返回合并 disposer
|
||||
(fiber 卸载时撤销,HMR 重激活不抛 "already registered")。
|
||||
6. **xlsx 预览的 Univer locale pack**:`xlsx-view.tsx` 原内联 navigator 检测改走
|
||||
`isZh()`(跟随 DSH 语言;Univer 实例在挂载时创建,语言切换不重建实例——可接受)。
|
||||
|
||||
## 实施偏差记录
|
||||
|
||||
- 无。计划与实施一致。
|
||||
- 版本号随 v0.8.2 发版提交(`package.json` + `dsh.plugin.json`)。
|
||||
- v0.8.2 后补遗:md 预览(`TextEditor.tsx`)的代码块复制按钮文案——DSH 的
|
||||
`MarkdownText`/`CodeBlock` 是 cordis-free 组件,未传 `codeLabels` 时回退到硬编码中文
|
||||
「复制/复制成功」。修复为渲染时传入 `codeLabels={{ copyLabel: t('copy'), copiedLabel: t('copied') }}`
|
||||
(对齐 DSH 聊天区 `AssistantMarkdown` 的做法),新增 `tests/markdown-copy-labels.spec.tsx`
|
||||
钉住 zh/en 跟随。
|
||||
|
||||
## 验收
|
||||
|
||||
- `pnpm typecheck` / `pnpm test` / `pnpm build`(纯度门)全绿;`tests/locales.spec.ts`
|
||||
覆盖:无服务回退、attach 后跟随活跃 locale 实时切换、detach 恢复回退、占位符插值、
|
||||
relativeTime 链路、命名空间名、zh/en 键集相等。
|
||||
- 手动:设置页切换语言 → 侧边栏全部文案实时切换;`locale.preference: zh` 且浏览器 en
|
||||
→ 侧边栏中文。
|
||||
@@ -0,0 +1,89 @@
|
||||
# 客户端懒加载分块(lazy chunks)设计
|
||||
|
||||
> 日期:2026-08-12
|
||||
> 状态:已实现
|
||||
> 范围:`dsh-better-sidebar` 插件,客户端 bundle 拆分
|
||||
|
||||
## 1. 背景与问题
|
||||
|
||||
改造前客户端是单文件 bundle(`lib/client.js`,约 24.6MB),页面启动时全部**下载 + 解析 + 执行**。实测构成(按 sourcemap 源字节):
|
||||
|
||||
| 依赖 | 体积 | 用途 |
|
||||
|---|---|---|
|
||||
| `@univerjs/*` 全家桶 + echarts/zrender 等 | ~18MB | .xlsx 预览 |
|
||||
| `@codemirror/*` + `@lezer/*` | ~1.9MB | 文本编辑器高亮 |
|
||||
| `xlsx`(SheetJS) | ~0.6MB | .xlsx 解析 |
|
||||
| `@aiden0z/pptx-renderer` | ~0.5MB | .pptx 预览 |
|
||||
| `xterm` | ~0.3MB | 终端 |
|
||||
| `docx-preview` + `jszip` | ~0.2MB | .docx 预览 |
|
||||
| 插件自身代码 | ~0.35MB | — |
|
||||
|
||||
虽然 `office-view.tsx` 用了动态 `import()`,但 `codeSplitting: false` 只保证**延迟执行**(`Promise.resolve().then(() => (init_docx_preview(), ...))`),下载与解析成本仍在启动时;且 `xlsx-to-univer.ts` 对 `@univerjs/presets` 的静态 value-import 让 Univer 核心实际在启动时执行。
|
||||
|
||||
**目标**:重依赖拆成独立脚本,首次打开对应功能才下载/解析/执行;启动只拉核心(~325KB)。
|
||||
|
||||
## 2. 机制
|
||||
|
||||
chunk 脚本**不经过** `window.__ModuleLoader__` / `ClientModuleSystem.import()` 的物化路径——模块系统的 import() 只解析 seed word / shell-own 模块 / 已注册 factory / boot graph row,chunk id 四者皆非,解析行为依赖 DSH 版本(实测某些版本在 factory 已注册时仍抛 "cannot resolve")。因此改为插件自有的全局 factory 注册表:
|
||||
|
||||
1. **构建期**:每个 chunk 是独立 tsdown 浏览器 bundle(`lib/client-<name>.js`),脚本首行为:
|
||||
```js
|
||||
globalThis.__dshChunks__ = globalThis.__dshChunks__ || {};
|
||||
globalThis.__dshChunks__["terminal"] = (require) => { /* CJS 闭包体 */ };
|
||||
```
|
||||
与主 bundle 相同的 externals(react 等走 module table)、纯度门、CSS 内联管线;`codeSplitting: false` 保持单文件。
|
||||
2. **运行期** `loadChunk(name)`:
|
||||
- 注入 `<script src="/sidebar/bundle/<name>.js">`(经典同源脚本;官方 `/plugins/<id>/client.js` 路由的路径白名单无法服务任意文件名,故走插件自有路由);
|
||||
- 从 `globalThis.__dshChunks__[name]` 读取 factory(脚本执行即赋值,幂等——重复执行覆盖槽位,无 "duplicate factory registration" 错误类);
|
||||
- 用**自定义 require** 物化:externals(CLIENT_EXTERNALS 清单)经 `__DSH_MODULES__.import(spec)` 的 **seed 分支**逐一解析(页面级记忆化;解析失败的 spec 标记未解析,仅当 chunk 实际 require 时才报错)——seed 分支是模块系统最稳定、跨版本一致的部分。
|
||||
|
||||
## 3. 分块清单
|
||||
|
||||
| chunk 名 | 入口 | 内容 | 构建产物 | 触发 |
|
||||
|---|---|---|---|---|
|
||||
| `xlsx` | `src/client/chunks/xlsx.tsx` | `XlsxView` + Univer 全家桶 + xlsx + Univer CSS | lib/client-xlsx.js(~20MB) | 打开 .xlsx |
|
||||
| `docx` | `src/client/chunks/docx.tsx` | `DocxView` + docx-preview + jszip | lib/client-docx.js(~330KB) | 打开 .docx |
|
||||
| `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/*` 深导入。
|
||||
|
||||
## 4. 缓存契约(三层,各带失败路径)
|
||||
|
||||
| 层 | 机制 | 失败路径 |
|
||||
|---|---|---|
|
||||
| 内存 | `loadChunk` 每 chunk 一个 in-flight promise,并发去重,常驻到 `resetChunks` | 失败删除缓存项 → 下次调用重试 |
|
||||
| 执行 | 脚本执行即覆盖全局注册表槽位(赋值幂等) | 物化失败清缓存 → 重试重新注入 + 重新执行脚本 |
|
||||
| HTTP | `/sidebar/bundle` 路由 `cache-control: no-cache` + ETag(内容哈希,按 mtime/size 记忆化),`If-None-Match` 命中返回 304 | 页面刷新/HMR 重激活时 304 免重下载 17MB;文件变化 ETag 轮转返回 200 |
|
||||
|
||||
**HMR**:`apply()` 每次激活调用 `resetChunks()`(清内存缓存 + externals 记忆),热更新后重新拉取新代码。chunk 源码单独改动需手动刷新页面(HMR 轮询只盯 client.js)。
|
||||
|
||||
## 5. 对外 API 兼容性(硬约束)
|
||||
|
||||
- `ctx.betterSidebar` 服务方法签名与行为零变化;懒加载只作用于**内置** descriptor,外部插件注册的原样存储/渲染。
|
||||
- `component` 契约 `(props) => ReactNode` 保持:懒包装是**纯渲染函数**(函数体无 hooks,hooks 在内部 `LazyChunkView`),兼容 Sidebar 的函数式调用与 EditorHost 的 `createElement` 渲染两种风格。
|
||||
- 内置 viewer/tab 的 id、exts、priority、detect、icon、title、settings 全部不变 → `matchFileViewer` 语义、外部插件同扩展名高优先级覆盖、设置页清单不变。
|
||||
- 公开类型面(`./client/service`、`./client/api`)不变;无新增 peerDeps。
|
||||
- 契约测试:`tests/lazy-chunk.spec.tsx` 钉住"component 可直接函数调用不抛错"。
|
||||
|
||||
## 6. 边界与失败模式
|
||||
|
||||
- chunk 加载/执行/物化失败 → 视图级错误 + 重试 UI(`css.editorError` + `css.terminalRetry`),面板其余部分不受影响;
|
||||
- host 旧版本无 `/sidebar/bundle` 路由(404) → 同样的错误 + 重试,升级 host 后重试即可;
|
||||
- `window.__DSH_MODULES__` 缺失(异常环境) → 明确报错,不触网;
|
||||
- chunk 注册 id 恒定 `dsh-better-sidebar/<name>`,两渠道共用(每页只活一个渠道——双渠道并存本就双挂载,属既有破损组合);
|
||||
- 跨 chunk 复用:`t()`/`api.ts`/`SandboxStatusBar`/`clsx`/`sidebar.module.css` 等无状态小模块允许在各 chunk 内联(几十 KB 级重复),避免 chunk→主 bundle require 的脆弱耦合;
|
||||
- CSS:`sidebar.module.css` 的 tagId 与官方渠道核心一致 → 幂等跳过注入;registry 渠道会多注入一份同内容样式(数据标签差异,无功能影响);xterm.css/Univer.css 随各自 chunk 注入。
|
||||
|
||||
## 7. 实施偏差记录
|
||||
|
||||
- 原设想 React.lazy + Suspense 被否决:错误处理需 error boundary,重试语义差;改用有状态包装组件(loading/error/retry 三态),与本库"每个视图自带 loading/error/retry"风格一致。
|
||||
- 原设想 chunk 注册 id 按渠道区分(`__DSH_PLUGIN_ID__` define)被否决:chunk 文件两渠道共用,恒定槽位即可;避免重复构建 6 份产物。
|
||||
- `resetChunks` 同时清空测试注册表(测试隔离需要)。
|
||||
- `lazy-chunk.tsx` 内部渲染用 `ComponentType<any>` 状态成员规避 React 18 createElement 泛型重载限制(仅内部一处显式 any)。
|
||||
- **v2(运行时修复)**:最初实现经 `window.__ModuleLoader__.load({id})` 注册 chunk factory、再 `__DSH_MODULES__.import(id)` 物化——真实环境报 `client-modules: cannot resolve "dsh-better-sidebar/terminal"`(某 DSH 版本的 import() 不解析已注册的非 boot 图 id)。改为全局 factory 注册表(`globalThis.__dshChunks__`) + 自定义 externals require(seed 分支),彻底不依赖模块系统的 factory 解析行为;`resetChunks` 不再需要 invalidate。
|
||||
- **v3(运行时修复)**:terminal 懒包装把整个 `TabComponentProps` 原样透传,但 `TerminalView` 的 props 是 `{ scope, tabId, store }` 而 `TabComponentProps` 只有 `tab` 对象、没有 `tabId` → `tabId` 为 undefined → `isAgentTabId` 里 `undefined.startsWith` 崩溃(`dsh-better-sidebar: Cannot read properties of undefined (reading 'startsWith')`)。修复:descriptor 显式映射 `tabId={tab.id}`,懒包装的 props 泛型 = TerminalView 真实签名(`{ scope, tabId, store }`);回归测试钉住映射(lazy-chunk.spec.tsx)。**教训:懒包装的 props 泛型必须等于目标组件真实 props,而非描述符 props 全集**。
|
||||
- **v2 附加**:bundled 产物经真实 `ClientModuleSystem`(带真实 react seed)端到端验证——5 个 chunk 均能物化出真实 React 组件;产物契约由 `tests/chunk-artifact.spec.ts` 长期钉住(脚本执行赋值槽位 + factory 可被 externals require 物化)。
|
||||
@@ -0,0 +1,77 @@
|
||||
# 移动端(窄视口 <768px)侧边栏布局设计
|
||||
|
||||
**日期**:2026-08-12
|
||||
**状态**:已实施(本文档含实施偏差记录)
|
||||
**目标版本**:v0.7.0
|
||||
|
||||
## 1. 目标
|
||||
|
||||
按**视口宽度**(<768px——"真正的移动端宽度",刻意不对齐宿主 1024 断点)切换移动端侧边栏体验:
|
||||
|
||||
1. 移动端**不提供底部面板按钮**(右上角按钮簇只剩一枚右侧面板开关)。
|
||||
2. **合并显示 = 只显示右侧栏**:进入窄屏时底部面板的标签页**直接并入右侧栏**(一次性状态迁移),右侧栏成为全宽抽屉,底部面板浮层整体不存在。
|
||||
3. 附带移动端优化:新会话默认收起抽屉、文件/外链打开自动展开、宽度拖动条禁用、布局不挤压(抽屉悬浮)、窄屏标签收窄。
|
||||
|
||||
## 2. 非目标
|
||||
|
||||
- 不改持久化 schema:`bottomOpen` / `bottomHeight` / `bottomSplits` 字段原样保留。
|
||||
- 不改桌面行为:≥768px 时双面板布局与之前完全一致(含布局挤压、拐角、底部首展自动终端)。
|
||||
- 不改公开服务 API、PrefsSchema、host 半。
|
||||
- 不处理 DSH 详情栏(宿主让步链在 <~996px 自动关闭详情栏,与本改动无交互)。
|
||||
|
||||
## 3. 现状回顾
|
||||
|
||||
- `Sidebar.tsx` 渲染两个独立浮层:右侧面板(全高、宽度可拖、布局挤压 `--dsh-sidebar-width`)与底部面板(只挤中间列、高度可拖、右上角按钮簇双按钮、共享拐角)。
|
||||
- 底部面板状态字段:`bottomOpen` / `bottomHeight`(钳制 `[BOTTOM_MIN, innerHeight-PANEL_MIN]`)/ `bottomOpenedOnce` / `bottomSplits`(独立 split 树,pane/tab id 全局唯一)。
|
||||
- 首次展开底部面板自动开终端(`bottomPanelAutoTerminal` pref)。
|
||||
|
||||
## 4. 设计
|
||||
|
||||
### 4.1 断点模块(新 `src/client/breakpoints.ts`)
|
||||
|
||||
- `NARROW_MAX_WIDTH = 768`;`isNarrowWidth(width)` 纯函数;`useNarrowViewport()` hook(初始读 `window.innerWidth`,resize + rAF 节流,`typeof window` 守卫;不用 matchMedia——jsdom 未实现)。
|
||||
- CSS 侧配对 `@media (max-width: 767px)`(767 ≡ <768),两端注释互指。
|
||||
- 刻意**不对齐**宿主 `SIDEBAR_AUTO_COLLAPSE = 1024`:1024px 窗口(小笔记本、分屏)保留桌面双面板;只有手机/竖屏平板才进移动布局。
|
||||
|
||||
### 4.2 合并 = 底部标签并入右侧栏(状态迁移)
|
||||
|
||||
- **新 reducer `migrateBottomTabs(state)`**(`state.ts`):
|
||||
- 底部树所有标签按深度优先序**追加到右侧树第一个 leaf**(`firstLeaf(splits)`)的标签条;
|
||||
- 底部树清空(结构保留——桌面端回显欢迎卡片)、`bottomOpen=false`、`activePane` 指向右侧树第一个 leaf(保证迁移后新开的标签落在可见面板);
|
||||
- 幂等:底部无标签且面板已关且 activePane 不在底部树时返回同一引用。
|
||||
- **Sidebar 触发 effect**:`narrow && sessionId 已定义` 时执行(覆盖:窄屏挂载、会话切换、桌面→窄屏过渡);幂等性保证其自然收敛,不循环。
|
||||
- 迁移是**永久**的(符合"直接丢到右侧栏中"):回桌面后标签仍留在右侧树,底部面板为空(欢迎卡片);用户可在桌面重新往底部面板开标签,再进窄屏会再次迁移。
|
||||
- 迁移后右侧栏用**现有 `Workbench` 原样渲染**——零视图层特判、零 action 路由修补(标签真实住在右侧树,close/activate/drag 全部走原路径)。
|
||||
|
||||
### 4.3 窄屏渲染(`Sidebar.tsx`)
|
||||
|
||||
- 面板宽度 `100vw`(全宽抽屉);`--dsh-sidebar-*` 布局变量恒 0(抽屉悬浮,不挤压 AppFrame);宽度拖动条、底部浮层、`bottomClose`、拐角、底部面板按钮全部不渲染。
|
||||
- `visible` 语义回到原始:底部树只在桌面底部面板场景(`bottomOpen && active`)——窄屏下底部树为空且不渲染。
|
||||
- 底部首展自动终端 effect 加 `narrow` 守卫。
|
||||
|
||||
### 4.4 行为修正
|
||||
|
||||
- `state.ts` `loadState`:窄屏新会话默认 `panelOpen=false`(首开才生效;用户手动展开后照常持久化)。
|
||||
- `service.ts` `openTab`:窄屏且 seed 带 `path`/`url` 且面板收起 → 自动展开抽屉(dedupe-focus 分支同样生效);纯类型打开不展开;宽屏行为不变。
|
||||
|
||||
### 4.5 样式(`sidebar.module.css`)
|
||||
|
||||
- `@media (max-width: 767px)`:开放面板标签条预留宽度 72px → 40px(按钮簇只剩一枚);`.tab` min/max 宽收窄(48/128px)。
|
||||
|
||||
### 4.6 测试
|
||||
|
||||
- `tests/breakpoints.spec.ts`:断点边界(767 窄 / 768 宽;1024 明确为宽)。
|
||||
- `tests/unit.spec.ts`:`migrateBottomTabs` 4 例(并入第一 leaf 且底部清空/面板关/activePane 重指、右树为 split 时并入最左 leaf、幂等性、空底部树但 activePane 在底部树时重指)+ 窄屏新会话默认收起(stub `window.innerWidth=390`)。
|
||||
- `tests/service.spec.ts`:窄屏 auto-expand 5 例(390 < 768 仍窄)。
|
||||
|
||||
## 5. 边界情况
|
||||
|
||||
- 迁移触发:窄屏挂载 / 会话切换 / 桌面→窄屏过渡,均由同一 effect 覆盖;幂等保证收敛。
|
||||
- 迁移后新开标签:`activePane` 已重指右侧树第一 leaf,`openTabInActivePane` 必落在可见面板(含"空底部树但 activePane 指向它"的陈旧指针场景)。
|
||||
- 跨断点往返:迁移永久;桌面底部面板空显欢迎卡片;`bottomHeight` 不受影响。
|
||||
- jsdom 兼容:hook 不用 matchMedia;既有测试 stub `innerWidth=1024` 自动走桌面路径,零行为变化。
|
||||
|
||||
## 6. 实施偏差记录
|
||||
|
||||
- **v1(已提交 b391ebf,用户反馈推翻)**:合并 = 单面板内上下堆叠两个工作台(`MobileWorkbench` + 可拖分隔条,高度复用 `bottomHeight`),断点 1024(对齐宿主)。用户明确:合并 = 只显示右侧栏、底部标签直接丢进右侧栏;窄屏 = 真窄屏、不对齐 1024。
|
||||
- **v2(本文档,最终形态)**:删除 `MobileWorkbench`(组件 + CSS + spec),改为 `migrateBottomTabs` 状态迁移 + 现有 `Workbench` 渲染;断点 768。`bottomHeight` 不再参与移动端布局(仅桌面底部面板使用)。
|
||||
@@ -0,0 +1,43 @@
|
||||
# 任务管理页后台命令区块(查看输出 + 强制终止)设计
|
||||
|
||||
> 2026-08-12 · v0.8.0
|
||||
|
||||
## 目标
|
||||
|
||||
任务管理页(当前树的主代理拓扑,原「子代理」页)在拓扑树下方同页显示**当前树全部会话**(主代理 + 子代理)的后台任务:
|
||||
|
||||
- 点击任务行,输出显示在**侧边栏底部共享 Dock**(sticky 终端式,滚动时保持可见;运行中每 2s 轮询,页面不可见时暂停;自动跟随底部);
|
||||
- 运行中的任务行带两击确认的终止按钮;
|
||||
- **不破坏模型契约、不修改 DSH 源码**(本仓库规则:对官方源码 checkout 零写入,挂载只走 cordis.patch.yml + profile 机制):输出查看是**模型已读输出的回放**,绝不推进 `task_output` 的读取游标。
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
bash/tool run_in_background → ctx.tasks.start(owner = 执行 agent)
|
||||
api-proxy session/tasks 帧(TaskView[],无输出)→ 客户端 tasksBySession 镜像
|
||||
SubagentView 读镜像 → collectTreeTasks(树内会话) → 行渲染(单 Dock,选中行)
|
||||
点行 → POST /sidebar/api/tasks.output(事件回放)→ 扫描属主会话 events 里
|
||||
task_output 的 tool/call + tool/result 对 → 模型已读文本
|
||||
终止 → POST /sidebar/api/tasks.kill → ctx.tasks.kill(DSH 现成 API)
|
||||
```
|
||||
|
||||
## 关键决策
|
||||
|
||||
1. **输出 = 会话事件回放,零 DSH 写入**:`TaskService.read()` 是单一消费游标——UI 直接读会偷走模型 `task_output` 的字节(或读到空)。而 DSH 源码禁止修改,所以 `tasks.output` **不碰注册表**:扫描属主会话的 append-only 事件日志,取 `task_output` 调用的 `tool/call`(`arguments.task_id` 配对)与其 `tool/result`(finalize 后的文本,即模型实际收到的内容)按序拼接;`"(no new output)"` 增量与错误结果跳过;`read: false` 表示模型尚未读过。模型游标天然不被触碰。
|
||||
- 语义代价(用户已确认接受):面板只在模型读过之后才有内容——没有实时流。
|
||||
2. **列表不新增路由**:任务清单由 harness 的 `session/tasks` 推送镜像提供;只加 `tasks.output`(回放)与 `tasks.kill`(注册表原生 `kill`)两个路由(信任围栏与其余路由一致)。注册表缺席时 kill 降级 503(output 不需要注册表)。
|
||||
3. **终止的访问围栏**:caller 由请求的 `sessionId` 现场解析 `ctx.agents.get(sessionId)`,注册表按 owner session id 拒绝 foreign 任务;未知/foreign 统一映射 404 `task-error`。
|
||||
4. **展示范围 = 整棵树**:与页面作用域一致(拓扑即当前树);树跨多会话时行尾显示属主标题。
|
||||
5. **单共享 Dock(大量任务的形态)**:行内展开会让列表随任务数膨胀、排序变化时面板跳动;改为一个底部 sticky Dock + 单一轮询实例,行保持紧凑、排序变动不惊吓,选中行高亮。
|
||||
6. **两击确认终止**:首次点击进入「再次点击确认」态(3s 自动解除),防止误杀长任务。
|
||||
7. **输出上限**:路由按 `readLimit`(默认 512KB)截断并置 `truncated` 标记。
|
||||
|
||||
## 实施偏差
|
||||
|
||||
- 曾实现 harness 侧非消费 peek 缝隙(`BashProcess.peekOutput` / `TaskService.peek`),因「禁止修改 DSH 源码」规则**整体回退**(harness 提交已 reset,checkout 恢复纯净快照),改用事件回放。
|
||||
- 宿主不新增 `@deepseek-ai/*` 依赖(`ctx.tasks`/`ctx.agents` 走结构性镜像 + 运行时 `ctx.get`,可选服务降级 503);`ctx.sessions.get(id).events` 为只读访问。
|
||||
|
||||
## 验证
|
||||
|
||||
- 插件单测:tasks-routes(回放拼接/过滤/read 标志/截断/404/503 降级);subagent-tasks 纯函数;jsdom 视图(行渲染、单 Dock 切换、关闭、两击终止、read:false 提示、大量任务、隐藏不轮询、镜像清空 hook 顺序回归 #300)。
|
||||
- 集成(真实 dsh web):启动 `run_in_background` bash → 模型 `task_output` 读取 → `/sidebar/api/tasks.output` 返回模型已读内容 → `/sidebar/api/tasks.kill` 后进程退出、任务结算 `killed (signal: SIGTERM)`。
|
||||
@@ -0,0 +1,69 @@
|
||||
# 「添加插件」弹窗 + 推荐插件列表 + 复制安装命令 设计
|
||||
|
||||
**日期**:2026-08-14
|
||||
**状态**:已批准,已实施
|
||||
**作者**:opencode + 用户
|
||||
**当前版本**:v0.12.0(分支 `feat/plugin-api-v012`,已合入 main 后 rebase)
|
||||
**目标版本**:v0.12.0(不 bump)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
1. 为「侧边栏内容(Tab)」与「文件预览(viewer)」两类扩展点分别增加**添加入口**:DSH 设置页「Side card」分区的两个响应式网格(侧边栏内容 / 文件预览)末尾各新增一个与其他卡片同尺寸的**虚线边框**添加卡片(「添加 Tab 插件」/「添加预览插件」)。
|
||||
2. 点击后弹出对应 kind 的原生 Modal:声明该类扩展点可由插件扩展(`ctx.betterSidebar` 服务),提供「在 GitHub 上浏览更多插件」**按钮**(打开 `https://github.com/topics/dsh-better-sidebar`,新标签页),并展示仓库**推荐**的同 kind 插件列表(名字 / url / 简介 / 安装脚本)。
|
||||
3. 每个条目两个动作:**「跳转」按钮**(`window.open` 新标签页直达插件仓库,绕过链接接管)与**「复制」按钮**——点击只把安装脚本写入剪贴板(`writeClipboard`,按钮闪现「已复制」反馈),用户自行粘贴到 DSH 所在环境的终端执行。
|
||||
4. **不关闭任何窗口、不打开终端、无失败路径**:复制是纯 client 动作,不依赖会话/终端/配额,弹窗保持打开可连续复制多个插件。
|
||||
5. 推荐列表为仓库静态数据、**分为两个文件**(`plugins-tabs.ts` Tab 注册目录 / `plugins-viewers.ts` 文件预览注册目录),初始在 viewer 目录收录唯一真实三方插件 `@huanlin/dsh-plugin-better-sidebar-plugin-office`(简介不注版本前提);后续插件只加一条数据。
|
||||
6. 内置 Office 预览(docx/xlsx/pptx)**从本体移除**——已迁至上述推荐插件(见偏差记录),`docx/xlsx/pptx` 文件在未装插件时落 code/下载兜底。
|
||||
|
||||
## 2. 非目标(Out of Scope)
|
||||
|
||||
- **不打开终端**:不做终端预填安装流(曾实现的 `OpenTabSeed.expand`、`TerminalSeedMeta` 预填/提示条、service meta 透传等已全部删除回滚)。
|
||||
- 不加 + 菜单入口、不加编辑器空态入口(入口只在设置页)。
|
||||
- 不做插件在线拉取 / 动态发现(列表为仓库推荐静态数据)。
|
||||
- 不改 host 半的服务契约(`service.ts` 相对 v0.12.0 无 API 变化;host 半仅在 office 移除时缩减了 chunk 白名单)。
|
||||
|
||||
## 3. 实施要点
|
||||
|
||||
### 3.1 数据层:两个目录文件 + 共享词汇
|
||||
|
||||
- `src/client/plugins-shared.ts`:`PluginEntry { id, name, url, description, install }` + `PLUGIN_TOPIC_URL`。
|
||||
- `src/client/plugins-tabs.ts`:`builtinTabPlugins`(收录 dsh-sentinel——条件驱动的 agent 唤醒系统,注册 `dsh-sentinel:watches` Tab;install 用官方 github: 源一键命令)。
|
||||
- `src/client/plugins-viewers.ts`:`builtinViewerPlugins`(种子:office 插件,install = `cd ~/.dsh && dsh plugin --profile web add @huanlin/dsh-plugin-better-sidebar-plugin-office`)。
|
||||
|
||||
### 3.2 UI 层:`src/client/add-plugin-modal.tsx` + `SideCardSection`
|
||||
|
||||
- 两个网格末尾各一个虚线卡片(复用 `.card` 配方 + `IconPlusOutline16`),`addPluginsOpen: PluginKind | null` 状态;Modal 仅在打开时条件挂载(Modal 原语无条件跑 hooks,SSR 规则同 settingsFor)。
|
||||
- `PluginListBody`(提取组件,直接可测):topic 按钮(`window.open`)+「推荐插件」分组(`groupHeading` + count)+ 条目(名字链接 / 简介 / mono 安装命令 / 跳转按钮 / 复制按钮)+ 空态。
|
||||
- **复制**:`void writeClipboard(entry.install)` + `copiedId` 状态闪现 `t('copied')`(1.5s 后还原)。不关闭、不打开、不抛错。
|
||||
|
||||
### 3.3 i18n
|
||||
|
||||
新增键:`addPluginsTabCard/TabCardDesc/ViewerCard/ViewerCardDesc/TabDesc/ViewerDesc`、`addPluginsBrowseMore`、`addPluginsRecommended`(推荐插件)、`addPluginsEmpty`、`openPlugin`(跳转)、`copyInstall`(复制安装命令,按钮 aria-label);复用 `copy`/`copied`/`close`/`settingsDone`。已删除废弃键:`install`、`installNotice`、`installError*`、office 相关键(`viewerDocx/Xlsx/Pptx`、`officeTooLarge/Corrupt/Encrypted/LoadFailed`、`previousSlide/nextSlide/zoom/zoomHint`)。
|
||||
|
||||
### 3.4 内置 Office 预览移除(与推荐插件衔接)
|
||||
|
||||
删除 3 个 viewer 描述符 + 10 个源文件(docx-view/xlsx-view/PptxView/office-view/office-shared/office-types/xlsx-to-univer + chunks/docx|xlsx|pptx)+ 3 个图标 + office CSS + office locale 键;chunk 白名单 5→2(terminal/editor,同步 `bundle-route.ts` / `chunk-loader.ts` / `tsdown.config.ts` / `scripts/package-registry.mjs`);`tests/builtins.spec.ts` 断言 6 个 viewer 且不含 office id。`.docx`(zip、无 NUL)未装插件时落 code 兜底。
|
||||
|
||||
## 4. 边界情况与失败模式
|
||||
|
||||
- **复制失败**:`writeClipboard` 尽力而为(navigator.clipboard → execCommand 兜底),失败静默;按钮反馈照常闪现。无会话/无终端/配额等一切前置条件均不涉及——复制与它们无关。
|
||||
- **空目录**:目录可为空(弹窗渲染空态 + topic 按钮);当前 Tab/Viewer 目录各有 1 条。
|
||||
- **版本号**:v0.12.0(`package.json` / `dsh.plugin.json` / `service.ts` 的 `SIDEBAR_SERVICE_VERSION` 三处一致,manifest-consistency 测试守护)。
|
||||
|
||||
## 5. 实施偏差记录
|
||||
|
||||
- **评审修改(第一批)**:入口由 + 菜单/编辑器空态改为**仅设置页虚线卡片**;安装命令并入「安装按钮」单一交互。
|
||||
- **评审修改(第二批)**:① topic 链接改为**按钮**(`window.open` 新标签页);② 添加按钮拆为**两个**(Tab 注册 / 文件预览注册),列表数据拆为**两个文件**;③ 安装错误只内联显示在设置内(预检 + try/catch);④ 终端提示条用品牌高亮色。
|
||||
- **评审修改(第三批)**:内置 Office 预览整体移除(迁至推荐插件);「内置插件」全部改名**「推荐插件」**。
|
||||
- **评审修改(第四批,最终形态)**:设置窗口关闭问题无法根治(openTab 的 store 涟漪会复活弹窗),且用户实测弹窗关闭不可靠——**放弃终端安装流**:安装按钮改为**只复制安装脚本**(`writeClipboard` + 「已复制」反馈),弹窗保持打开;随之删除整个终端预填流:`terminal-meta.ts`、`TerminalView` 的 meta/预填/提示条、`tabs.tsx` 的 meta 透传与 `uiTerminalCount` 导出、`service.ts` 的 `OpenTabSeed.expand` 与 createTab meta 透传(全部回滚到 v0.12.0 原始 API 面)、`.terminalNotice` CSS、相关测试与文档;`install`/`installNotice`/`installError*` 文案键删除,新增 `copyInstall`。
|
||||
- 测试中 `AddPluginModal` 不能 renderToString(React 18 server renderer 拒绝 portal),改为 createRoot + act 挂载后断言 `document.body`(portal 落点)。
|
||||
|
||||
## 6. 验收清单
|
||||
|
||||
- [x] 设置页两个网格末尾各出现虚线边框添加卡片,尺寸与既有卡片一致
|
||||
- [x] 两个弹窗(kind 区分):声明文案 + GitHub topic **按钮** + 对应推荐插件条目(名字/url/简介/安装命令/跳转按钮/复制按钮)
|
||||
- [x] 点「复制」:安装脚本写入剪贴板,按钮闪现「已复制」,弹窗不关闭、不打开终端、无失败路径
|
||||
- [x] 点「跳转」:`window.open` 新标签页直达插件仓库
|
||||
- [x] 内置 Office 预览已移除;chunk 白名单 2 个;无「内置插件」残留文案
|
||||
- [x] 版本号 v0.12.0 三处一致;`pnpm typecheck` / `pnpm test` / `pnpm build` 全绿
|
||||
- [x] README/README_EN/AGENTS.md 更新到位
|
||||
@@ -0,0 +1,63 @@
|
||||
# Issue #42 诊断与收尾记录(底部面板首展自动终端 → 整面板空白)
|
||||
|
||||
**日期**:2026-08-14
|
||||
**状态**:已诊断、已收尾(无新代码修复;回归测试 + 文档交付)
|
||||
**关联**:issue #42、issue #25(同根因)、issue #31(放大器)、PR #27 / commit 37d91be、commit 9aeb046
|
||||
**目标版本**:v0.11.0(根因修复已发布)
|
||||
|
||||
## 1. 问题报告
|
||||
|
||||
[issue #42](https://github.com/omdsh-dev/DSH-better-sidebar/issues/42)(Windows 11 / QQBrowser / dsh-launcher 宿主 / 插件 0.10.3):
|
||||
|
||||
1. 全新安装后重启 + 硬刷新,首次展开底部面板(`bottomPanelAutoTerminal` 默认开)→ **整个底部面板空白,连标签栏都没有**;
|
||||
2. 控制台两条关键报错:
|
||||
- `Uncaught TypeError: Cannot read properties of undefined (reading 'dimensions')`(`Viewport._innerRefresh`)
|
||||
- `Uncaught TypeError: Cannot read properties of undefined (reading 'handleResize')`(`IdleTaskQueue._process`)
|
||||
3. 关闭「底部面板首次展开自动开终端」后一切正常;右侧栏终端一直正常;
|
||||
4. 附带问题:Windows 无控制台宿主下每次关闭终端,服务器日志出现 `Error: AttachConsole failed`(node-pty `conpty_console_list_agent.js`)。
|
||||
|
||||
## 2. 诊断结论
|
||||
|
||||
### 2.1 根因 = issue #25 的同一 bug
|
||||
|
||||
与 [issue #25](https://github.com/omdsh-dev/DSH-better-sidebar/issues/25)(WKWebView 底部面板终端空白)完全同源,证据链:
|
||||
|
||||
- **触发路径一致**:`Sidebar.tsx` 的「底部面板首次展开自动开终端」effect(`bottomOpenedOnce` 过渡)在面板 slide-in transition 的**同一次 commit** 挂载 `TerminalView`。`docs/plans/2026-08-14-terminal-open-when-sized-design.md` §3 在设计 #25 修复时已明确把该路径列为零尺寸挂载路径之一。
|
||||
- **崩溃签名逐字一致**:0.10.3 的 `TerminalView` 挂载 effect 同步执行 `term.open(host); fit.fit()`(无尺寸守卫)。xterm 5.3.0 在零尺寸容器 open 时渲染器创建失败,`RenderService._renderer` 保持 undefined → `Viewport._innerRefresh` 读 `_renderService.dimensions`(getter 即 `_renderer.value.dimensions`)、idle 任务读 `handleResize` 全部抛 "Cannot read properties of undefined"——与 #42 两条报错逐字对应。
|
||||
- **平台差异只是时序**:#25 设计文档注明「Chrome(Blink)对零尺寸布局更宽容所以不崩;WKWebView 稳定复现」。QQBrowser(Chromium fork)在 Windows + launcher 启动的组合下命中同一零尺寸窗口期,属边界时序,不是新缺陷。
|
||||
- **版本差**:#42 报告针对 **0.10.3**(2026-08-13 发布);根因修复 37d91be(PR #27)于 2026-08-14 合并并随 **v0.11.0** 发布(早于 #42 提交约 1 小时,晚于 reporter 安装的版本)。
|
||||
|
||||
### 2.2 修复覆盖路径(无需新代码)
|
||||
|
||||
| 层 | 机制 | 位置 | 状态 |
|
||||
|---|---|---|---|
|
||||
| 根因(零尺寸 open) | `openWhenSized`:`term.open + fit + sendResize` 推迟到容器有尺寸后恰好执行一次;RO/字体订阅的 `fit()` 全部 try/catch 加固 | `src/client/open-when-sized.ts` + `TerminalView.tsx`(37d91be,v0.11.0) | 已发布 |
|
||||
| 根因单测 | 手动步进 rAF 的 6 个用例(延迟/一次性/单维缺失/cancel/脱离文档/异常传播) | `tests/open-when-sized.spec.ts` | 已提交 |
|
||||
| 「整面板空白」放大器 | per-tab `RenderBoundary` 崩溃隔离(单 tab 崩溃只显示该 pane 内条带,toggle 簇/其他 tab/面板存活)+ 根 boundary 兜底 + layout-push CSS 变量卸载清理 | `src/client/RenderBoundary.tsx`、`Sidebar.tsx`、`index.tsx`(9aeb046,#31) | 已提交 |
|
||||
| 隔离测试 | 崩溃条带/重试恢复/布局变量清理 | `tests/sidebar-crash.spec.tsx` | 已提交 |
|
||||
| **#42 触发链回归** | 底部面板首展自动开终端:恰好一个 terminal tab 落在底部工作树、`bottomOpenedOnce` 原子置位、pref / 类型禁用双门控、面板完整存活 | `tests/bottom-auto-terminal.spec.tsx`(本次新增,4 例) | 本次提交 |
|
||||
|
||||
三者合起来构成 #42 全链回归保护:触发链(本测试)→ 零尺寸延迟(openWhenSized 单测)→ 崩溃隔离(containment 测试)。
|
||||
|
||||
## 3. 附带问题:AttachConsole 报错(仅文档化)
|
||||
|
||||
`Error: AttachConsole failed`(node-pty `conpty_console_list_agent.js`)是 **node-pty 上游行为**:`kill()` 清理路径 fork 的 console-list 辅助进程在无控制台宿主(launcher/服务方式启动的 `dsh`)下 `AttachConsole(shellPid)` 失败。终端功能本身不受影响(spawn/输入输出正常),仅刷服务器日志。
|
||||
|
||||
- 插件侧不可消除:不改 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 例);
|
||||
- `pnpm typecheck`(`tsc --noEmit`)通过;
|
||||
- `pnpm build`(`tsc -p tsconfig.build.json && tsdown`)通过。
|
||||
|
||||
## 5. Issue #42 回复草稿(供 maintainer 发布/关闭用)
|
||||
|
||||
> 感谢复现与详细报告。诊断结论:**这是 issue #25 同一根因在不同平台的复现**——底部面板首次展开自动开终端时,终端在容器尚为零尺寸(面板 slide-in 窗口期)就执行了 xterm `Terminal.open()`,渲染器创建失败导致后续 `Viewport._innerRefresh` / 空闲任务读取 `.dimensions` / `.handleResize` 崩溃(与 #25 的控制台报错逐字一致)。
|
||||
>
|
||||
> 该根因已在 **v0.11.0** 修复(终端延迟到容器有尺寸后再 open,PR #27);「整个面板空白」的放大器也已在最新 main 消除(单 tab 崩溃隔离 + 布局变量清理,issue #31)。**请升级到 v0.11.0 验证**:预期首次展开自动开终端不再空白;若仍复现,请提供 v0.11.0 下的控制台报错。
|
||||
>
|
||||
> 附带问题(`AttachConsole failed` 日志噪音)确认为 node-pty 在无控制台宿主(launcher 启动)下的上游行为,无功能影响,插件侧无法在不改动 node-pty/DSH 的前提下消除;已文档化(`docs/plans/2026-08-14-issue-42-verify-design.md`),并已补 #42 触发链回归测试。
|
||||
@@ -0,0 +1,111 @@
|
||||
# 终端自定义字体(设置页二级设置)设计
|
||||
|
||||
**日期**:2026-08-14
|
||||
**状态**:已实施(本文档含实施偏差记录)
|
||||
**目标版本**:v0.11.0
|
||||
|
||||
## 1. 目标
|
||||
|
||||
在 DSH 设置页「侧边卡片」分区中,终端 tab 卡片的二级设置弹窗(齿轮按钮,README 所称"终端卡片二级设置")新增两行:
|
||||
|
||||
1. **终端字体**(text 输入):自定义 CSS font-family stack,如 `"JetBrains Mono", monospace`;留空 = 跟随主题等宽字体(`--ds-font-family-code` token)。
|
||||
2. **终端字号**(number 输入):9–32px,默认 13,带 `px` 后缀。
|
||||
|
||||
配置持久化到 `SidebarPrefs`(`dsh-better-sidebar` 命名空间,host 侧 PrefsSchema 校验),对**所有已打开终端实时生效**(UI tab 与 agent tab 共用 TerminalView):设置页提交 → `store.setPrefs` 通知 → xterm `options.fontFamily/fontSize` 更新 + 重排网格。
|
||||
|
||||
## 2. 非目标
|
||||
|
||||
- 不在终端视图内加菜单/按钮(用户明确选择仅设置页齿轮弹窗)。
|
||||
- 不做 per-tab / per-terminal 隔离(全局偏好,与 SidebarPrefs 现有语义一致)。
|
||||
- 不自动给字体族加引号、不做字体合法性嗅探(用户负责合法 CSS font-family;placeholder 示范格式)。
|
||||
- 不改 pty / WS / 终端连接生命周期:字体变化不重连、不重启 shell。
|
||||
|
||||
## 3. 现状回顾
|
||||
|
||||
- 终端描述符(`builtins/tabs.tsx`)的 `settings.toggles` 只支持**布尔开关**(`SidebarSettingToggle` 无类型字段),`FeatureSettingsRows`(`SideCardSection.tsx`)只渲染 Switch 行。
|
||||
- `SidebarPrefs` / `PrefsSchema` / `parsePrefs` 三处同步维护(`prefs-shared.ts` / `config.ts` / `client/prefs.ts`)。
|
||||
- `TerminalView` 硬编码 `fontSize: 13` + `fontFamily: tokenValue('--ds-font-family-code') || 内置栈`,且不响应偏好变化。
|
||||
|
||||
## 4. 设计
|
||||
|
||||
### 4.1 偏好扩展(`prefs-shared.ts` / `config.ts` / `client/prefs.ts`)
|
||||
|
||||
```ts
|
||||
interface SidebarPrefs {
|
||||
// …
|
||||
terminalFontFamily: string // '' = 跟随主题等宽字体
|
||||
terminalFontSize: number // 9–32,默认 13
|
||||
}
|
||||
|
||||
// prefs-shared.ts
|
||||
export const TERMINAL_FONT_SIZE_MIN = 9
|
||||
export const TERMINAL_FONT_SIZE_MAX = 32
|
||||
export const TERMINAL_FONT_SIZE_DEFAULT = 13
|
||||
export function clampTerminalFontSize(value: number): number // round + clamp(仿 clampWidthPercent)
|
||||
```
|
||||
|
||||
- `PrefsSchema`:`terminalFontFamily: z.string().default('')`、`terminalFontSize: z.number().step(1).min(9).max(32).default(13)` —— 旧文档解析自动补默认,无迁移。
|
||||
- `parsePrefs`:family 需 string 否则默认;size 需有限数并 `clampTerminalFontSize` 否则默认。
|
||||
|
||||
### 4.2 声明式 API 扩展(`client/service.ts`,向后兼容)
|
||||
|
||||
```ts
|
||||
type SidebarSettingToggleType = 'switch' | 'text' | 'number'
|
||||
|
||||
interface SidebarSettingToggle {
|
||||
key: string
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
type?: SidebarSettingToggleType // 缺省 'switch',外部插件现有用法不变
|
||||
min?: number // number 行的下限(提交时钳制)
|
||||
max?: number // number 行的上限
|
||||
placeholder?: string // text 行的占位符
|
||||
unit?: string // 输入框后的单位后缀(如 'px')
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 终端描述符声明(`builtins/tabs.tsx`)
|
||||
|
||||
`settings.toggles` 追加两行:`{ key: 'terminalFontFamily', type: 'text', placeholder: … }` 与 `{ key: 'terminalFontSize', type: 'number', min: 9, max: 32, unit: 'px' }`(标题/描述走 locales)。
|
||||
|
||||
### 4.4 设置行渲染(`SideCardSection.tsx` + CSS)
|
||||
|
||||
- `FeatureSettingsRows` 按 `toggle.type` 分支:`switch` 走现有 Switch 行;`text`/`number` 走新 `TypedRow`(局部 draft state,`Input` 原语,blur/Enter 提交,number 行带 unit 后缀)。
|
||||
- `TypedRow` 的 `key` 含**已提交值**:提交失败 → prefs 回滚 → key 变化 → 行重挂载为存储值(输入中 draft 不因 key 变化被打断,因为 key 只随已提交值变)。
|
||||
- 父级 `onCommitSetting(toggle, raw): string`:number 解析+钳制(非法/空 → 走 `Number('')=0` 钳到下限,与宽度行 `commitWidth` 先例一致);text 原样;乐观 `setPrefs` → `commit` → 失败 `applyOutcome` 回滚(复用既有 `applyPref` 管道,原 `togglePref` 更名)。
|
||||
|
||||
### 4.5 终端消费(`client/terminal-font.ts` 新模块 + `TerminalView.tsx`)
|
||||
|
||||
```ts
|
||||
// terminal-font.ts(纯函数,可单测)
|
||||
export const DEFAULT_TERMINAL_FONT_FAMILY = '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace'
|
||||
export function resolveTerminalFont(prefs, themeFontFamily):
|
||||
{ fontFamily: 非空用户值 ?? themeFontFamily ?? 内置栈, fontSize: clampTerminalFontSize(prefs.terminalFontSize) }
|
||||
```
|
||||
|
||||
- `TerminalView` 构造 Terminal 时用解析值;主 effect 内 `store.subscribe` 回调 diff `term.options.fontFamily/fontSize`,变化则更新 options + `fit.fit()` + `sendResize()`(网格尺寸随字体变化);cleanup 退订。懒加载 chunk 无新增依赖(store 为运行时 prop)。
|
||||
|
||||
### 4.6 i18n(`locales.ts`)
|
||||
|
||||
zh/en 各新增:`settingsFontFamilyTitle/Desc/Placeholder`、`settingsFontSizeTitle/Desc/Suffix`(px)。
|
||||
|
||||
## 5. 测试
|
||||
|
||||
- `tests/unit.spec.ts`:`parsePrefs`/`loadPrefs` 新字段(合法 string 直通、越界钳制 9/32、非 number 回退、缺省默认);全等字面量补字段。
|
||||
- `tests/plugin-shape.spec.ts`:PrefsSchema 默认值 + overridden 全等。
|
||||
- `tests/builtins.spec.ts`:终端 toggles keys = 4 个,后两个 type/min/max 断言。
|
||||
- `tests/smoke.spec.ts`:settings.get 全默认字面量补字段。
|
||||
- `tests/side-card-section.spec.tsx`(SSR):text/number 行渲染(value/min/max/placeholder/unit,非 checkbox)。
|
||||
- `tests/side-card-section-rows.spec.tsx`(新,jsdom + createRoot):blur 提交回调、number 钳制、空输入钳到下限、提交失败回滚草稿(key 重挂载)。
|
||||
- `tests/terminal-font.spec.ts`(新):`resolveTerminalFont` 回退链 + `clampTerminalFontSize`。
|
||||
|
||||
## 6. 限制与取舍
|
||||
|
||||
- 外部插件的自定义 toggle key 照旧被 host PrefsSchema 校验丢弃(现有 seam 行为);`type: 'text'/'number'` 是公开 API 扩展,外部插件可用但 key 仍须是宿主 schema 字段。
|
||||
- number 输入的"非法值"在浏览器里只会以空串出现(number input 拒绝非数字字符),因此 `Number('')=0 → 钳到下限` 是唯一可达路径,与宽度行先例一致。
|
||||
- 字号实时生效依赖 xterm 的 options setter + re-fit;若未来 xterm 版本行为变化,回退方案是重建 Terminal(不推荐,会丢滚动缓冲)。
|
||||
|
||||
## 7. 实施偏差记录
|
||||
|
||||
- 计划中的"非法输入回退当前存储值"用例在实现中发现不可达(浏览器 number input 的值清洗),改为断言"空输入钳到下限"(与宽度行一致)。
|
||||
- `togglePref` 更名为 `applyPref`(它本就接受任意 patch,不只是布尔翻转)。
|
||||
@@ -0,0 +1,90 @@
|
||||
# 终端延迟初始化(零尺寸容器 xterm open 崩溃修复)设计
|
||||
|
||||
**日期**:2026-08-14
|
||||
**状态**:已实施(本文档含实施偏差记录)
|
||||
**目标版本**:v0.11.x(issue #25 修复)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
修复 [issue #25](https://github.com/omdsh-dev/DSH-better-sidebar/issues/25):WKWebView 下点击「展开底部面板」后底部面板内容空白、无终端,控制台报 `Cannot read properties of undefined (reading 'dimensions')` 与 `(reading 'handleResize')`。
|
||||
|
||||
根因:`TerminalView` 的挂载 effect 在容器可能为零尺寸时**同步**执行 `term.open(host); fit.fit()`。xterm 5.3.0 在零尺寸容器里 open 时渲染器创建失败,`RenderService._renderer.value` 保持 undefined,随后 Viewport 的 `_innerRefresh` 读 `_renderService.dimensions`(getter 即 `_renderer.value.dimensions`)崩溃。Chrome(Blink)对零尺寸布局更宽容所以不崩;WKWebView 稳定复现。
|
||||
|
||||
修复:把 `term.open(host)` + `fit.fit()` 推迟到容器有实际尺寸之后再执行(xterm 在隐藏/零尺寸容器初始化的标准修法),open 恰好执行一次。
|
||||
|
||||
## 2. 非目标
|
||||
|
||||
- 不换 xterm 渲染器(打包产物只有 DomRenderer 是现状,不是本次根因;`rendererType` 保持默认)。
|
||||
- 不改 pty / WS / 终端连接生命周期:推迟 open 不推迟连接、不重启 shell、不丢 transcript。
|
||||
- 不改 host 半与 chunk 打包结构(新模块随 terminal chunk 打入 `lib/client-terminal.js`)。
|
||||
- 不 bump 版本号(release 流程负责)。
|
||||
|
||||
## 3. 现状回顾
|
||||
|
||||
- `src/client/TerminalView.tsx` 第 99–101 行同步 `term.open(host); fit.fit()`。
|
||||
- 零尺寸挂载路径真实存在且不止一条:
|
||||
- 底部面板首次展开自动开终端(`Sidebar.tsx` 的 `bottomOpenedOnce` effect)在面板 slide-in transition 的同一次 commit 挂载 TerminalView,WKWebView 下此刻 host 高度为 0;
|
||||
- 所有 tab 保持挂载(`split-pane.tsx`:非激活 tab 用 `.paneTabHidden { display: none }` 隐藏),面板折叠时保持挂载(`.panelHidden`)——任何 `display:none` / 零尺寸祖先都会踩同一坑。
|
||||
- 前提已验证(xterm 5.3.0 与 @xterm/addon-fit 0.10.0 源码):
|
||||
- `FitAddon.fit()` 在 open 之前是安全 no-op(`proposeDimensions` 先查 `this._terminal.element`,undefined 即返回)。
|
||||
- `WriteBuffer.write()` 不依赖 open 状态:数据解析进 buffer,open 后渲染——推迟 open 不丢初始 transcript。
|
||||
- `TerminalView` 已有的 ResizeObserver / 字体订阅里的 `fit.fit()` 在 open 前 no-op、open 后照常 re-fit,无需改动。
|
||||
|
||||
## 4. 设计
|
||||
|
||||
### 4.1 新模块 `src/client/open-when-sized.ts`(纯逻辑,可单测)
|
||||
|
||||
```ts
|
||||
export function openWhenSized(
|
||||
host: HTMLElement,
|
||||
open: () => void,
|
||||
raf: (cb: FrameRequestCallback) => number = requestAnimationFrame,
|
||||
caf: (id: number) => void = cancelAnimationFrame,
|
||||
): () => void
|
||||
```
|
||||
|
||||
- 单条代码路径:`raf(step)` → step 检查 `host.isConnected`(false 则停止,防卸载后空转)→ `clientWidth > 0 && clientHeight > 0` 则调 `open()` 并停止;否则再排一帧。
|
||||
- 返回 cancel:`caf(frame)` 并置空(幂等),cleanup 调用。
|
||||
- helper 不 try/catch(保持纯净),异常由调用方处理。
|
||||
|
||||
### 4.2 消费点 `src/client/TerminalView.tsx`
|
||||
|
||||
- 移除 effect 顶部的 `term.open(host); fit.fit()`(保留 `term.loadAddon(fit)`)。
|
||||
- 在 effect 尾部(`sendResize` / `connect` 定义之后、`connect()` 调用之前)注册:
|
||||
|
||||
```ts
|
||||
const cancelOpen = openWhenSized(host, () => {
|
||||
try {
|
||||
term.open(host)
|
||||
fit.fit()
|
||||
sendResize()
|
||||
} catch (error) {
|
||||
console.error('[dsh-better-sidebar] xterm open failed:', error)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
- 回调里补 `sendResize()`:推迟路径下 socket 可能已先连上(`connect()` 在 effect 尾部同步调用),open+fit 后必须把真实 cols/rows 发出去,否则 pty 停留在默认 80x24 直到下次 resize;已尺寸快速路径下 socket 尚未 open,`sendResize` 内部守卫(`readyState === OPEN`)自然 no-op,无行为变化。
|
||||
- cleanup 里在 `term.dispose()` 前加 `cancelOpen()`。
|
||||
- 其余(connect / observer / fontSub / schemeSub / onData / 主题)不动。
|
||||
|
||||
## 5. 测试
|
||||
|
||||
`tests/open-when-sized.spec.ts`(jsdom + 注入手动步进的假 raf/caf;`Object.defineProperty` 桩 `clientWidth/clientHeight`;`appendChild/remove` 控制 `isConnected`):
|
||||
|
||||
1. 已尺寸 → tick 一次后 open 恰好一次,之后不再排帧;
|
||||
2. 零尺寸 → 多次 tick 不 open,设尺寸后下一次 tick open 一次,后续 tick 不重复;
|
||||
3. 只有一维缺失 → 补齐后 open;
|
||||
4. cancel 后永不 open、无残留帧、cancel 幂等;
|
||||
5. host 脱离文档 → 轮询停止、不 open;
|
||||
6. open 抛错 → 异常向调用方传播(调用方负责 try/catch),不重复调用。
|
||||
|
||||
## 6. 限制与取舍
|
||||
|
||||
- rAF 轮询在 host 长期隐藏(`display:none` 祖先)期间持续排帧,每帧只读两个属性,开销可忽略;open 后立即停止。用 ResizeObserver 触发也可行,但轮询是 issue 中建议的标准修法,且不依赖 observer 对 transition 中间态的回调时序。
|
||||
- 已尺寸快速路径比原行为晚一帧(rAF 回调时机),无感知差异。
|
||||
- 若容器永远零尺寸(异常布局),终端永不 open、不崩溃——对不可见内容是可接受的降级。
|
||||
|
||||
## 7. 实施偏差记录
|
||||
|
||||
无(按设计实施)。
|
||||
@@ -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`。
|
||||
@@ -0,0 +1,79 @@
|
||||
# 桌面端兼容统一方案:四选项下拉(自动检测 / DSH官方Web / 壳预设 / 自定义 CSS)
|
||||
|
||||
> 2026-08-20 · 对应分支 `feat/desktop-scheme-settings` · 关联 issue #257、#208、#103/#111;外部 PR #82/#89/#111/#153/#226/#243 处置见文末。
|
||||
|
||||
## 背景与问题
|
||||
|
||||
better-sidebar 的桌面端适配历史上是"每壳一条补丁":右上角开关簇与原生标题栏按钮重叠(#87/#101)、点击被拖拽区吞掉(#103/#111)、推挤锚点随 DSH DOM 版本漂移(#208/#226/#262)、WCO 高度硬编码 32px 与实际不符(#257,Electron 版本间 32/36px 不一)。这些补丁全部针对**具体某个壳的 DOM/类名/数值**,换一个壳(Tauri、Pake、新壳)就失效或出错。
|
||||
|
||||
**全仓证据盘点**(121 issues + 159 PRs 全量 + 三个候选壳克隆取证):
|
||||
|
||||
| 壳 | ⭐ | issue/PR 提及 | 形态 | 需要什么 |
|
||||
|---|---|---|---|---|
|
||||
| anywhere-labs/deepseek-harness-desktop | 16.3K | ✅ 大量 | Electron;advanced: win32 WCO 32px / darwin caption 20px;compat 原生框 | win32 已被标准 WCO 覆盖;darwin 20px 让位 |
|
||||
| dataelement/dsh-desktop | 1.3K | ❌ 零提及 | Electron;win32 WCO 36px | 已被标准 WCO 覆盖 |
|
||||
| Deepseek-Harness-EAC | 1.0K | ❌ 零提及 | Electron Win-only;自绘 36px 玻璃栏(私有标记 `data-dsh-title-bar-height`) | 无 WCO,需 36px 让位(文档示例) |
|
||||
| hairyf Tauri 版 | 715 | ✅ pr#229 | Tauri2;44px 导航栏在 iframe **之上** | **无冲突**,无需适配 |
|
||||
|
||||
平台分布结论:兼容问题**不是 Windows 独有**(macOS 6 条、Web 3 条),但当前 main 上未闭环的 chrome 兼容基本就是 **Windows 顶栏**(WCO 几何 + 通用 no-drag 兜底);macOS 的坏 bug(#25/#208)已修复。
|
||||
|
||||
## 决策
|
||||
|
||||
1. **核心只留 Web 标准机制**:WCO(`navigator.windowControlsOverlay` + `geometrychange`)是唯一"不是适配、是标准"的信号;`-webkit-app-region: no-drag` 是标准属性(普通浏览器/无拖拽区壳惰性无害)。核心代码 grep 不到任何壳类名。
|
||||
2. **壳专属适配进用户空间**:「位置兼容模式」升级为**主行下拉**四选项:**自动检测(默认,保守,web 零修改)/ DSH官方Web(显式零适配)/ 壳兼容方案(内置预设,opt-in)/ 自定义方案(自由 CSS + 下移 px,保留齿轮弹窗)**。
|
||||
3. **预设准入规则**:issue/PR 中被提及(链接或明确名称)且 GitHub ⭐>100 的壳;v1 仅 anywhere-labs(唯一同时满足者)。预设只做**标题栏 strip 让位**,不做布局推挤/壳类名(尊重"不特意兼容 DSH Desktop 高级模式")。Tauri 无冲突只写文档;EAC 未提及但提供 custom CSS 示例。
|
||||
4. 不向 anywhere-labs 提跨仓库 PR;不修改 DSH 源码。
|
||||
|
||||
## 实施
|
||||
|
||||
### prefs 模型(`src/prefs-shared.ts` / `src/config.ts` / `src/client/prefs.ts`)
|
||||
|
||||
- 新增 `titleBarScheme: 'auto' | 'web' | 'preset' | 'custom'`(默认 `'auto'`)、`titleBarPresetId: string`、`customCss: string`。`web` = 显式「DSH官方Web」,取值链最优先强制 0(连 WCO 也不适用)。
|
||||
- schema 中三个新字段**无 default**(schemastery 验证:缺失字段不出现在解析结果里)→ `parsePrefs` 在字段缺失时由旧 `titleBarCompat` 派生:`true` → `custom`(保留 `titleBarStripPx`);`false`/缺失 → `auto`。写路径镜像写旧字段(`titleBarCompat = scheme !== 'auto'`)供降级。
|
||||
|
||||
### 标准件(`src/client/wco.ts` + `src/client/titlebar-strip.ts`)
|
||||
|
||||
- `wco.ts`:模块级反应式 store(subscribe/getSnapshot,`useSyncExternalStore` 消费),特性检测 `navigator.windowControlsOverlay`,订阅 `geometrychange` 重读矩形(最大化/还原实时更新);最后订阅者退订时解绑原生监听;`setWcoSourceForTests` 注入测试源。API 抛错视为 present+0(绝不崩布局)。
|
||||
- `titlebar-strip.ts`(唯一决策点,纯函数):**⓪ `web` 方案强制 0 → ① WCO 真实高度(权威,0 也权威;`visible=false` 幽灵 API 视为缺失)→ ② URL `dsh-desktop-titlebar-inset`(0–120 clamp)→ ③ 预设 `stripFor`(仅 preset 方案)→ ④ 手动 `titleBarStripPx`(仅 custom 方案)→ ⑤ 0**。结果驱动 `body[data-dsh-title-bar-compat]` + `--dsh-title-bar-strip`(CSS 契约不变)。
|
||||
- `desktop-env.ts`:只做环境报告(desktop/mode/platform/titlebarInset),SSR 安全;移除旧 `win32OverlayTop` 硬编码。
|
||||
|
||||
### 预设与用户 CSS(`src/client/shell-presets.ts` + `Sidebar.tsx` effect)
|
||||
|
||||
- `ShellPreset { id; title; desc; stripFor?; css?; detect? }`;v1 一条 `dsh-desktop`:darwin advanced 20px、win32 advanced 32px(无 WCO 兜底)、其余 undefined;`detect` 仅用于设置页「已检测」徽标。
|
||||
- 注入:预设 css → `<style data-dsh-preset-css="<id>">`;自定义 css → `<style data-dsh-custom-css="custom">`;追加到 `document.head` 末尾(后写胜出),fiber 卸载/变更即移除。
|
||||
- 稳定寻址面:`[data-dsh-toggle-cluster]`、`[data-dsh-panel]`、`[data-dsh-bottom-panel]` data 属性。
|
||||
|
||||
### no-drag 兜底(`sidebar.module.css`)
|
||||
|
||||
`.toggleCluster` / `.toggleButton` / `.tabBar` 统一 `-webkit-app-region: no-drag`(吸收 #111/#153,补 #103 插件侧缺口)。
|
||||
|
||||
### Web 推挤锚点加固(`layout.css`)
|
||||
|
||||
复合选择器(同元素双保险):`#root [data-dsh-frame] > [data-pane="conversation"]` 与 `#root :has(> [data-slot="conversation"])`;拖拽过渡/prefers-reduced-motion 同步;`drag-layout.e2e.ts` 新增两选择器同元素断言。
|
||||
|
||||
### 设置 UI(`SideCardSection.tsx` + locales)
|
||||
|
||||
「位置兼容模式」行改为**主行下拉**:自动检测(默认)/ DSH官方Web / 各壳预设(命中环境带「已检测」后缀,仅提示)/ 自定义方案(选中后行内出现齿轮,弹窗 = 下移距离 + CSS textarea(Cmd/Ctrl+Enter 提交))。
|
||||
|
||||
### 测试
|
||||
|
||||
- 单测:`wco.spec.ts`(7 例:检测/读取/取整/geometrychange 通知/抛错降级/换源)、`titlebar-strip.spec.ts`(取值链 5 例)、`shell-presets.spec.ts`(注册表完整性 + dsh-desktop strip 值)、`desktop-env.spec.ts`(重写:inset 参数 clamp + 移除 win32 硬编码)、`prefs.spec.ts`(新字段 + 旧字段迁移)、`plugin-shape.spec.ts`(schema 无默认值语义)、`smoke.spec.ts`(解析结果不含新字段)。
|
||||
- e2e(`mount.e2e.ts` 重写桌面场景):保守 auto(stamp 不触发修改 + data 属性 + no-drag 规则存在)、WCO mock 驱动 strip 且 geometrychange 反应、预设注入(settings.update 写入 scheme=preset → 32px + style 标签)、自定义 CSS 注入(marker 生效);`drag-layout.e2e.ts` 复合锚点。
|
||||
|
||||
## 验证
|
||||
|
||||
- `pnpm typecheck` / `pnpm test`(691 passed)/ `pnpm test:mount` 全绿。
|
||||
- 普通浏览器零行为变化(auto 无 WCO → 无 strip)。
|
||||
- Electron WCO win32(mock 实测):strip=真实高度(32/36),最大化/还原跟随。
|
||||
- anywhere-labs darwin advanced:启用预设后 20px 让位。
|
||||
- 核心代码 grep 无壳类名(`.dshDesktopFrame` 等仅存在于预设数据字符串与测试断言之外——v1 预设无 CSS,纯 strip 数据)。
|
||||
|
||||
## 实施偏差记录
|
||||
|
||||
- 无。计划与实施一致;仅补充:schema 新字段必须无 default 才能让旧文档迁移(否则 settings 服务会把 default 填进解析结果、吞掉迁移),`plugin-shape.spec.ts` 断言了这一点。
|
||||
|
||||
## 存量 PR/issue 处置(合并后执行,均含哲学声明)
|
||||
|
||||
- 实现后关闭:#257(WCO 标准件)。
|
||||
- 关闭:#111/#153(no-drag 吸收)、#226(复合锚点吸收,main 已修)、#243(布局推挤不采纳;标题栏部分进 dsh-desktop 预设)、#108(#232 已吸收 + WCO)、#82/#89/#138(不采纳,指向自定义 CSS/预设路径)。
|
||||
- 回复:#208(已修+回归)、#221(自定义 CSS 可微调)、#233(git.ts windowsHide 独立问题)、#167/#240(非 chrome 范畴/文档已有)。
|
||||
@@ -0,0 +1,97 @@
|
||||
# 侧边对话(Side Chat)Tab 设计
|
||||
|
||||
**日期**:2026-08-20
|
||||
**状态**:已批准,实施中
|
||||
**作者**:opencode + 用户
|
||||
**当前版本**:v0.14.1(分支 `feat/sidechat-tab`)
|
||||
**目标版本**:v0.14.1(不 bump)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
1. 对标 Codex 的 side 对话,为 better-sidebar 新增内置 Tab **sidechat**:在当前主会话下开一个**继承主会话完整上下文**的独立子会话线程,线程在 Tab 内可续聊、可查看完整时间线(user / assistant / reasoning / tool)。
|
||||
2. **继承必须是结构化的**:线程种子 = 父会话点击时刻的全部事件——已完成回合 + 未回答的 user 消息 + **进行中回合的 assistant 流式输出与工具调用**(原样复制,合成 `step/end`+`turn/end{reason:'interrupted'}` 诚实闭合,子会话看到的是「被切断的进行中回合」而非完整定稿)。
|
||||
3. **前缀缓存复用**:子会话与父会话同组合(同 preset / 同 provider/model、无 persona/toolFilter、非 continuable 无 report 增量)→ 子会话首个请求的 token 前缀与父会话当前请求一致(到未回答 user 消息为止),命中 provider 前缀缓存。
|
||||
4. **即用即销语义 + 保存为新会话**:线程以 `origin:'subagent'` 创建(主会话列表不可见、子代理目录零噪音);用户可一键「保存为新会话」——`session.fork` 提升为顶层会话(自动命名、挂工作区、打开)。
|
||||
5. 零 dsh-subagent 依赖;dsh-subagent 缺失时功能不受影响。
|
||||
|
||||
## 2. 非目标(Out of Scope)
|
||||
|
||||
- 不做 `/btw` 一次性侧问(只做持续可续聊线程)。
|
||||
- 不做合并回主会话 / 重命名线程。
|
||||
- **不改 DSH 源码**:不加 promote RPC、不改 fork 边界、不改 continuation 机制。
|
||||
- 工具执行中途(悬挂 tool/call)的事件级继承不可行(provider 拒绝悬挂 assistant 调用)→ 该窗口回退为「截断 + 结构化文本转储」(见 §4)。
|
||||
|
||||
## 3. 为什么自建机制(重实现)
|
||||
|
||||
用户明确拍板:不走 `ctx.subagents.startContinuable`。原因(已核实源码):
|
||||
|
||||
1. **种子粒度受限**:fork provider 的 `completedTurnPrefix` 固定切在最后一个 `turn/end`(`subagent-fork-in-process/src/index.ts`),`session.fork` RPC 显式拒绝 open-turn 锚点(`core/session` `OPEN_TURN`)——进行中回合永远进不了种子。
|
||||
2. **continuable 组合破坏前缀复用**:continuable 子会话的请求头部在继承历史之前插入 `report` 工具 + `tool:report` 提示段(`.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md`,issue #2124)——前缀从请求头即分叉,重发整个转录却零 cache 收益。DSH 官方因此把 fork 绑定为 one-shot。
|
||||
3. **无法注入自定义种子**:`ContinuableStartSpec` 无 seed 字段,创建路径无注入点。
|
||||
|
||||
自建机制只复用 DSH 公开 host 能力:`ctx.agents.create/resume`(AgentRegistry 公开方法,api-proxy fork 与 subagent driver 同用)、`agent.followup/cancel`(公开方法)、`ctx.get('agentPresets')`、`ctx.get('sessionTitle')`、`ctx.get('sessionPersistence')`。客户端复用 `ctx.sessions.list / fork / binding / open` 与通用 `session.history` RPC。
|
||||
|
||||
## 4. 种子算法(`src/sidechat-core.ts`)
|
||||
|
||||
1. 复制父会话**全部事件**(点击时刻快照;live 日志 seq === 数组下标,保持连续)。
|
||||
2. 日志结束于 turn 外(最后 turn 边界是 turn/end 或无 turn):种子 = 全部复制事件(可能以未回答 user 消息结尾,合法)。
|
||||
3. 结束于 open turn 内:
|
||||
- **当前 step 存在无配对 tool/call(悬挂)** → 回退:切到该 turn 的 `turn/start` 之前(保留已完成回合 + 未回答 user 消息);进行中内容(含执行中的工具调用,标注 executing)走 `buildOpenTurnSnapshot` 结构化文本转储并入 boundary 消息。原因:validator 不拒悬挂调用(step/end 清空 pendingCalls),但 **provider 拒绝悬挂 assistant 调用**(`core/session/src/repair.ts:108`)。
|
||||
- 否则:追加合成 `step/end{turn,step}`(若 step 未闭合)+ `turn/end{turn, reason:{kind:'interrupted'}}`(编号取自已复制事件的 turn/start、step/start)→ 种子平衡合法(种子逐事件过同一不变式检查器,`core/session/src/index.ts:517-541`;`turn/end` schema 要求 reason,`interrupted` 是合法持久化变体,语义即「回合未完成被切断」——诚实标记,客户端对冻结局部渲染「已停止」)。
|
||||
|
||||
子会话首个请求(常见路径)= [已完成回合][未回答 user][冻结的进行中回合:chunk 文本 + 已完成工具调用/结果][boundary+question]。前缀复用不受影响:共享前缀到未回答 user 消息为止,冻结回合位于共享前缀之后。
|
||||
|
||||
## 5. 线程生命周期(host 路由,`/sidebar/api/sidechat.*`,既有信任 fence)
|
||||
|
||||
- **start** `{sessionId, question}`:父 Agent 必须 live;`buildSidechatInheritance` 出种子 + 转储;`ctx.agents.create({meta:{cwd,parentSession,seedLength,origin:'subagent',delegationDepth:父+1,agentPreset}, seed, agentOptions:父 provider/model, setup:preset 组合})`;`agent.followup(createUserMessage({content:[boundary+转储?+question], source:{kind:'user'}}))`;`sessionTitle.rename(session,'Side: '+截断(question,48))` 钉死线程标签;返回 `{childId}`。
|
||||
- **prompt** `{childId, text}`:live → `followup`;absent(重启/关闭后)→ `ctx.agents.resume`(`resolvePresetId` 从 persisted header/events 解析 preset,`agentPresets.resolve/mount` 组合)→ `followup`。
|
||||
- **cancel** `{childId}`:`agent.cancel({kind:'user'},{keepInbox:true})`。
|
||||
- **dispose** `{childId}`:释放创建/恢复时保存的 AgentHandle disposer(会话与历史保留)。
|
||||
|
||||
为什么全走自有路由:`origin:'subagent'` 的会话被通用 RPC 的 agent-lookup 所有权 fence 全面封锁(`api/remotes/agent-lookup.ts` `hasApiRemoteSubagentOwner`),`subagents.prompt` 对我们的线程也无法寻址——自有路由直接调 registry/agent 绕开 fence,且只依赖公开方法。~~线程没有 subagent/descriptor(无目录注册、零噪音:`list-children.ts` 投影为 undefined 即丢弃)~~ **修正(v3 起)**:该判断只对 live 线程成立——cold 线程无 descriptor 时 `resolveColdIdentity` 确定性产出 `corrupt` 诊断行(`list-children.ts:405`),用户实测在 Subagent 页看到「目录损坏」。修复 = 线程种子尾部追加合法的 `subagent/descriptor`(`snapshotSubagentDescriptor`,version 2 / continuable / provider `'sidechat'` / label 即 `Side: ` 标题),线程成为目录里的健康行;SubagentView 与 `subagent-detect.ts` 按 `Side: ` 标签/标题前缀过滤,拓扑 UI 与自动打开触发保持零噪音(模型侧 `list_agents` 也会列出带标签的健康行,而非 corrupt 垃圾)。
|
||||
|
||||
## 6. 客户端 Tab(`src/client/SideChatView.tsx`)
|
||||
|
||||
- 内置 descriptor:`id:'sidechat'`、`order:35`、`single:true`、图标 `IconNewChatOutline16`。
|
||||
- 两栏布局:左线程列表(`sideThreadRows`:`origin==='subagent' && parentId===当前 && displayTitle 前缀 'Side: '`;订阅 `ctx.sessions.list`);右详情(时间线 + 输入框 + 保存/停止/关闭按钮)。
|
||||
- 时间线:轮询 `connection.api.sessions.history`(通用 RPC,未 fence;`session/end-seed` 截断 + boundary 行丢弃 + chunk 流式累加 + 工具配对——`src/client/sidechat-transcript.ts` 纯映射);可见且运行中 ~2s,seq 去重缓存;`visible=false` 停轮询。
|
||||
- 保存为新会话:`ctx.sessions.fork({sessionId: threadId, increaseTitle:true})` → `binding(newId).session.rename(标题去前缀)` → `ctx.sessions.open(newId)`。无已完成回合 → 禁用 + 提示;末条追问未完成 → 提示不包含(fork 边界=最后 turn/end)。
|
||||
- 选中线程持久化:`tab.meta.threadId`(既有 per-session localStorage 通道)。
|
||||
|
||||
## 7. 边界情况与失败模式
|
||||
|
||||
| 场景 | 行为 |
|
||||
|---|---|
|
||||
| 父会话无 live agent | 409 `sidechat-error` 内联提示 |
|
||||
| 父会话进行中(流式/思考/工具已返回) | 完整事件入种子 + interrupted 合成闭合 |
|
||||
| 父会话工具执行中途点击新建 | 悬挂 tool/call → 截断 + 结构化转储(工具标注 executing) |
|
||||
| seam 缺失(agentPresets/sessionTitle/sessionPersistence) | 降级:默认组合 / 自动标题 / 无 preset 组合的冷恢复 |
|
||||
| DSH 重启后续聊 / 线程关闭后追问 | `ctx.agents.resume`(persisted preset 组合) |
|
||||
| 线程无已完成回合 | 保存禁用 + 提示 |
|
||||
| 线程末条追问未完成 | 保存前提示不包含 |
|
||||
| 与 dsh-sidechain 并存 | 各自独立;双方线程都有 descriptor(目录健康行),SubagentView 按 `Side: ` 前缀过滤均不可见;双方共享 `Side: ` 标签约定,线程互见 |
|
||||
|
||||
## 8. 测试与自验
|
||||
|
||||
- 单测:`sidechat-core.spec.ts`(种子全分支:turn 外 / 空 / 仅 pending user / open turn 无工具 / 成对工具 / 悬挂回退 / 多 step 闭合 / seq 连续性;转储保真;线程过滤;保存资格;preset 解析;boundaryDelivered)、`sidechat-seed-validation.spec.ts`(真实 `Session.create` 校验器接受种子产物)、`sidechat-transcript.spec.ts`(种子截断、boundary 丢弃、chunk 流式、工具配对、孤儿失败工具)、`sidechat-routes.spec.ts`(start 带问/空问立即创建、首条 prompt 包裹边界+快照并改名、prompt live+cold、cancel、dispose、info live/cold/未知)、`builtins.spec.ts` 7 tab + sidechat 每线程一 Tab 铸造/去重。
|
||||
- e2e 挂载冒烟:+ 菜单深扫含 Side Chat;**host 路由级自验**(真实 keyless dsh web 中 sidechat.start/prompt/cancel/dispose/info 全链路 + 空问题立即创建流程,种子会话为父)。
|
||||
- 真实环境手测(实施后勾选):新建线程 → 追问往返 → 流式渲染 → 工具行 → 主会话流式中新建(冻结继承)→ 工具执行中途新建(回退转储)→ 重启后续聊 → 保存为新会话 → 刷新持久。
|
||||
|
||||
## 9. 复用门记录
|
||||
|
||||
搜索面:DSH checkout `docs/` + `packages/`(关键词 side conversation / sidetrack / thread / promote)+ dsh-external hub catalog(`search-org.mjs`,323 repos)。命中:`dsh-external/dsh-sidechain`(Codex 风格侧会话,/side & /btw,临时 fork + 侧栏面板),但**无 better-sidebar Tab、无保存为新会话**;用户拍板独立实现(「我们做独立实现」),sidechain 保持不动。本文档与 PR 记录该结论。
|
||||
|
||||
## 10. 实施偏差记录
|
||||
|
||||
- 路由键命名:`buildSidechatApi` 返回对象的键必须是**完整 wire 方法名**(`'sidechat.start'` 等,/sidebar/api 分发器按 `api[method]` 查找),而非短名 `start/prompt/…`。首次实现用了短名,单测直接调对象方法未暴露,真实挂载冒烟以 404 抓出(该冒烟因此新增了路由级自验,见 §8)。
|
||||
- 种子函数入参类型:sidechat-core 使用宽松结构类型 `SidechatLogEvent {type, seq, time, data: unknown}`(同时接受 host 真实 `SessionEvent` 与 client 的 `SidebarSessionEvent` 镜像),避免两半的类型图互相污染。
|
||||
- keyless 挂载冒烟无法验证 boundary 消息落盘:`agent.followup` 的消息先入 inbox,被 turn claim 后才写入 session 日志——keyless(无模型路由)下 turn 不会 claim。挂载冒烟的深度断言因此改为 **session.list 成员校验**(证明子会话真实创建,provider 无关);boundary 落盘与流式/回答在真实 provider 环境手测(§8 清单)。
|
||||
- **UI 两轮返工(用户实测驱动)**:
|
||||
1. 种子事件信封字段丢失(用户实测报 `invalid seed event … requires a surfaceOp marker`):`copyEvents` 重建事件时只保留 `{type,seq,time,data}`,剥掉了 `surfaceOp`/`sourceEventSeqs`/`ignorable`,而种子校验器要求 surface-eligible 事件必带 `surfaceOp`。修复 = 信封字段原样透传 + 新增 `sidechat-seed-validation.spec.ts`(用真实 `@deepseek-ai/dsh-session` 的 `Session.create` 校验器跑种子产物,回归守护)。
|
||||
2. 交互模型从「单 Tab + 内嵌线程列表 + 首问表单」改为 **Codex app 对齐的「每线程一 Tab」**:`createTab` 铸造 `sidechat:new-<uuid>`(meta `autoCreate`,视图挂载即调 `sidechat.start` 空问题立即建线程)或消费 `parkSidechatReopen` 停泊的 `sidechat:<threadId>` 重连 Tab;`dedupeKey = meta.threadId` 保证同线程聚焦不重复;`onClose` 释放 live agent(历史保留,头部菜单可重开)。配套后端变化:`sidechat.start` 的 `question` 变为可选(空 = 仅建线程,快照停泊在 `pendingSnapshots`);`sidechat.prompt` 检测边界未送达时(`boundaryDelivered` 扫日志)自动包裹边界 + 停泊快照并用首条消息改名;新增 `sidechat.info` 路由(live 状态 + provider/model/preset 身份,供头部 Agent 徽标)。UI 对齐主对话区:用户右对齐气泡(`--dsw-specific-bubble`)、assistant 通栏 markdown、胶囊 composer(`--dsw-specific-input-major` + 圆形 accent 发送/停止钮 + 自动增高 textarea)、运行扫光状态行与工具行(`StateDot` + shimmer),动效全部 ≤200ms 交叉淡化并受 `prefers-reduced-motion` 收敛。
|
||||
- (预留)实施过程中如有与本文档的其他偏差,在此记录。
|
||||
- **第三轮实测修复(4 bug)**:
|
||||
1. 「保存为新会话」报 `Cannot read properties of undefined (reading 'list')`:`handleSave` 把 `ctx.sessions.fork` 解构成自由函数再调用,丢失 `this`——client-runtime 的 `service.fork` 内部读 `this.list.getSnapshot()`(标题递增)。修复 = 保持方法调用形态(`ctx.sessions.fork({...})`)。
|
||||
2. 图标语义:线程切换菜单改自绘 `IconHistoryOutline16`(逆时针时钟)、保存按钮改自绘 `IconSaveOutline16`(软盘),沿用 1.5px 描边规范(primitives 无现成 history/save 图标)。
|
||||
3. 重开线程 Tab 丢工具行:根因**不在宿主**(`session.history` cold 路径原样返回 `tool/call`,见 `api-proxy.ts:749` 与 `chunk-rows.ts` 的无损 chunk 压缩)而在首挂回源窗口——旧实现 8 事件/页 × 32 页 = 256 事件上限,而 cold 读会把 chunk-rows 展开回每个流式 delta 一条事件,一轮回答动辄数百事件,较早的 `tool/call` 掉出窗口(settled 文本靠最终 `assistant/message` 幸存)。修复 = 抽出 `collectOwnEvents`(200 事件/页 × 40 页帽、日志穷尽/无标记时落定 `seedBoundary:0` 停止每轮重走),单测覆盖四分支。
|
||||
4. Subagent 页「目录损坏」行:见 §5 修正——种子追加 descriptor + SubagentView/subagent-detect 按前缀过滤(`isSideThreadSummary` 同时修好 autoOpenSubagent 误触发与计数膨胀)。旧版创建的线程仍是 corrupt 诊断行,由 SubagentView 的标题前缀过滤兜底隐藏。
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "dsh-external/dsh-better-sidebar",
|
||||
"version": "0.6.0",
|
||||
"version": "0.15.1",
|
||||
"main": "./lib/index.js",
|
||||
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
|
||||
"engines": {
|
||||
|
||||
+71
-44
@@ -1,9 +1,15 @@
|
||||
{
|
||||
"name": "dsh-better-sidebar",
|
||||
"version": "0.6.0",
|
||||
"version": "0.15.1",
|
||||
"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.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/omdsh-dev/DSH-better-sidebar"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
@@ -34,11 +40,16 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
},
|
||||
"client": {
|
||||
"inject": [
|
||||
"@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"
|
||||
}
|
||||
@@ -47,39 +58,57 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/client-registry.js",
|
||||
"lib/client-terminal.js",
|
||||
"lib/client-editor.js",
|
||||
"lib/client-mermaid.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"src"
|
||||
"src",
|
||||
"scripts/install.sh",
|
||||
"scripts/install.ps1",
|
||||
"cordis.patch.yml",
|
||||
"README.md",
|
||||
"README_EN.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown",
|
||||
"prepublishOnly": "pnpm build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch",
|
||||
"prepare": "tsdown",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"test:mount": "bash scripts/e2e-mount.sh",
|
||||
"check:consumer-types": "bash scripts/check-consumer-types.sh"
|
||||
},
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-schema-form": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-settings": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/cordis": "^4.0.1",
|
||||
"@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-subagent": "^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"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"cordis": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@aiden0z/pptx-renderer": "^1.2.4",
|
||||
"@codemirror/commands": "^6.10.4",
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
@@ -101,39 +130,37 @@
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/view": "^6.43.8",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@univerjs/preset-sheets-core": "^0.25.1",
|
||||
"@univerjs/presets": "^0.25.1",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"clsx": "^2.1.1",
|
||||
"docx-preview": "^0.4.0",
|
||||
"mermaid": "^11.16.1",
|
||||
"node-pty": "^1.1.0",
|
||||
"rxjs": "^7.8.2",
|
||||
"schemastery": "^3.18.0",
|
||||
"ws": "^8.18.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"xterm": "^5.3.0"
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-schema-form": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-settings": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-agent": "0.1.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-locale": "0.1.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "0.1.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "0.1.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "0.1.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "0.1.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "0.1.1-rc.1",
|
||||
"@deepseek-ai/dsh-host-webserver": "0.1.1-rc.1",
|
||||
"@deepseek-ai/dsh-invariants": "0.1.1-rc.1",
|
||||
"@deepseek-ai/dsh-llm": "0.1.1-rc.1",
|
||||
"@deepseek-ai/dsh-session": "0.1.1-rc.1",
|
||||
"@deepseek-ai/dsh-settings": "0.1.1-rc.1",
|
||||
"@deepseek-ai/dsh-subagent": "0.1.1-rc.1",
|
||||
"@deepseek-ai/dsh-tools": "0.1.1-rc.1",
|
||||
"@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
+2438
-4594
File diff suppressed because it is too large
Load Diff
@@ -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'
|
||||
@@ -65,3 +66,8 @@ minimumReleaseAgeExclude:
|
||||
- '@deepseek-ai/dsh-user-interaction@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-workspace@0.0.1-rc.1'
|
||||
- '@deepseek-ai/schemastery@3.18.1-rc.1'
|
||||
- '@deepseek-ai/cordis-plugin-include@1.0.6'
|
||||
- '@deepseek-ai/cordis-plugin-loader@1.0.2'
|
||||
- '@deepseek-ai/cordis@4.0.1'
|
||||
- '@deepseek-ai/cosmokit@1.8.2'
|
||||
- '@deepseek-ai/schemastery@3.18.1'
|
||||
|
||||
Executable
+101
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bash
|
||||
# Consumer-facing type surface check: build a tiny standalone consumer that
|
||||
# imports `dsh-better-sidebar/client/service` WITHOUT @types/node and with
|
||||
# skipLibCheck: false, then type-check it with the repo's tsc.
|
||||
#
|
||||
# This is the exact scenario the v0.12.0 API work cares about: the shipped
|
||||
# declaration graph (service.d.ts -> context-types.d.ts -> state/api) must
|
||||
# resolve and type-check for a browser-only plugin author. Run AFTER
|
||||
# `pnpm build` (the check reads lib/types).
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# The check reads the BUILT declaration surface (lib/types) — fail loudly
|
||||
# instead of silently passing when the build output is missing.
|
||||
if [ ! -f lib/types/client/service.d.ts ]; then
|
||||
echo "[check-consumer-types] FAIL: lib/types/client/service.d.ts not found — run 'pnpm build' first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
mkdir -p "$WORK/node_modules"
|
||||
ln -s "$(pwd)" "$WORK/node_modules/dsh-better-sidebar"
|
||||
|
||||
cat > "$WORK/check.ts" <<'EOF'
|
||||
import { SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from 'dsh-better-sidebar/client/service'
|
||||
import type {} from 'dsh-better-sidebar/client/service'
|
||||
import type {
|
||||
BetterSidebarService, FileViewerDescriptor, OpenTabSeed, SidebarSettingsRenderProps,
|
||||
TabComponentProps, TabDescriptor,
|
||||
} from 'dsh-better-sidebar/client/service'
|
||||
import type { SessionScope, SidebarSnapshot, SidebarState, SidebarStore, SidebarTab } from 'dsh-better-sidebar/client/service'
|
||||
import type { SidebarPrefs } from 'dsh-better-sidebar/client/service'
|
||||
|
||||
declare const ctx: { betterSidebar: BetterSidebarService }
|
||||
const tab: TabDescriptor = {
|
||||
id: 'x:tab',
|
||||
title: 'X',
|
||||
single: true,
|
||||
badge: (_ctx, _scope, state: SidebarState) => state.expanded.length,
|
||||
onOpen: (_tab: SidebarTab, _scope: SessionScope) => {},
|
||||
onClose: (_tab: SidebarTab, _scope: SessionScope) => {},
|
||||
createTab: (state: SidebarState) => ({ tab: { id: 'x:1', type: 'x:tab', title: 'X', meta: {} } }),
|
||||
settings: {
|
||||
toggles: [{ key: 'autoOpenSubagent', title: 'A' }],
|
||||
pluginToggles: [{ key: 'k', title: 'K', type: 'number', min: 0, max: 9 }],
|
||||
render: (props: SidebarSettingsRenderProps) => { props.updatePluginSetting('a', 1); return null },
|
||||
},
|
||||
component: (props: TabComponentProps) => null,
|
||||
}
|
||||
ctx.betterSidebar.registerTab(tab)
|
||||
const viewer: FileViewerDescriptor = {
|
||||
id: 'x:csv', exts: ['csv'], fetchStrategy: 'custom',
|
||||
load: (_path, _scope, signal?: AbortSignal) => { void signal; return Promise.resolve([]) },
|
||||
component: () => null,
|
||||
}
|
||||
ctx.betterSidebar.registerFileViewer(viewer)
|
||||
const seed: OpenTabSeed = { type: 'x:tab', title: 'X', meta: { a: 1 } }
|
||||
ctx.betterSidebar.openTab(seed, { sessionId: 's1' })
|
||||
ctx.betterSidebar.openFile({ sessionId: 's1' }, '/p/a.csv')
|
||||
ctx.betterSidebar.updateTab('t', { meta: { b: 2 } })
|
||||
const snap: SidebarSnapshot = ctx.betterSidebar.getSnapshot()
|
||||
const prefs: SidebarPrefs = snap.prefs
|
||||
const store: SidebarStore = null as unknown as SidebarStore
|
||||
void prefs; void store; void ctx.betterSidebar.version; void ctx.betterSidebar.features
|
||||
void SIDEBAR_SERVICE_VERSION; void SIDEBAR_FEATURES
|
||||
EOF
|
||||
|
||||
cat > "$WORK/tsconfig.json" <<'EOF'
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023", "module": "esnext", "moduleResolution": "bundler",
|
||||
"strict": true, "noEmit": true, "skipLibCheck": false, "types": [],
|
||||
"jsx": "react-jsx", "lib": ["ES2023", "DOM"]
|
||||
},
|
||||
"include": ["check.ts"]
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "[check-consumer-types] type-checking a browser-only consumer (no @types/node)..."
|
||||
echo "[check-consumer-types] pass 1: skipLibCheck: true (the realistic consumer experience) — must be fully clean"
|
||||
./node_modules/.bin/tsc -p "$WORK/tsconfig.json" --skipLibCheck true
|
||||
echo "[check-consumer-types] pass 2: skipLibCheck: false — OUR declaration surface (lib/types/**) must be clean"
|
||||
echo "[check-consumer-types] (upstream cordis/cosmokit declaration noise is filtered out; it predates this plugin)"
|
||||
set +e
|
||||
./node_modules/.bin/tsc -p "$WORK/tsconfig.json" --skipLibCheck false > "$WORK/strict.log" 2>&1
|
||||
STATUS=$?
|
||||
set -e
|
||||
# Fail only on errors that mention OUR package / declarations / the fixture.
|
||||
if grep -E "dsh-better-sidebar|lib/types|check\.ts" "$WORK/strict.log" | grep -v "node_modules/cordis\|node_modules/cosmokit"; then
|
||||
echo "[check-consumer-types] FAIL: errors in the dsh-better-sidebar declaration surface:" >&2
|
||||
grep -E "dsh-better-sidebar|lib/types|check\.ts" "$WORK/strict.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$STATUS" -ne 0 ]; then
|
||||
echo "[check-consumer-types] pass 2 note: strict mode only reports upstream declaration noise:"
|
||||
grep -v "dsh-better-sidebar\|lib/types\|check\.ts" "$WORK/strict.log" | head -5
|
||||
fi
|
||||
echo "[check-consumer-types] OK: the client/service declaration surface is node-free and self-contained."
|
||||
|
||||
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 后无头渲染未崩溃"
|
||||
@@ -0,0 +1,289 @@
|
||||
## =============================================================================
|
||||
# dsh-better-sidebar 一键安装脚本(官方 CLI 方式,Windows PowerShell 5.1+ / pwsh)
|
||||
#
|
||||
# 通过 DSH 官方插件命令安装 npm 包并自动挂载:
|
||||
# dsh plugin --profile web add dsh-better-sidebar@<version>
|
||||
#
|
||||
# 包内声明了 dsh.bundle.patch(cordis.patch.yml):CLI 的 bundle 协调会把它
|
||||
# 自动加进 profile 的 dsh.profile.bundles,下次启动即挂载——无需手动写
|
||||
# cordis.patch.yml 挂载行。符合仓库硬约束:不修改 DSH 源码,插件永远作为
|
||||
# 独立包被 profile 引用。
|
||||
#
|
||||
# 用法(任选其一):
|
||||
# # 默认最新版
|
||||
# irm https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1 | iex
|
||||
# # 指定版本 / 装完重启
|
||||
# & ([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 只打印将要执行的操作,不写任何文件。
|
||||
#
|
||||
# 环境变量(均可省略):
|
||||
# DSH_HOME 默认 %USERPROFILE%\.dsh
|
||||
# REGISTRY 默认 https://registry.npmjs.org
|
||||
# DSH_CMD 默认优先 PATH 上的 dsh,缺省回退 npx -y --package @deepseek-ai/dsh
|
||||
#
|
||||
# 说明:
|
||||
# - pnpm 11 的 strict-dep-builds 会拦截 node-pty/protobufjs 的构建脚本并使
|
||||
# `dsh plugin add` 非零退出。脚本会先把这两个构建许可写进 profile 的
|
||||
# pnpm-workspace.yaml(幂等),保证 CLI 一步成功。
|
||||
# - pnpm 11 的 minimumReleaseAge 会拒绝发布 <24h 的新版本。脚本会预写
|
||||
# minimumReleaseAgeExclude(幂等),放行本插件,避免"重跑一次才成功"。
|
||||
# - 老版本(<0.10.2)用手动挂载行,bundle 通道激活后需移除,否则双挂载。
|
||||
# 脚本会幂等移除 better-sidebar 挂载行。
|
||||
# =============================================================================
|
||||
param(
|
||||
[string]$Version = '',
|
||||
[switch]$Restart,
|
||||
[switch]$DryRun,
|
||||
[switch]$Repair,
|
||||
[string]$Profile = 'web'
|
||||
)
|
||||
|
||||
$PKG = 'dsh-better-sidebar'
|
||||
$REGISTRY = if ($env:REGISTRY) { $env:REGISTRY } else { 'https://registry.npmjs.org' }
|
||||
|
||||
# DSH_HOME:DSH_HOME 环境变量 > %USERPROFILE% > $HOME
|
||||
if ($env:DSH_HOME) {
|
||||
$DSH_HOME = $env:DSH_HOME
|
||||
} elseif ($env:USERPROFILE) {
|
||||
$DSH_HOME = Join-Path $env:USERPROFILE '.dsh'
|
||||
} else {
|
||||
$DSH_HOME = Join-Path $HOME '.dsh'
|
||||
}
|
||||
$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'
|
||||
|
||||
function Say([string]$m) { Write-Host "[install] $m" -ForegroundColor Green }
|
||||
function Warn([string]$m) { Write-Host "[warn] $m" -ForegroundColor Yellow }
|
||||
function Die([string]$m) { Write-Host "[error] $m" -ForegroundColor Red; exit 1 }
|
||||
|
||||
# 解析版本 -> npm spec("x.y.z" / "^x.y.z" / latest)
|
||||
function Resolve-Spec {
|
||||
param([string]$Given)
|
||||
if ([string]::IsNullOrWhiteSpace($Given) -or $Given -eq 'latest') {
|
||||
$v = $null
|
||||
foreach ($tool in @('npm', 'pnpm')) {
|
||||
if (Get-Command $tool -ErrorAction SilentlyContinue) {
|
||||
$v = (& $tool view $PKG version "--registry=$REGISTRY" 2>$null | Select-Object -Last 1)
|
||||
if ($v) { break }
|
||||
}
|
||||
}
|
||||
if ($v) { return ([string]$v).Trim() }
|
||||
Warn '无法联网解析最新版本(npm/pnpm 查询失败),回退为 latest,由 pnpm 直接解析。'
|
||||
Warn '若已知版本号,可显式传入:-Version 0.10.2'
|
||||
return 'latest'
|
||||
}
|
||||
return $Given
|
||||
}
|
||||
|
||||
# 组装 dsh CLI:优先 PATH 上的 dsh,缺省 npx 拉官方包
|
||||
function Get-DshCli {
|
||||
if ($env:DSH_CMD) { return $env:DSH_CMD }
|
||||
if (Get-Command dsh -ErrorAction SilentlyContinue) { return 'dsh' }
|
||||
if (Get-Command npx -ErrorAction SilentlyContinue) { return 'npx' }
|
||||
return $null
|
||||
}
|
||||
|
||||
# 步骤 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");
|
||||
const before = t;
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
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");
|
||||
'@
|
||||
# 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', $Profile, 'add', "$PKG@$SPEC")
|
||||
} else {
|
||||
$cliArgs = @('-y', '--package', '@deepseek-ai/dsh', 'dsh', 'plugin', '--profile', $Profile, 'add', "$PKG@$SPEC")
|
||||
}
|
||||
Say "执行 $CLI plugin --profile $Profile add $PKG@$SPEC ..."
|
||||
$addOut = & $CLI @cliArgs 2>&1
|
||||
$addCode = $LASTEXITCODE
|
||||
$addOut | ForEach-Object { $_ }
|
||||
if ($addCode -ne 0) {
|
||||
Warn 'dsh plugin add 失败。已预写 allowBuilds 与 minimumReleaseAgeExclude,仍失败的可能原因:'
|
||||
Warn ' - 网络/登录问题:npm registry 不可达或需要登录。'
|
||||
Warn " - 依赖安装冲突:可手动重试 cd $PROFILE_DIR; pnpm install。"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 步骤 3:校验 bundle 已注册(挂载生效的判据)
|
||||
$pkgJson = Get-Content -Raw (Join-Path $PROFILE_DIR 'package.json') | ConvertFrom-Json
|
||||
$bundles = $pkgJson.dsh.profile.bundles
|
||||
if ($bundles -notcontains $PKG) {
|
||||
Warn 'dsh-better-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册。'
|
||||
Warn "若上面的 pnpm 输出提示 ignored build scripts,请确认 $WS_YML 的 allowBuilds 后重跑本脚本。"
|
||||
exit 1
|
||||
}
|
||||
Say "bundle 已注册:dsh.profile.bundles 包含 $PKG(下次启动自动挂载)"
|
||||
|
||||
# 步骤 4:幂等移除旧的 manual 挂载行(避免与 bundle 双挂载)
|
||||
$mountScript = @'
|
||||
const fs = require("fs");
|
||||
const p = process.argv[2];
|
||||
const lines = fs.readFileSync(p, "utf8").split("\n");
|
||||
const out = [];
|
||||
let i = 0;
|
||||
let removed = false;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (/^[ \t]*- insert:\s*$/.test(line)) {
|
||||
const block = [line];
|
||||
let j = i + 1;
|
||||
while (j < lines.length && lines[j].trim() !== "" && !/^-\s/.test(lines[j])) {
|
||||
block.push(lines[j]);
|
||||
j++;
|
||||
}
|
||||
if (block.some((l) => /id:\s*better-sidebar\b/.test(l))) {
|
||||
while (out.length && /^[ \t]*#/.test(out[out.length - 1])) out.pop();
|
||||
i = j;
|
||||
removed = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(line);
|
||||
i++;
|
||||
}
|
||||
if (!removed) {
|
||||
console.log("none");
|
||||
} else {
|
||||
const t = out.join("\n").replace(/\n{3,}/g, "\n\n");
|
||||
fs.writeFileSync(p, t);
|
||||
console.log("removed");
|
||||
}
|
||||
'@
|
||||
$mountJs = Join-Path $env:TEMP ("dshbs-mount-" + [guid]::NewGuid().ToString("N") + ".js")
|
||||
Set-Content -LiteralPath $mountJs -Value $mountScript -Encoding UTF8
|
||||
$mountOut = node $mountJs "$PATCH_YML" 2>&1
|
||||
$mountCode = $LASTEXITCODE
|
||||
Remove-Item -LiteralPath $mountJs -Force -ErrorAction SilentlyContinue
|
||||
$mountResult = (($mountOut | Out-String)).Trim()
|
||||
if ($mountCode -ne 0) { Die "处理 $PATCH_YML 失败(node 退出码 $mountCode):$mountResult" }
|
||||
if ($mountResult -eq 'removed') {
|
||||
Say "已从 $PATCH_YML 移除旧的 better-sidebar 手动挂载行(bundle 通道接管挂载)"
|
||||
} else {
|
||||
Say '无旧手动挂载行,跳过'
|
||||
}
|
||||
|
||||
Say "安装完成:$PKG@$SPEC"
|
||||
|
||||
# 步骤 5:重启提示
|
||||
if ($Restart) {
|
||||
if (Get-Command pm2 -ErrorAction SilentlyContinue) {
|
||||
Say '重启 dsh-web(pm2)...'
|
||||
pm2 restart dsh-web
|
||||
if ($LASTEXITCODE -ne 0) { Warn 'pm2 restart 失败,请手动重启 DSH' }
|
||||
} else {
|
||||
Warn '未找到 pm2,请手动重启 DSH(如:pm2 restart dsh-web 或 dsh web)'
|
||||
}
|
||||
} else {
|
||||
Say '下一步:重启 DSH 并硬刷新(Ctrl+Shift+R / Cmd+Shift+R)使新副本生效。'
|
||||
if (Get-Command pm2 -ErrorAction SilentlyContinue) {
|
||||
Say '本机可用:pm2 restart dsh-web(会短暂断开当前页面会话)'
|
||||
}
|
||||
}
|
||||
Executable
+287
@@ -0,0 +1,287 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# dsh-better-sidebar 一键安装脚本(官方 CLI 方式,macOS / Linux / Windows Git Bash)
|
||||
#
|
||||
# 通过 DSH 官方插件命令安装 npm 包并自动挂载:
|
||||
# dsh plugin --profile web add dsh-better-sidebar@<version>
|
||||
#
|
||||
# 包内声明了 dsh.bundle.patch(cordis.patch.yml):CLI 的 bundle 协调会把它
|
||||
# 自动加进 profile 的 dsh.profile.bundles,下次启动即挂载——无需手动写
|
||||
# cordis.patch.yml 挂载行。符合仓库硬约束:不修改 DSH 源码,插件永远作为
|
||||
# 独立包被 profile 引用。
|
||||
#
|
||||
# 用法:
|
||||
# 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 只打印将要执行的操作,不写任何文件。
|
||||
# -h/--help 打印本帮助。
|
||||
#
|
||||
# 环境(均可省略,脚本会自动探测):
|
||||
# DSH_HOME 默认 ~/.dsh(Windows Git Bash 下回退 $USERPROFILE/.dsh)
|
||||
# REGISTRY 默认 https://registry.npmjs.org(发布源;装依赖仍走 pnpm 配置)
|
||||
# DSH_CMD 默认优先用 PATH 上的 `dsh`,缺省回退 npx -y --package @deepseek-ai/dsh
|
||||
#
|
||||
# 说明:
|
||||
# - pnpm 11 的 strict-dep-builds 会拦截 node-pty/protobufjs 的构建脚本并使
|
||||
# `dsh plugin add` 非零退出(bundle 协调因此被跳过)。脚本会先把这两个
|
||||
# 构建许可写进 profile 的 pnpm-workspace.yaml(幂等),保证 CLI 一步成功。
|
||||
# - pnpm 11 的 minimumReleaseAge 会拒绝发布 <24h 的新版本。脚本会预写
|
||||
# minimumReleaseAgeExclude(幂等),放行本插件,避免"重跑一次才成功"。
|
||||
# - 老版本(<0.10.2)用手动挂载行,bundle 通道激活后需移除,否则双挂载
|
||||
# (Node 半挂两次、页面两个侧边栏)。脚本会幂等移除 better-sidebar 挂载行。
|
||||
# - 回滚:dsh plugin --profile web remove dsh-better-sidebar,或把 profile 依赖
|
||||
# 改回 "dsh-better-sidebar": "link:<路径>" 再 pnpm install。
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# 帮助请求优先处理
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = "-h" ] || [ "$arg" = "--help" ]; then
|
||||
cat <<'EOF'
|
||||
dsh-better-sidebar 一键安装 / 依赖修复脚本
|
||||
|
||||
用法:
|
||||
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 只打印将要执行的操作,不写任何文件
|
||||
|
||||
环境变量(可省略):DSH_HOME(默认 ~/.dsh)、REGISTRY(npm 源)、DSH_CMD(dsh 命令)
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
DSH_HOME="${DSH_HOME:-${HOME:-${USERPROFILE:-}}/.dsh}"
|
||||
REGISTRY="${REGISTRY:-https://registry.npmjs.org}"
|
||||
PKG="dsh-better-sidebar"
|
||||
DSH_CMD="${DSH_CMD:-dsh}"
|
||||
|
||||
RESTART=false
|
||||
DRY_RUN=false
|
||||
REPAIR=false
|
||||
VERSION_SPEC=""
|
||||
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 "未知参数: ${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}"
|
||||
case "$given" in
|
||||
latest)
|
||||
local v=""
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
v="$(npm view "$PKG" version --registry="$REGISTRY" 2>/dev/null)" || v=""
|
||||
fi
|
||||
if [ -z "$v" ] && command -v pnpm >/dev/null 2>&1; then
|
||||
v="$(pnpm view "$PKG" version --registry="$REGISTRY" 2>/dev/null)" || v=""
|
||||
fi
|
||||
if [ -n "$v" ]; then
|
||||
printf '%s' "$v"
|
||||
else
|
||||
warn "无法联网解析最新版本(npm/pnpm 查询失败),回退为 latest,由 pnpm 直接解析。"
|
||||
warn "若已知版本号,可显式传入:bash scripts/install.sh 0.10.2"
|
||||
printf 'latest'
|
||||
fi
|
||||
;;
|
||||
*) printf '%s' "$given" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 组装 dsh CLI 调用:优先 PATH 上的 dsh,缺省 npx 拉官方包
|
||||
dsh_cli() {
|
||||
if command -v "$DSH_CMD" >/dev/null 2>&1; then
|
||||
printf '%s' "$DSH_CMD"
|
||||
elif command -v npx >/dev/null 2>&1; then
|
||||
printf 'npx -y --package @deepseek-ai/dsh dsh'
|
||||
else
|
||||
die "未找到 dsh 或 npx。请先安装 DSH(并确保 Node/npm 可用),或用 DSH_CMD 指定 dsh 路径。"
|
||||
fi
|
||||
}
|
||||
|
||||
# 前置校验
|
||||
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}(请先初始化 ${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 $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 $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
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 步骤 1:预写 workspace 设置(幂等),保证 pnpm 不拦截构建、不放行旧版本策略
|
||||
ensure_workspace_settings
|
||||
|
||||
# 步骤 2:官方 CLI 安装 + bundle 自动注册(含挂载)
|
||||
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。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 校验 bundle 已注册(挂载生效的判据)
|
||||
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(process.argv[2]) ? 0 : 1);
|
||||
' "$PROFILE_DIR/package.json" "$PKG"; then
|
||||
warn "dsh-better-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册。"
|
||||
warn "若上面的 pnpm 输出提示 ignored build scripts,请确认 $WS_YML 的 allowBuilds 后重跑本脚本。"
|
||||
exit 1
|
||||
fi
|
||||
say "bundle 已注册:dsh.profile.bundles 包含 ${PKG}(下次启动自动挂载)"
|
||||
|
||||
# 步骤 3:幂等移除旧的 manual 挂载行(避免与 bundle 双挂载)
|
||||
MOUNT_RESULT="$(node -e '
|
||||
const fs = require("fs");
|
||||
const p = process.argv[1];
|
||||
const lines = fs.readFileSync(p, "utf8").split("\n");
|
||||
const out = [];
|
||||
let i = 0;
|
||||
let removed = false;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (/^[ \t]*- insert:\s*$/.test(line)) {
|
||||
const block = [line];
|
||||
let j = i + 1;
|
||||
while (j < lines.length && lines[j].trim() !== "" && !/^-\s/.test(lines[j])) {
|
||||
block.push(lines[j]);
|
||||
j++;
|
||||
}
|
||||
if (block.some((l) => /id:\s*better-sidebar\b/.test(l))) {
|
||||
while (out.length && /^[ \t]*#/.test(out[out.length - 1])) out.pop();
|
||||
i = j;
|
||||
removed = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(line);
|
||||
i++;
|
||||
}
|
||||
if (!removed) {
|
||||
console.log("none");
|
||||
} else {
|
||||
const t = out.join("\n").replace(/\n{3,}/g, "\n\n");
|
||||
fs.writeFileSync(p, t);
|
||||
console.log("removed");
|
||||
}
|
||||
' "$PATCH_YML")"
|
||||
[ "$MOUNT_RESULT" = "removed" ] \
|
||||
&& say "已从 $PATCH_YML 移除旧的 better-sidebar 手动挂载行(bundle 通道接管挂载)" \
|
||||
|| say "无旧手动挂载行,跳过"
|
||||
|
||||
say "安装完成:$PKG@$SPEC"
|
||||
|
||||
# 步骤 4:重启提示
|
||||
if [ "$RESTART" = true ]; then
|
||||
if command -v pm2 >/dev/null 2>&1; then
|
||||
say "重启 dsh-web(pm2)..."
|
||||
pm2 restart dsh-web || warn "pm2 restart 失败,请手动重启 DSH"
|
||||
else
|
||||
warn "未找到 pm2,请手动重启 DSH(如:pm2 restart dsh-web 或 dsh web)"
|
||||
fi
|
||||
else
|
||||
say "下一步:重启 DSH 并硬刷新(Cmd/Ctrl+Shift+R)使新副本生效。"
|
||||
if command -v pm2 >/dev/null 2>&1; then
|
||||
say "本机可用:pm2 restart dsh-web(会短暂断开当前页面会话)"
|
||||
fi
|
||||
fi
|
||||
@@ -29,6 +29,12 @@ const files = [
|
||||
'lib/invariant.js',
|
||||
'lib/client-registry.js',
|
||||
'lib/client-registry.js.map',
|
||||
// Lazy chunks: the client fetches these from the host's /sidebar/bundle
|
||||
// route on first use (see src/client/chunk-loader.ts / src/bundle-route.ts).
|
||||
'lib/client-terminal.js',
|
||||
'lib/client-terminal.js.map',
|
||||
'lib/client-editor.js',
|
||||
'lib/client-editor.js.map',
|
||||
'README.md',
|
||||
'LICENSE',
|
||||
]
|
||||
|
||||
+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,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Lazy chunk route: serves the client bundle's chunk scripts
|
||||
* (/sidebar/bundle/<name>.js). The official /plugins/<id>/client.js route
|
||||
* cannot serve arbitrary file names, so the plugin serves its own split
|
||||
* bundles (lib/client-<name>.js) here; the client injects the script on
|
||||
* first use of the feature that needs it (see src/client/chunk-loader.ts).
|
||||
*
|
||||
* Caching contract: every response carries `cache-control: no-cache` plus an
|
||||
* ETag (content hash, memoized per file by mtime/size) and honors
|
||||
* If-None-Match — the browser revalidates each fetch, but a 304 avoids
|
||||
* re-downloading multi-MB chunks that did not change (page refresh, HMR
|
||||
* re-activation). Same browser-trust fence as every other /sidebar route;
|
||||
* only allowlisted chunk names are servable (no path traversal).
|
||||
*/
|
||||
import { createHash } from 'node:crypto'
|
||||
import { stat, readFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
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', 'mermaid'] as const
|
||||
export type ChunkName = (typeof CHUNK_NAMES)[number]
|
||||
|
||||
/** Directory of this host-half module (lib/ — the chunk scripts live next to it). */
|
||||
const LIB_DIR = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
/** sha1 content hash shortened to 12 hex chars (same shape as the client-modules rev). */
|
||||
function shortHash(input: string | Buffer): string {
|
||||
return createHash('sha1').update(input).digest('hex').slice(0, 12)
|
||||
}
|
||||
|
||||
interface ChunkEtag {
|
||||
mtimeMs: number
|
||||
size: number
|
||||
etag: string
|
||||
}
|
||||
|
||||
/** ETag memo: recompute the content hash only when the file's stat changed. */
|
||||
const etags = new Map<string, ChunkEtag>()
|
||||
|
||||
/**
|
||||
* The chunk file's ETag (quoted hash), or undefined when the file is
|
||||
* missing. Hash is recomputed only when mtime/size changed (hashing a
|
||||
* multi-MB chunk per request is wasteful).
|
||||
*/
|
||||
async function etagOf(name: ChunkName, chunkDir: string): Promise<string | undefined> {
|
||||
const path = join(chunkDir, `client-${name}.js`)
|
||||
const key = `${chunkDir}:${name}`
|
||||
try {
|
||||
const info = await stat(path)
|
||||
const memo = etags.get(key)
|
||||
if (memo !== undefined && memo.mtimeMs === info.mtimeMs && memo.size === info.size) {
|
||||
return memo.etag
|
||||
}
|
||||
const etag = `"${shortHash(await readFile(path))}"`
|
||||
etags.set(key, { mtimeMs: info.mtimeMs, size: info.size, etag })
|
||||
return etag
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the /sidebar/bundle route handler. `fence` is the shared browser-
|
||||
* trust check every /sidebar route applies; `chunkDir` is the directory the
|
||||
* chunk scripts live in (overridable for tests).
|
||||
*/
|
||||
export function createBundleRouteHandler(
|
||||
fence: (req: SidebarHttpRequest) => boolean,
|
||||
chunkDir: string = LIB_DIR,
|
||||
): (req: SidebarHttpRequest, res: SidebarHttpResponse) => Promise<void> {
|
||||
return async (req, res): Promise<void> => {
|
||||
if (!fence(req)) {
|
||||
res.writeHead(403)
|
||||
res.end('forbidden')
|
||||
return
|
||||
}
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.writeHead(405)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
||||
const match = /^\/sidebar\/bundle\/([a-z0-9-]+)\.js$/.exec(pathname)
|
||||
const name = match?.[1] as ChunkName | undefined
|
||||
if (name === undefined || !(CHUNK_NAMES as readonly string[]).includes(name)) {
|
||||
res.writeHead(404)
|
||||
res.end('not found')
|
||||
return
|
||||
}
|
||||
const etag = await etagOf(name, chunkDir)
|
||||
if (etag === undefined) {
|
||||
// Registered name but unreadable (bundle not built yet): loud 404.
|
||||
res.writeHead(404)
|
||||
res.end('not found')
|
||||
return
|
||||
}
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
// Revalidation hit: unchanged chunk, no body — avoids re-downloading
|
||||
// multi-MB scripts on page refresh / HMR re-activation.
|
||||
res.writeHead(304, { 'cache-control': 'no-cache', etag })
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const body = await readFile(join(chunkDir, `client-${name}.js`))
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/javascript; charset=utf-8',
|
||||
'cache-control': 'no-cache',
|
||||
etag,
|
||||
})
|
||||
res.end(body)
|
||||
} catch {
|
||||
// Read raced a delete/rebuild between the stat and the read.
|
||||
res.writeHead(404)
|
||||
res.end('not found')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the /sidebar/bundle route (disposed with the fiber). */
|
||||
export function registerBundleRoute(ctx: Context, fence: (req: SidebarHttpRequest) => boolean): () => void {
|
||||
return ctx.webServer.register({
|
||||
kind: 'prefix',
|
||||
path: '/sidebar/bundle',
|
||||
handler: createBundleRouteHandler(fence),
|
||||
})
|
||||
}
|
||||
@@ -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>}
|
||||
|
||||
+346
-25
@@ -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,12 +46,160 @@ 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.
|
||||
const controller = new AbortController()
|
||||
setLoad({ status: 'loading' })
|
||||
const mediaUrlOf = (): string => mediaUrl(scope, path)
|
||||
const apply = (action: EditorLoadAction): void => {
|
||||
@@ -50,7 +219,7 @@ export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: Se
|
||||
})
|
||||
return
|
||||
case 'customLoad':
|
||||
void action.viewer.load?.(path, scope).then((data) => {
|
||||
void action.viewer.load?.(path, scope, controller.signal).then((data) => {
|
||||
if (cancelled) return
|
||||
setLoad({ status: 'ready', viewer: action.viewer, customData: data })
|
||||
}).catch((error: unknown) => {
|
||||
@@ -77,25 +246,177 @@ export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: Se
|
||||
}
|
||||
}
|
||||
apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf))
|
||||
return () => { cancelled = true }
|
||||
}, [scope.sessionId, scope.cwd, path, ctx])
|
||||
return () => { cancelled = true; controller.abort() }
|
||||
}, [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,283 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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).
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16, IconFolderClose16, IconFolderOpen16,
|
||||
IconRefreshOutline16, Menu, writeClipboard,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { api, downloadUrl, type FsEntry } from './api.ts'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
interface LevelData {
|
||||
entries?: FsEntry[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Root label: the last path segment (mirror of the host rootLabel). */
|
||||
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)
|
||||
}
|
||||
|
||||
/** How long the row's "copied" label stays after a successful write. */
|
||||
const COPIED_MS = 1200
|
||||
|
||||
export function ExplorerView(props: {
|
||||
sessionId: string
|
||||
cwd: string | undefined
|
||||
expanded: string[]
|
||||
onToggle: (path: string) => void
|
||||
onOpenFile: (path: string) => void
|
||||
/** Insert `@<relative path>` into the composer draft. */
|
||||
onReferenceFile: (path: string) => void
|
||||
}) {
|
||||
const { sessionId, cwd, expanded, onToggle, onOpenFile, onReferenceFile } = 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. */
|
||||
const [rowMenu, setRowMenu] = useState<{ path: string; isDir: boolean; x: number; y: number } | null>(null)
|
||||
|
||||
const storeLevel = useCallback((path: string, level: LevelData) => {
|
||||
dataRef.current = { ...dataRef.current, [path]: level }
|
||||
setData(dataRef.current)
|
||||
}, [])
|
||||
|
||||
const loadDir = useCallback((dir: string) => {
|
||||
if (dataRef.current[dir] !== undefined) return
|
||||
storeLevel(dir, {})
|
||||
api.fsTree({ sessionId, cwd }, dir).then((listing) => {
|
||||
storeLevel(dir, { entries: listing.entries })
|
||||
}).catch((error: unknown) => {
|
||||
storeLevel(dir, { error: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
}, [sessionId, cwd, storeLevel])
|
||||
|
||||
useEffect(() => {
|
||||
// Load the visible set; already-loaded levels (kept in the cache) are
|
||||
// not refetched. Only the refresh button wipes the cache.
|
||||
const root = cwd
|
||||
if (root === undefined) return
|
||||
loadDir(root)
|
||||
for (const dir of expanded) loadDir(dir)
|
||||
}, [cwd, expanded, refreshTick, loadDir])
|
||||
|
||||
/** Copy `text`; on success flip the row's copied label for a moment. */
|
||||
const copyPath = useCallback((text: string, path: string): void => {
|
||||
void writeClipboard(text).then((ok) => {
|
||||
if (!ok) return
|
||||
setCopiedPath(path)
|
||||
window.setTimeout(() => {
|
||||
setCopiedPath(current => current === path ? null : current)
|
||||
}, COPIED_MS)
|
||||
})
|
||||
}, [])
|
||||
|
||||
/** The row's trailing actions: the @-reference button, or the copied label. */
|
||||
const rowActions = (entry: FsEntry): ReactNode => {
|
||||
if (copiedPath === entry.path) {
|
||||
return <span className={css.explorerCopied}>{t('copied')}</span>
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={css.explorerRef}
|
||||
aria-label={t('referenceFile')}
|
||||
title={t('referenceFile')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onReferenceFile(entry.path)
|
||||
}}
|
||||
>
|
||||
{t('referenceFile')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const openRowMenu = (event: MouseEvent, path: string, isDir: boolean): void => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setRowMenu({ path, isDir, x: event.clientX, y: event.clientY })
|
||||
}
|
||||
|
||||
/** Download a file through the host route (raw bytes, binary-safe). */
|
||||
const downloadFile = (path: string): void => {
|
||||
const url = downloadUrl({ sessionId, cwd }, path)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.style.display = 'none'
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
}
|
||||
|
||||
const root = cwd
|
||||
|
||||
const renderLevel = (dir: string, depth: number): ReactNode => {
|
||||
const level = data[dir]
|
||||
if (level === undefined) {
|
||||
return <div className={css.explorerRow} style={{ paddingLeft: depth * 22 + 6 }}>{t('loading')}</div>
|
||||
}
|
||||
if (level.error !== undefined) {
|
||||
return (
|
||||
<div className={clsx(css.explorerRow, css.explorerError)} style={{ paddingLeft: depth * 22 + 6 }}>
|
||||
{level.error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const entries = level.entries ?? []
|
||||
return entries.map(entry => {
|
||||
if (entry.isDir) {
|
||||
const isOpen = expanded.includes(entry.path)
|
||||
return (
|
||||
<div key={entry.path}>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={clsx(css.explorerRow, css.explorerDir, entry.hidden && css.explorerHidden)}
|
||||
style={{ paddingLeft: depth * 22 + 6 }}
|
||||
onClick={() => { onToggle(entry.path) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onToggle(entry.path)
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) => { openRowMenu(event, entry.path, true) }}
|
||||
>
|
||||
{isOpen ? <IconFolderOpen16 size={14} /> : <IconFolderClose16 size={14} />}
|
||||
<span className={css.explorerName}>{entry.name}</span>
|
||||
{rowActions(entry)}
|
||||
</div>
|
||||
{isOpen && renderLevel(entry.path, depth + 1)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={entry.path}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={clsx(css.explorerRow, entry.hidden && css.explorerHidden)}
|
||||
style={{ paddingLeft: depth * 22 + 6 }}
|
||||
title={entry.path}
|
||||
onClick={() => { onOpenFile(entry.path) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onOpenFile(entry.path)
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) => { openRowMenu(event, entry.path, false) }}
|
||||
>
|
||||
<IconCodeOutline16 size={14} />
|
||||
<span className={css.explorerName}>{entry.name}</span>
|
||||
{rowActions(entry)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
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>
|
||||
{/*
|
||||
The one shared context menu, positioned at the right-click cursor
|
||||
(portal so the explorer's overflow clip cannot crop it).
|
||||
*/}
|
||||
<Menu
|
||||
open={rowMenu !== null}
|
||||
onClose={() => { setRowMenu(null) }}
|
||||
items={[
|
||||
// Download applies to files only (the host route refuses directories).
|
||||
...(rowMenu?.isDir === false
|
||||
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={14} /> }]
|
||||
: []),
|
||||
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
|
||||
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
|
||||
]}
|
||||
onSelect={(id) => {
|
||||
const target = rowMenu
|
||||
if (target === null) return
|
||||
setRowMenu(null)
|
||||
if (id === 'download') {
|
||||
downloadFile(target.path)
|
||||
return
|
||||
}
|
||||
copyPath(
|
||||
id === 'relative' ? relativeTo(cwd ?? '', target.path) : target.path,
|
||||
target.path,
|
||||
)
|
||||
}}
|
||||
portal
|
||||
align="start"
|
||||
getAnchorRect={() => (rowMenu === null ? null : new DOMRect(rowMenu.x, rowMenu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
/**
|
||||
* 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: 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); directory rows offer
|
||||
* "upload here"; every row can copy the relative or absolute path (with a
|
||||
* brief "copied" label replacing the button after a successful write).
|
||||
*
|
||||
* Uploads start here (drag-drop or the context menu picker) but run in the
|
||||
* caller: every request is reported through `onUploadRequest(dir, items)`
|
||||
* (VSCode semantics — a drop on a file row targets its parent directory),
|
||||
* and `busy` gates new drags while one upload is in flight.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16, IconFolderClose16, IconFolderOpen16,
|
||||
IconLinkOutline16, Menu, writeClipboard,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { api, downloadUrl, type FsEntry } from './api.ts'
|
||||
import { IconUploadOutline16 } from './icons.tsx'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { uploadItemsFromDrop, uploadItemsFromFiles, type UploadItem } from './upload.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
interface LevelData {
|
||||
entries?: FsEntry[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Root label: the last path segment (mirror of the host rootLabel). */
|
||||
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)
|
||||
}
|
||||
|
||||
/** The containing directory of an absolute row path (never the root edge here). */
|
||||
function parentOf(path: string): string {
|
||||
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
||||
return at <= 0 ? path : path.slice(0, at)
|
||||
}
|
||||
|
||||
/** Only OS file drags belong to the upload surface; in-app drags (tab reorder,
|
||||
* split zones) must pass through untouched to the pane's tab-drop handling
|
||||
* (mirror of Sidebar.tsx's panel-host shield gate). */
|
||||
function isFileDrag(event: DragEvent): boolean {
|
||||
return event.dataTransfer?.types.includes('Files') ?? false
|
||||
}
|
||||
|
||||
/** How long the row's "copied" label stays after a successful write. */
|
||||
const COPIED_MS = 1200
|
||||
|
||||
/**
|
||||
* The drop overlay's hero art: an arrow rising out of a notched tray
|
||||
* (upload zone — the same glyph family as the toolbar's upload icon) and a
|
||||
* tilted pair of photo cards (chat zone). Hand-drawn, colored in the
|
||||
* palette of DSH's own native drop illustration (#3964FE / #9CE5ED) so the
|
||||
* two zones read as one family; the drop overlay is this flow's one brand
|
||||
* moment, so it gets color the rest of the UI never does.
|
||||
*/
|
||||
const UploadDropIllustration = () => (
|
||||
<svg width="64" height="56" viewBox="0 0 64 56" fill="none" aria-hidden="true">
|
||||
<path d="M32 28V11" stroke="#3964FE" strokeWidth="5" strokeLinecap="round" />
|
||||
<path d="M23 20l9-9 9 9" stroke="#3964FE" strokeWidth="5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path
|
||||
d="M10 40a4 4 0 0 1 4-4h7l3.2 4.6a5 5 0 0 0 4.1 2.2h7.4a5 5 0 0 0 4.1-2.2L43 36h7a4 4 0 0 1 4 4v2a10 10 0 0 1-10 10H20A10 10 0 0 1 10 42v-2z"
|
||||
fill="#9CE5ED"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** The chat zone's art: two tilted photo cards, each with its own
|
||||
* sun-over-mountains motif (the back card carries detail too, so it never
|
||||
* reads as a bare blob). */
|
||||
const ChatDropIllustration = () => (
|
||||
<svg width="96" height="76" viewBox="0 0 96 76" fill="none" aria-hidden="true">
|
||||
<g transform="rotate(-12 24 34)">
|
||||
<rect x="6" y="16" width="36" height="36" rx="10" fill="#9CE5ED" />
|
||||
<circle cx="16" cy="27" r="3.5" fill="white" />
|
||||
<path d="M11 44l8-9 6 6 4-4 8 9" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</g>
|
||||
<g transform="rotate(8 61 35)">
|
||||
<rect x="40" y="12" width="42" height="46" rx="10" fill="#3964FE" />
|
||||
<circle cx="55" cy="27" r="5" fill="white" />
|
||||
<path d="M46 50l10-13 7 8 6-6 9 11" stroke="white" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
|
||||
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
|
||||
/** Upload into `dir` (absolute, inside the workspace); runs in the caller. */
|
||||
onUploadRequest: (dir: string, items: UploadItem[]) => void
|
||||
/** True while an upload is in flight (drops are ignored). */
|
||||
busy: boolean
|
||||
}) {
|
||||
const { sessionId, cwd, expanded, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, onReferenceFile, refreshTick, onUploadRequest, busy } = props
|
||||
const [data, setData] = useState<Record<string, LevelData>>({})
|
||||
const dataRef = useRef(data)
|
||||
/** 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. */
|
||||
const [rowMenu, setRowMenu] = useState<{ path: string; isDir: boolean; x: number; y: number } | null>(null)
|
||||
/** Whether a file drag hovers the tree (drives the portaled drop zone). */
|
||||
const [dropOver, setDropOver] = useState(false)
|
||||
/** The directory a drag is hovering right now (null = body, drop to root). */
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null)
|
||||
/**
|
||||
* Enter/leave depth under the tree body. dragenter/dragleave fire per
|
||||
* element along the drag path (and bubble), so a counter — DSH InputBar's
|
||||
* own pattern — is the flicker-free signal; relatedTarget is unreliable
|
||||
* across engines for drag events.
|
||||
*/
|
||||
const dropDepth = useRef(0)
|
||||
/** Explorer body element; its viewport rect anchors the portaled drop zone. */
|
||||
const bodyRef = useRef<HTMLDivElement>(null)
|
||||
/** The body's viewport rect captured at drag entry (null = not measured). */
|
||||
const [dropRect, setDropRect] = useState<{ top: number; left: number; width: number; height: number } | null>(null)
|
||||
/** Context-menu "upload here" target directory. */
|
||||
const pendingUploadDir = useRef<string | undefined>(undefined)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
/** Reset all drag state (drop landed, the drag left, or a new drag begins). */
|
||||
const resetDrop = (): void => {
|
||||
dropDepth.current = 0
|
||||
setDropOver(false)
|
||||
setDropTarget(null)
|
||||
setDropRect(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop handlers: always swallow the event (a dropped file must never open
|
||||
* in the browser), then report the target directory to the caller. A drop
|
||||
* ends the drag without further leave events, so the depth resets here.
|
||||
* The payload collection is async (dropped folders are traversed through
|
||||
* their entry handles — captured synchronously inside uploadItemsFromDrop
|
||||
* while the dataTransfer is still live), so the request rides a then.
|
||||
*/
|
||||
const reportDrop = (dir: string, data: DataTransfer | undefined): void => {
|
||||
if (busy) return
|
||||
void uploadItemsFromDrop(data).then((items) => {
|
||||
if (items.length > 0) onUploadRequest(dir, items)
|
||||
})
|
||||
}
|
||||
const handleBodyDrop = (event: DragEvent): void => {
|
||||
if (!isFileDrag(event)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
resetDrop()
|
||||
if (cwd !== undefined) reportDrop(cwd, event.dataTransfer)
|
||||
}
|
||||
const handleDirDrop = (event: DragEvent, dir: string): void => {
|
||||
if (!isFileDrag(event)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
resetDrop()
|
||||
reportDrop(dir, event.dataTransfer)
|
||||
}
|
||||
const handleFileDrop = (event: DragEvent, path: string): void => {
|
||||
// VSCode semantics: dropping onto a file uploads into its directory.
|
||||
handleDirDrop(event, parentOf(path))
|
||||
}
|
||||
const handleBodyDragEnter = (event: DragEvent): void => {
|
||||
if (!isFileDrag(event)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dropDepth.current += 1
|
||||
if (busy) return
|
||||
// First entry: anchor the portaled drop zone to the body's rect.
|
||||
if (dropDepth.current === 1) {
|
||||
const rect = bodyRef.current?.getBoundingClientRect()
|
||||
setDropRect(rect === undefined ? null : { top: rect.top, left: rect.left, width: rect.width, height: rect.height })
|
||||
}
|
||||
setDropOver(true)
|
||||
}
|
||||
const handleBodyDragLeave = (): void => {
|
||||
dropDepth.current = Math.max(0, dropDepth.current - 1)
|
||||
if (dropDepth.current > 0) return
|
||||
setDropOver(false)
|
||||
setDropTarget(null)
|
||||
setDropRect(null)
|
||||
}
|
||||
const handleBodyDragOver = (event: DragEvent): void => {
|
||||
if (!isFileDrag(event)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.dataTransfer.dropEffect = busy ? 'none' : 'copy'
|
||||
if (busy) return
|
||||
// Rows stop propagation, so this only fires over non-row regions: the
|
||||
// drag targets the workspace root. dragover fires continuously, making
|
||||
// it the authoritative (flicker-free) place to clear the row target.
|
||||
setDropTarget(null)
|
||||
}
|
||||
const handleRowDragOver = (event: DragEvent, dir: string): void => {
|
||||
if (!isFileDrag(event)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.dataTransfer.dropEffect = busy ? 'none' : 'copy'
|
||||
if (busy) return
|
||||
setDropTarget(dir)
|
||||
}
|
||||
|
||||
const storeLevel = useCallback((path: string, level: LevelData) => {
|
||||
dataRef.current = { ...dataRef.current, [path]: level }
|
||||
setData(dataRef.current)
|
||||
}, [])
|
||||
|
||||
const loadDir = useCallback((dir: string) => {
|
||||
if (dataRef.current[dir] !== undefined) return
|
||||
storeLevel(dir, {})
|
||||
api.fsTree({ sessionId, cwd }, dir).then((listing) => {
|
||||
storeLevel(dir, { entries: listing.entries })
|
||||
}).catch((error: unknown) => {
|
||||
storeLevel(dir, { error: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
}, [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 tick wipes the cache.
|
||||
const root = cwd
|
||||
if (root === undefined) return
|
||||
loadDir(root)
|
||||
for (const dir of expanded) loadDir(dir)
|
||||
}, [cwd, expanded, refreshTick, loadDir])
|
||||
|
||||
/** Copy `text`; on success flip the row's copied label for a moment. */
|
||||
const copyPath = useCallback((text: string, path: string): void => {
|
||||
void writeClipboard(text).then((ok) => {
|
||||
if (!ok) return
|
||||
setCopiedPath(path)
|
||||
window.setTimeout(() => {
|
||||
setCopiedPath(current => current === path ? null : current)
|
||||
}, COPIED_MS)
|
||||
})
|
||||
}, [])
|
||||
|
||||
/** The row's trailing actions: the @-reference button, or the copied label. */
|
||||
const rowActions = (entry: FsEntry): ReactNode => {
|
||||
if (copiedPath === entry.path) {
|
||||
return <span className={css.explorerCopied}>{t('copied')}</span>
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={css.explorerRef}
|
||||
aria-label={t('referenceFile')}
|
||||
title={t('referenceFile')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onReferenceFile(entry.path)
|
||||
}}
|
||||
>
|
||||
{t('referenceFile')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const openRowMenu = (event: MouseEvent, path: string, isDir: boolean): void => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setRowMenu({ path, isDir, x: event.clientX, y: event.clientY })
|
||||
}
|
||||
|
||||
/** Download a file through the host route (raw bytes, binary-safe). */
|
||||
const downloadFile = (path: string): void => {
|
||||
const url = downloadUrl({ sessionId, cwd }, path)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.style.display = 'none'
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
}
|
||||
|
||||
const root = cwd
|
||||
|
||||
const renderLevel = (dir: string, depth: number): ReactNode => {
|
||||
const level = data[dir]
|
||||
if (level === undefined) {
|
||||
return <div className={css.explorerRow} style={{ paddingLeft: depth * 22 + 6 }}>{t('loading')}</div>
|
||||
}
|
||||
if (level.error !== undefined) {
|
||||
return (
|
||||
<div className={clsx(css.explorerRow, css.explorerError)} style={{ paddingLeft: depth * 22 + 6 }}>
|
||||
{level.error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const entries = level.entries ?? []
|
||||
return entries.map(entry => {
|
||||
if (entry.isDir) {
|
||||
const isOpen = expanded.includes(entry.path)
|
||||
return (
|
||||
<div key={entry.path}>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
css.explorerRow, css.explorerDir, entry.hidden && css.explorerHidden,
|
||||
dropTarget === entry.path && css.explorerRowDropTarget,
|
||||
)}
|
||||
style={{ paddingLeft: depth * 22 + 6 }}
|
||||
onClick={() => { onToggle(entry.path) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onToggle(entry.path)
|
||||
}
|
||||
}}
|
||||
onDragOver={(event) => { handleRowDragOver(event, entry.path) }}
|
||||
onDrop={(event) => { handleDirDrop(event, entry.path) }}
|
||||
onContextMenu={(event) => { openRowMenu(event, entry.path, true) }}
|
||||
>
|
||||
{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)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={entry.path}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
css.explorerRow, entry.hidden && css.explorerHidden, entry.broken && css.explorerBroken,
|
||||
dropTarget === parentOf(entry.path) && css.explorerRowDropTarget,
|
||||
)}
|
||||
style={{ paddingLeft: depth * 22 + 6 }}
|
||||
title={entry.broken ? `${entry.path} — ${t('brokenSymlink')}` : entry.path}
|
||||
onClick={() => { onOpenFile(entry.path) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onOpenFile(entry.path)
|
||||
}
|
||||
}}
|
||||
onDragOver={(event) => { handleRowDragOver(event, parentOf(entry.path)) }}
|
||||
onDrop={(event) => { handleFileDrop(event, entry.path) }}
|
||||
onContextMenu={(event) => { openRowMenu(event, entry.path, false) }}
|
||||
>
|
||||
<IconCodeOutline16 size={14} />
|
||||
<span className={css.explorerName}>{entry.name}</span>
|
||||
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
|
||||
{rowActions(entry)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={bodyRef}
|
||||
className={css.explorerBody}
|
||||
onDragEnter={handleBodyDragEnter}
|
||||
onDragOver={handleBodyDragOver}
|
||||
onDragLeave={handleBodyDragLeave}
|
||||
onDrop={handleBodyDrop}
|
||||
>
|
||||
{root === undefined ? (
|
||||
<div className={css.explorerEmpty}>{t('noSession')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={clsx(css.explorerRow, dropTarget === root && css.explorerRowDropTarget)}
|
||||
style={{ paddingLeft: 6 }}
|
||||
onDragOver={(event) => { handleRowDragOver(event, root) }}
|
||||
onDrop={(event) => { handleDirDrop(event, root) }}
|
||||
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)}
|
||||
</>
|
||||
)}
|
||||
{dropOver && dropRect !== null && createPortal(
|
||||
/*
|
||||
* The sidebar's drop surface, portaled to document.body at z-1001 —
|
||||
* above DSH's own whole-page drop mask (z-1000, see InputBar's
|
||||
* document-level intake) so the two never compete. It dims the WHOLE
|
||||
* viewport (the giant box-shadow spread on the zone frame is the
|
||||
* mask; the zone rect itself stays clear), teaching the zone split:
|
||||
* the dimmed conversation column still takes drops into the chat
|
||||
* natively (this layer is pointer-inert), while the clear frame
|
||||
* marks the tree as the workspace-upload zone. Deliberate exception
|
||||
* to the "panel stays below the DSH float stack" rule: transient,
|
||||
* and the drop always lands on the element beneath. The hint pill
|
||||
* docks at the TOP edge of the zone — right under the search row,
|
||||
* the first thing the eye meets — keeping the rows aimable.
|
||||
*/
|
||||
<>
|
||||
<div
|
||||
className={css.uploadDropZone}
|
||||
style={{
|
||||
top: dropRect.top + 2,
|
||||
left: dropRect.left + 2,
|
||||
width: dropRect.width - 4,
|
||||
height: dropRect.height - 4,
|
||||
}}
|
||||
>
|
||||
<div className={css.uploadDropHero}>
|
||||
<UploadDropIllustration />
|
||||
<div className={css.uploadDropZonePill}>
|
||||
<IconUploadOutline16 size={14} />
|
||||
<span className={css.uploadDropZoneText}>
|
||||
{dropTarget !== null ? t('uploadTo', { dir: dropTarget }) : t('uploadDropHint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* The left zone's invitation, centered in the space beside the
|
||||
tree; skipped when that space is too narrow to hold it. */}
|
||||
{dropRect.left >= 200 && (
|
||||
<div className={css.uploadDropChatHint} style={{ width: dropRect.left }}>
|
||||
<div className={css.uploadDropChatCard}>
|
||||
<ChatDropIllustration />
|
||||
<span>{t('uploadDropChat')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
{/*
|
||||
The one shared context menu, positioned at the right-click cursor
|
||||
(portal so the tree's overflow clip cannot crop it).
|
||||
*/}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={(event) => {
|
||||
const dir = pendingUploadDir.current ?? root
|
||||
pendingUploadDir.current = undefined
|
||||
if (dir !== undefined && !busy) onUploadRequest(dir, uploadItemsFromFiles(event.target.files ?? []))
|
||||
event.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<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} /> }]
|
||||
: []),
|
||||
// Upload into a directory (incl. the workspace root row).
|
||||
...(rowMenu?.isDir === true
|
||||
? [{ id: 'upload-here', label: t('uploadHere'), icon: <IconUploadOutline16 size={14} /> }]
|
||||
: []),
|
||||
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
|
||||
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
|
||||
]}
|
||||
onSelect={(id) => {
|
||||
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
|
||||
}
|
||||
if (id === 'upload-here') {
|
||||
pendingUploadDir.current = target.path
|
||||
fileInputRef.current?.click()
|
||||
return
|
||||
}
|
||||
copyPath(
|
||||
id === 'relative' ? relativeTo(cwd ?? '', target.path) : target.path,
|
||||
target.path,
|
||||
)
|
||||
}}
|
||||
portal
|
||||
align="start"
|
||||
getAnchorRect={() => (rowMenu === null ? null : new DOMRect(rowMenu.x, rowMenu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -297,7 +297,7 @@ export function GitView(props: {
|
||||
title={t('refresh')}
|
||||
onClick={() => { void refresh() }}
|
||||
>
|
||||
<IconRefreshOutline16 />
|
||||
<IconRefreshOutline16 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
/** High-fidelity browser-native PPTX preview with slide navigation. */
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { downloadUrl, mediaUrl, type SessionScope } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
type PptxViewerInstance = import('@aiden0z/pptx-renderer').PptxViewer
|
||||
|
||||
type LoadState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'ready'; count: number }
|
||||
| { status: 'error'; message: string }
|
||||
|
||||
export function PptxView(props: { scope: SessionScope; path: string; title: string }) {
|
||||
const { scope, path, title } = props
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const viewerRef = useRef<PptxViewerInstance | null>(null)
|
||||
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
|
||||
const [slide, setSlide] = useState(0)
|
||||
const [navigating, setNavigating] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
const host = hostRef.current
|
||||
if (host === null) return
|
||||
setLoad({ status: 'loading' })
|
||||
setSlide(0)
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(mediaUrl(scope, path), { signal: controller.signal })
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
const bytes = await response.arrayBuffer()
|
||||
if (controller.signal.aborted) return
|
||||
const { PptxViewer, RECOMMENDED_ZIP_LIMITS } = await import('@aiden0z/pptx-renderer')
|
||||
if (controller.signal.aborted) return
|
||||
const viewer = await PptxViewer.open(bytes, host, {
|
||||
renderMode: 'slide',
|
||||
fitMode: 'contain',
|
||||
lazyMedia: true,
|
||||
lazySlides: true,
|
||||
pdfjs: false,
|
||||
signal: controller.signal,
|
||||
zipLimits: RECOMMENDED_ZIP_LIMITS,
|
||||
onSlideChange: (index) => {
|
||||
if (!controller.signal.aborted) setSlide(index)
|
||||
},
|
||||
})
|
||||
if (controller.signal.aborted) {
|
||||
viewer.destroy()
|
||||
return
|
||||
}
|
||||
viewerRef.current = viewer
|
||||
setSlide(viewer.currentSlideIndex)
|
||||
setLoad({ status: 'ready', count: viewer.slideCount })
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return
|
||||
try {
|
||||
viewerRef.current?.destroy()
|
||||
} catch {
|
||||
// Partially initialized viewer — ignore disposal errors.
|
||||
}
|
||||
viewerRef.current = null
|
||||
host.innerHTML = ''
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
controller.abort()
|
||||
try {
|
||||
viewerRef.current?.destroy()
|
||||
} catch {
|
||||
// Already destroyed.
|
||||
}
|
||||
viewerRef.current = null
|
||||
host.innerHTML = ''
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
const navigate = (target: number): void => {
|
||||
const viewer = viewerRef.current
|
||||
if (viewer === null || load.status !== 'ready' || navigating) return
|
||||
const next = Math.max(0, Math.min(load.count - 1, target))
|
||||
if (next === slide) return
|
||||
setNavigating(true)
|
||||
void viewer.goToSlide(next, { behavior: 'auto' }).then(() => {
|
||||
setSlide(viewer.currentSlideIndex)
|
||||
}).catch((error: unknown) => {
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}).finally(() => {
|
||||
setNavigating(false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.editorPptx} aria-label={title}>
|
||||
<div className={css.editorPptxToolbar}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.editorPptxButton}
|
||||
disabled={load.status !== 'ready' || slide <= 0 || navigating}
|
||||
onClick={() => { navigate(slide - 1) }}
|
||||
>
|
||||
{t('previousSlide')}
|
||||
</button>
|
||||
<span className={css.editorPptxPosition}>
|
||||
{load.status === 'ready' ? `${slide + 1} / ${load.count}` : '– / –'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.editorPptxButton}
|
||||
disabled={load.status !== 'ready' || slide >= load.count - 1 || navigating}
|
||||
onClick={() => { navigate(slide + 1) }}
|
||||
>
|
||||
{t('nextSlide')}
|
||||
</button>
|
||||
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||
{t('downloadToView')}
|
||||
</a>
|
||||
</div>
|
||||
<div className={css.editorPptxStage}>
|
||||
<div className={css.editorPptxHost} ref={hostRef} />
|
||||
{load.status !== 'ready' && (
|
||||
<div className={css.editorOfficeOverlay}>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* The generic render error boundary for the sidebar tree: a render error in
|
||||
* the wrapped subtree shows a dismissible error strip (retry re-renders the
|
||||
* children) instead of blanking the shell. Used at two scopes:
|
||||
*
|
||||
* - ROOT (index.tsx, `css.boundaryError`): last-resort containment for
|
||||
* errors in the sidebar shell itself (Workbench, drag layout, …) — a full
|
||||
* swap keeps the page alive.
|
||||
* - PER-TAB (Sidebar.tsx TabContent, `css.tabBoundaryError`): a crashing
|
||||
* viewer/editor shows a strip inside ITS OWN pane; the toggle cluster, the
|
||||
* other tabs, and the panel itself stay alive (issue #31 — a tab crash
|
||||
* must never take down the whole sidebar).
|
||||
*
|
||||
* The className prop selects the strip's geometry: the root's full-height
|
||||
* fixed rail vs. the tab's pane-filling block.
|
||||
*/
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
export class RenderBoundary extends Component<{ children?: ReactNode; className?: string }, { error: string | null }> {
|
||||
state = { error: null as string | null }
|
||||
|
||||
static getDerivedStateFromError(error: unknown): { error: string } {
|
||||
return { error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.error('[dsh-better-sidebar] render error:', error, info.componentStack)
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.error !== null) {
|
||||
return (
|
||||
<div className={this.props.className}>
|
||||
<span>dsh-better-sidebar: {this.state.error}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.terminalRetry}
|
||||
onClick={() => { this.setState({ error: null }) }}
|
||||
>
|
||||
{t('terminalRetry')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,107 @@
|
||||
/**
|
||||
* Side card settings section chrome (DSH-native tokens throughout):
|
||||
* - the section is a column of GROUP CONTAINER cards (the PluginCard recipe:
|
||||
* l2 hairline, 16px radius, layer-3 fill) with a heading + inventory count
|
||||
* badge (the settings catalogHeading recipe), opening with a one-line
|
||||
* intro (the DSH section heading+intro recipe);
|
||||
* - 常规 holds full-width settings rows (the DSH settings-row recipe:
|
||||
* title/desc left + control right, hairline separators) whose toggles are
|
||||
* custom switches — a real checkbox (native semantics and focus) driving
|
||||
* a styled track/thumb;
|
||||
* - the feature inventories are responsive GRIDS of small toggle cards
|
||||
* (auto-fill columns — several cards per row, wrapping);
|
||||
* - a card's on/off state IS its visual state: enabled = brand border +
|
||||
* tinted fill + a check badge pinned to the far right of the top row,
|
||||
* disabled = neutral hairline + dimmed text;
|
||||
* - features that declare related settings carry a gear corner button
|
||||
* opening a native Modal whose rows are the DSH settings-row recipe
|
||||
* (title/desc left + native accent checkbox, hairline separators);
|
||||
* (auto-fill columns — several cards per row, wrapping): icon chip +
|
||||
* title + type id, the card's on/off state IS its visual state
|
||||
* (enabled = softened brand border + tinted fill + a compact brand
|
||||
* switch knob at the far right — the card itself is the toggle;
|
||||
* disabled = neutral hairline + dimmed, no knob); every card shares a
|
||||
* uniform min-height so rows stay equal whether or not they carry a
|
||||
* settings strip;
|
||||
* - features that declare related settings carry a labeled SETTINGS STRIP
|
||||
* across the card's bottom edge (gear icon + text, hairline-separated)
|
||||
* opening a native Modal (wider than the primitive default) whose rows
|
||||
* follow the same row recipe with switch controls and a Done footer;
|
||||
* - the width input stays a native settings row (a value, not a switch).
|
||||
*/
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 16px;
|
||||
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. */
|
||||
}
|
||||
|
||||
.section > :last-child {
|
||||
border-bottom: none;
|
||||
/* One-line section intro (the DSH section heading+intro recipe). */
|
||||
.intro {
|
||||
margin: 0;
|
||||
padding: 0 2px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Group container: the DSH card recipe (PluginCard) — l2 hairline, r16,
|
||||
layer-3 fill; holds its own heading + stacked rows / card grid. */
|
||||
.group {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 16px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
/* Group heading: 13/600 + the inventory count badge (the settings
|
||||
catalogHeading recipe: count in tabular-nums, tertiary). */
|
||||
.groupHeading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
padding: 0 2px 6px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* The inventory count: a soft-accent pill (the versionBadgeTag token recipe)
|
||||
so the count reads as metadata, not as part of the heading text. */
|
||||
.count {
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-alias-accent-soft, var(--dsw-alias-bg-layer-2));
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Responsive inventory: as many small cards per row as the width allows. */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* One small toggle card (visual chrome; the interactive part is .cardMain). */
|
||||
/* One small toggle card (visual chrome; the interactive part is .cardMain).
|
||||
A uniform min-height keeps every inventory card the same height across
|
||||
rows whether or not it carries a settings strip (strip cards are
|
||||
naturally taller); overflow:hidden clips the strip to the card's radius. */
|
||||
.card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 8px;
|
||||
min-height: 106px;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
@@ -52,21 +113,26 @@
|
||||
/* Hover affordance on disabled cards only (enabled cards are already lit). */
|
||||
.card:not(.cardOn):hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
border-color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
/* Enabled = highlighted: brand border + tinted fill. */
|
||||
/* Enabled = highlighted: a SOFTENED brand border (color-mix, the icon-chip
|
||||
precedent) + tinted fill — full-strength brand reads harsh at card scale. */
|
||||
.cardOn {
|
||||
border-color: var(--dsw-alias-button-primary-fill);
|
||||
border-color: color-mix(in srgb, var(--dsw-alias-button-primary-fill) 45%, transparent);
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
}
|
||||
|
||||
/* The card's switch: the whole main area (top row + desc) flips the feature. */
|
||||
/* The card's switch: the whole main area (top row + desc) flips the feature.
|
||||
flex:1 lets the main area absorb the extra height of the uniform card
|
||||
(cards without a settings strip stretch to match the strip cards). */
|
||||
.cardMain {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
padding: 12px;
|
||||
border: 0;
|
||||
border-radius: inherit;
|
||||
background: transparent;
|
||||
@@ -77,7 +143,8 @@
|
||||
}
|
||||
|
||||
.cardMain:focus-visible,
|
||||
.cardGear:focus-visible {
|
||||
.cardSettings:focus-visible,
|
||||
.rowGear:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-l4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -85,18 +152,29 @@
|
||||
.cardTop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.cardIcon {
|
||||
/* The icon sits in a rounded chip: brand-tinted while the card is on,
|
||||
neutral (layer-2 fill + hairline) while off. */
|
||||
.cardIconChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.cardOn .cardIcon {
|
||||
.cardOn .cardIconChip {
|
||||
border-color: color-mix(in srgb, var(--dsw-alias-button-primary-fill) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--dsw-alias-button-primary-fill) 12%, transparent);
|
||||
color: var(--dsw-alias-button-primary-fill);
|
||||
}
|
||||
|
||||
@@ -105,6 +183,7 @@
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -115,12 +194,46 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* The check badge: pinned to the FAR RIGHT of the top row (enabled only). */
|
||||
.cardCheck {
|
||||
/* The compact on/off switch knob at the card's far right (enabled only):
|
||||
the card itself is the toggle (aria-pressed on .cardMain), so this is a
|
||||
PURELY VISUAL switch — same track/thumb geometry as the row switches,
|
||||
one size down. Off cards show no knob (neutral card = off). */
|
||||
.cardSwitch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
color: var(--dsw-alias-button-primary-fill);
|
||||
}
|
||||
|
||||
.cardSwitchTrack {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 30px;
|
||||
height: 16px;
|
||||
padding: 2px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.cardSwitchThumb {
|
||||
display: block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-label-tertiary);
|
||||
transition: transform 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.cardOn .cardSwitchTrack {
|
||||
border-color: var(--dsw-alias-button-primary-fill);
|
||||
background: var(--dsw-alias-button-primary-fill);
|
||||
}
|
||||
|
||||
.cardOn .cardSwitchThumb {
|
||||
transform: translateX(14px);
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
.cardDesc {
|
||||
@@ -132,58 +245,115 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* The "add plugin" card: same grid cell / chrome as the inventory cards
|
||||
(it shares .card), but a DASHED border — the repo's first dashed surface;
|
||||
the hover accent follows the existing drag-drop dashed outlines
|
||||
(sidebar.module.css .dropCenter / .tabBarDrop). */
|
||||
.addCard {
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
border-style: dashed;
|
||||
border-color: var(--dsw-alias-border-l2);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.addCard:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
border-color: var(--dsw-alias-interactive-bg-hover-accent);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.addCard:hover .cardTitle {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.addCard:hover .cardIconChip {
|
||||
border-color: color-mix(in srgb, var(--dsw-alias-button-primary-fill) 35%, transparent);
|
||||
color: var(--dsw-alias-button-primary-fill);
|
||||
}
|
||||
|
||||
.addCard:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-l4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.cardOn .cardDesc {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* The gear corner button: bottom-right of cards that declare related
|
||||
settings; the desc gets right padding so the ellipsis never runs under it. */
|
||||
.cardWithGear .cardDesc {
|
||||
padding-right: 18px;
|
||||
/* The settings strip: a labeled full-width footer across the card's bottom
|
||||
edge (gear icon + "功能设置" text), hairline-separated from the toggle
|
||||
area. This is the SECONDARY settings entry — it must be discoverable at
|
||||
rest (not a hover-only ghost), so it keeps a steady secondary-ink label
|
||||
and answers hover with the brand-accent tint + brand ink. */
|
||||
.cardSettings {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--dsw-alias-border-l1);
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.cardGear {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
.cardOn .cardSettings {
|
||||
border-top-color: color-mix(in srgb, var(--dsw-alias-button-primary-fill) 18%, transparent);
|
||||
}
|
||||
|
||||
.cardSettings:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover-accent);
|
||||
color: var(--dsw-alias-brand-primary);
|
||||
}
|
||||
|
||||
/* The gear icon button on a general settings ROW (the position-compat
|
||||
row's "customize the strip height" entry): a bordered chip (not a ghost)
|
||||
so it reads as a control at rest; hover answers with the accent tint. */
|
||||
.rowGear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cardGear:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Inventory section heading (常规 / 侧边栏内容 / 文件预览). */
|
||||
.sectionHeading {
|
||||
padding: 14px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
/* Native settings row recipe (the width input row + popup rows). */
|
||||
.rowGear:hover {
|
||||
border-color: var(--dsw-alias-interactive-bg-hover-accent);
|
||||
background: var(--dsw-alias-interactive-bg-hover-accent);
|
||||
color: var(--dsw-alias-brand-primary);
|
||||
}
|
||||
|
||||
/* Settings-row recipe (the DSH General section rows): title/desc left,
|
||||
control right, hairline separators (stripped on the last row). */
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 0;
|
||||
padding: 12px 2px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.rowText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -199,45 +369,66 @@
|
||||
|
||||
.desc {
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Native checkbox: 16px, brand accent (the settings-row recipe). */
|
||||
.toggle {
|
||||
/* Custom switch: a real checkbox (native semantics + focus, visually
|
||||
hidden) driving a styled 36x20 track with a sliding thumb. */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switchInput {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: 0;
|
||||
accent-color: var(--dsw-alias-button-primary-fill);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.toggle:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-l4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Secondary settings popup body: stacked native rows, trailing separator
|
||||
stripped by the trailing-child rule. */
|
||||
.popupRows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.popupRow {
|
||||
display: flex;
|
||||
.switchTrack {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
cursor: pointer;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
padding: 2px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.popupRows > :last-child {
|
||||
border-bottom: none;
|
||||
.switchThumb {
|
||||
display: block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-label-tertiary);
|
||||
transition: transform 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.switch:hover .switchTrack {
|
||||
border-color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
.switchInput:checked + .switchTrack {
|
||||
border-color: var(--dsw-alias-button-primary-fill);
|
||||
background: var(--dsw-alias-button-primary-fill);
|
||||
}
|
||||
|
||||
.switchInput:checked + .switchTrack .switchThumb {
|
||||
transform: translateX(16px);
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
.switchInput:focus-visible + .switchTrack {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.control {
|
||||
@@ -251,12 +442,180 @@
|
||||
width: 76px;
|
||||
}
|
||||
|
||||
/* Text/number rows in the settings popup: the free-form input (e.g. the
|
||||
terminal font family) gets a generous width; numeric rows (font size)
|
||||
keep the compact width of the general number inputs. */
|
||||
.typedInput {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.typedInputNumber {
|
||||
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;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* The multi-line custom-CSS input (scheme `custom`): monospace, generous
|
||||
height, resizable — same surface tokens as the typed inputs. */
|
||||
.cssTextArea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 120px;
|
||||
padding: 8px 10px;
|
||||
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-family: var(--ds-font-family-code, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.cssTextArea:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Secondary settings popup: a wider dialog than the primitive default (the
|
||||
dialog portals to document.body; the doubled class keeps the width rule
|
||||
above the primitive's .dialog regardless of stylesheet order). */
|
||||
.popupDialog.popupDialog {
|
||||
width: min(460px, 100%);
|
||||
}
|
||||
|
||||
/* 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.
|
||||
The body scrolls INTERNALLY once a feature declares many rows (e.g.
|
||||
Terminal's six) — the dialog shell and its Done footer stay put; a thin
|
||||
token-driven scrollbar makes the scrollable region discoverable.
|
||||
box-sizing:border-box keeps width:100% + the scrollbar gutter from
|
||||
overflowing horizontally (a content-box width would add the 4px pad and
|
||||
show a horizontal scrollbar); overflow-x:hidden is the belt-and-braces
|
||||
guarantee that rows always FILL the width and never slide sideways. */
|
||||
.popupRows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
max-height: min(52vh, 440px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 4px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--dsw-alias-scrollbar-bg-l2, transparent) transparent;
|
||||
}
|
||||
|
||||
.popupRows::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.popupRows::-webkit-scrollbar-thumb {
|
||||
border-radius: 3px;
|
||||
background: var(--dsw-alias-scrollbar-bg-l2, var(--dsw-alias-border-l2));
|
||||
}
|
||||
|
||||
.popupRows::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--dsw-alias-scrollbar-hover-l2, var(--dsw-alias-label-dimmed));
|
||||
}
|
||||
|
||||
.popupRow {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
padding: 12px 14px;
|
||||
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 {
|
||||
border-color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
/* The popup's Done button: the settings primary-button recipe
|
||||
(PluginCard .save: label-primary fill, inverted surface text). */
|
||||
.done {
|
||||
appearance: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 5px 14px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
background: var(--dsw-alias-label-primary);
|
||||
color: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
.done:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 10px 0 2px;
|
||||
font-size: 12px;
|
||||
@@ -264,8 +623,273 @@
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* ── "Add plugin" modal ──────────────────────────────────────────────── */
|
||||
|
||||
/* A wide dialog like the settings popup (doubled class keeps the width
|
||||
rule above the primitive's .dialog regardless of stylesheet order). */
|
||||
.pluginModal.pluginModal {
|
||||
width: min(560px, 100%);
|
||||
}
|
||||
|
||||
.pluginList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* The GitHub topic entry: a full-width secondary BUTTON (not a link — it
|
||||
opens a real new browser tab via window.open, bypassing the sidebar
|
||||
link takeover). */
|
||||
.pluginTopicBtn {
|
||||
width: 100%;
|
||||
appearance: none;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
padding: 6px 12px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pluginTopicBtn:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
border-color: var(--dsw-alias-interactive-bg-hover-accent);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.pluginTopicBtn:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-l4);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.pluginEmpty {
|
||||
padding: 20px 2px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* One catalog entry: name + install button on the head row, description,
|
||||
then the install command in a mono block (the exact script that gets
|
||||
pre-typed into the terminal — review it here before installing). */
|
||||
.pluginEntry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
}
|
||||
|
||||
.pluginEntryHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pluginEntryActions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* The per-entry "jump" button: opens the plugin repo in a REAL new browser
|
||||
tab (window.open — a button, so the sidebar link takeover cannot reroute
|
||||
it). Secondary style, quieter than the Install button. */
|
||||
.pluginJumpBtn {
|
||||
flex: none;
|
||||
appearance: none;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
padding: 3px 12px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.pluginJumpBtn:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
border-color: var(--dsw-alias-interactive-bg-hover-accent);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.pluginJumpBtn:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-l4);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* The plugin name: a BUTTON (not an anchor — an anchor would be caught by
|
||||
the sidebar link takeover at document capture, ignoring target=_blank)
|
||||
styled like a link; opens the repo in a real new browser tab. */
|
||||
.pluginName {
|
||||
appearance: none;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pluginName:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--dsw-alias-button-primary-fill);
|
||||
}
|
||||
|
||||
.pluginDesc {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.pluginInstall {
|
||||
display: block;
|
||||
padding: 6px 10px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The per-entry copy button: copies the install script to the
|
||||
clipboard (the settings primary-button recipe, smaller than .done). */
|
||||
.pluginCopyBtn {
|
||||
flex: none;
|
||||
appearance: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 3px 12px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
background: var(--dsw-alias-label-primary);
|
||||
color: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
.pluginCopyBtn:hover {
|
||||
background: var(--dsw-alias-button-primary-hover);
|
||||
}
|
||||
|
||||
.pluginCopyBtn:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card {
|
||||
.card,
|
||||
.cardSettings,
|
||||
.cardSwitchTrack,
|
||||
.cardSwitchThumb,
|
||||
.rowGear,
|
||||
.popupRow,
|
||||
.switchTrack,
|
||||
.switchThumb {
|
||||
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;
|
||||
overflow-x: hidden;
|
||||
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;
|
||||
}
|
||||
|
||||
+853
-130
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,437 @@
|
||||
/* Side Chat tab: ONE side conversation per tab (Codex-style). Visual
|
||||
* language mirrors the main conversation pane: right-aligned user bubbles
|
||||
* on --dsw-specific-bubble, full-width assistant markdown, a capsule
|
||||
* composer card on --dsw-specific-input-major with a circular accent
|
||||
* send/stop button. Tool calls, thinking and context injections share one
|
||||
* collapsible-line chrome (no cards); shimmer is reserved for generating
|
||||
* states (running status line, streaming rows, the creating hero). Motion
|
||||
* is crossfade-only (≤200ms entrances, no springs); reduced-motion disables
|
||||
* every loop. All values consume DSW tokens (fallbacks point at older alias
|
||||
* tokens for rc-era hosts). */
|
||||
.sidechat {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── thread header (status dot · agent badge · threads menu · save) ─── */
|
||||
.sidechatDetailHeader {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 36px;
|
||||
padding: 4px 8px 4px 12px;
|
||||
border-bottom: 1px solid var(--dsw-alias-hairline);
|
||||
}
|
||||
|
||||
.sidechatHeaderDot {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.sidechatHeaderSpacer {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidechatAgentBadge {
|
||||
flex: none;
|
||||
max-width: 55%;
|
||||
padding: 1px 8px;
|
||||
border: 1px solid var(--dsw-alias-hairline);
|
||||
border-radius: 999px;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidechatIconBtn {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
transition: background-color 100ms ease-out, color 100ms ease-out;
|
||||
}
|
||||
|
||||
.sidechatIconBtn:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.sidechatIconBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── hero (unbound tab: the one brand moment) ───────────────────────── */
|
||||
.sidechatHero {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 24px 20px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
text-align: center;
|
||||
animation: sidechatFadeIn 200ms ease-out;
|
||||
}
|
||||
|
||||
.sidechatHeroTitle {
|
||||
font: var(--dsw-font-s-14);
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.sidechatHeroDesc {
|
||||
max-width: 300px;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.sidechatPrimaryBtn {
|
||||
flex: none;
|
||||
margin-top: 4px;
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-alias-button-info-fill, var(--dsw-alias-accent));
|
||||
color: var(--dsw-alias-button-info-label, var(--dsw-alias-accent-ink, #fff));
|
||||
font: var(--dsw-font-s-13);
|
||||
cursor: pointer;
|
||||
transition: opacity 100ms ease-out;
|
||||
}
|
||||
|
||||
.sidechatPrimaryBtn:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.sidechatPrimaryBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.sidechatHint {
|
||||
flex: none;
|
||||
padding: 4px 12px;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.sidechatError {
|
||||
flex: none;
|
||||
padding: 4px 12px;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-danger);
|
||||
}
|
||||
|
||||
/* ── transcript ─────────────────────────────────────────────────────── */
|
||||
.sidechatScroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Rows crossfade in on append (keys are stable, so only NEW rows play). */
|
||||
.sidechatScroll > * {
|
||||
animation: sidechatRowIn 180ms ease-out;
|
||||
}
|
||||
|
||||
/* User message: right-aligned bubble, same surface as the main pane. */
|
||||
.sidechatUser {
|
||||
align-self: flex-end;
|
||||
max-width: 88%;
|
||||
padding: 8px 14px;
|
||||
border-radius: 18px;
|
||||
background: var(--dsw-specific-bubble, var(--dsw-alias-bg-base));
|
||||
font: var(--dsw-font-s-14);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Assistant message: full-width narration, no bubble — the main pane way. */
|
||||
.sidechatAssistant {
|
||||
align-self: stretch;
|
||||
font: var(--dsw-font-s-14);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* ── collapsible context rows (tool / thinking / injection, Codex-style) ──
|
||||
* One quiet line — chevron + label + one-line summary — that expands into
|
||||
* an indented body hung on a hairline thread. No cards, no fills: hierarchy
|
||||
* rides ink weight alone. Streaming rows shimmer the label (shimmer =
|
||||
* generating); failed rows go danger; reduced-motion stills every loop. */
|
||||
.sidechatRow {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.sidechatRowLine {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 1px 0;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.sidechatRowSummary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sidechatRowSummary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidechatRowSummary:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.sidechatRowStatic {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.sidechatRowChevron {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
transition: transform 100ms ease-out;
|
||||
}
|
||||
|
||||
.sidechatRow[open] .sidechatRowChevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.sidechatRowLabel {
|
||||
flex: none;
|
||||
max-width: 60%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.sidechatRowMono {
|
||||
font-family: var(--dsw-font-mono);
|
||||
}
|
||||
|
||||
.sidechatRowMeta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--dsw-font-mono);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.sidechatRowFailed .sidechatRowLabel,
|
||||
.sidechatRowFailed .sidechatRowMeta {
|
||||
color: var(--dsw-alias-danger);
|
||||
}
|
||||
|
||||
.sidechatRowBody {
|
||||
margin: 2px 0 4px 7px;
|
||||
padding: 2px 0 2px 10px;
|
||||
border-left: 1px solid var(--dsw-alias-hairline);
|
||||
}
|
||||
|
||||
.sidechatRowProse {
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidechatRowCode {
|
||||
margin: 0;
|
||||
padding: 4px 0;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
font-family: var(--dsw-font-mono);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidechatRowCode + .sidechatRowCode {
|
||||
border-top: 1px solid var(--dsw-alias-hairline);
|
||||
}
|
||||
|
||||
/* Shimmer sweep text: generating (streaming row labels, creating hero). */
|
||||
.sidechatShimmerText {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
var(--dsw-alias-label-tertiary) 0%,
|
||||
var(--dsw-alias-label-primary) 50%,
|
||||
var(--dsw-alias-label-tertiary) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: sidechatSweep 2.6s linear infinite;
|
||||
}
|
||||
|
||||
/* ── running status line (above the composer, main-pane style) ──────── */
|
||||
.sidechatStatus {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 2px 14px 6px;
|
||||
animation: sidechatFadeIn 160ms ease-out;
|
||||
}
|
||||
|
||||
.sidechatStatusText {
|
||||
font: var(--dsw-font-xxs-12);
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
var(--dsw-alias-label-tertiary) 0%,
|
||||
var(--dsw-alias-label-primary) 50%,
|
||||
var(--dsw-alias-label-tertiary) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: sidechatSweep 2.6s linear infinite;
|
||||
}
|
||||
|
||||
/* ── composer (capsule card, InputBar-aligned) ──────────────────────── */
|
||||
.sidechatComposer {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: 0 8px 8px;
|
||||
padding: 8px 8px 6px 14px;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin, var(--dsw-alias-hairline));
|
||||
border-radius: 16px;
|
||||
background: var(--dsw-specific-input-major, var(--dsw-alias-bg-base));
|
||||
box-shadow: var(--dsw-shadow-lv2, none);
|
||||
}
|
||||
|
||||
.sidechatComposerInput {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 2px 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: var(--dsw-font-s-14);
|
||||
line-height: 22px;
|
||||
outline: none;
|
||||
resize: none;
|
||||
max-height: 132px;
|
||||
}
|
||||
|
||||
.sidechatComposerInput::placeholder {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.sidechatComposerBar {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.sidechatComposerMeta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidechatSendBtn {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-button-info-fill, var(--dsw-alias-accent));
|
||||
color: var(--dsw-alias-button-info-label, var(--dsw-alias-accent-ink, #fff));
|
||||
cursor: pointer;
|
||||
/* send ⇄ stop swap: keyed remount plays this, hover only fades. */
|
||||
animation: sidechatBtnIn 120ms ease-out;
|
||||
transition: opacity 100ms ease-out;
|
||||
}
|
||||
|
||||
.sidechatSendBtn:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.sidechatSendBtn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── motion vocabulary (crossfade only, ≤200ms; loops reduced-motion off) */
|
||||
@keyframes sidechatRowIn {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes sidechatFadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes sidechatBtnIn {
|
||||
from { opacity: 0; transform: scale(0.85); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes sidechatSweep {
|
||||
from { background-position: 200% 0; }
|
||||
to { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sidechatScroll > *,
|
||||
.sidechatHero,
|
||||
.sidechatStatus,
|
||||
.sidechatSendBtn {
|
||||
animation: none;
|
||||
}
|
||||
.sidechatStatusText,
|
||||
.sidechatShimmerText {
|
||||
animation: none;
|
||||
background-image: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
.sidechatRowChevron {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,644 @@
|
||||
/**
|
||||
* Side Chat page: Codex-style side conversations for the current session.
|
||||
*
|
||||
* EVERY side conversation is its own sidebar tab (侧边对话1/2/3 …): the
|
||||
* descriptor's createTab mints a fresh tab flagged `autoCreate` and this
|
||||
* view creates the EMPTY thread on mount (one click = one conversation,
|
||||
* exactly like the Codex app); the composer owns the first message (the
|
||||
* host wraps it with the side boundary + the in-progress snapshot parked
|
||||
* at creation, and the thread earns its real label — and the tab its
|
||||
* title — from that first message). Closing the tab releases the thread's
|
||||
* live agent (its history stays persisted); the header menu reopens any
|
||||
* existing thread into a tab (deduped by threadId).
|
||||
*
|
||||
* Each side thread is a child session the plugin created itself with a
|
||||
* custom seed (the parent's full log up to the click moment — see
|
||||
* sidechat-core.ts). Transport: thread creation/follow-up/cancel/dispose/
|
||||
* info go through the plugin's own /sidebar/api sidechat.* routes
|
||||
* (subagent-origin identities are fenced from the generic session RPCs);
|
||||
* the transcript is polled from the generic session.history RPC (seed-cut
|
||||
* at session/end-seed, boundary row dropped, chunk streaming accumulated)
|
||||
* — see sidechat-transcript.ts.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconChevronRightOutline14,
|
||||
IconNewChatOutline16,
|
||||
IconPlusOutline16,
|
||||
IconSendOutline16,
|
||||
IconStopFill16,
|
||||
MarkdownText,
|
||||
Menu,
|
||||
StateDot,
|
||||
type MenuEntry,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconHistoryOutline16, IconSaveOutline16 } from './icons.tsx'
|
||||
import type { Context, SidebarHistoryEntry } from '../context-types.ts'
|
||||
import {
|
||||
SIDE_LABEL_PREFIX,
|
||||
SIDE_NEW_THREAD_TITLE,
|
||||
sideThreadRows,
|
||||
threadHasCompletedTurn,
|
||||
threadTrailingPending,
|
||||
type SidechatThreadInfo,
|
||||
} from '../sidechat-core.ts'
|
||||
import { collectOwnEvents, toolArgsSummary, transcriptRows, type SidechatTranscriptRow } from './sidechat-transcript.ts'
|
||||
import { api } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import type { SessionScope } from './api.ts'
|
||||
import type { SidebarTab } from './state.ts'
|
||||
import css from './SideChatView.module.css'
|
||||
|
||||
/** Tail-page size for one transcript poll (events per page). Small on
|
||||
* purpose: streaming polls ride the tail and merge by seq. */
|
||||
const PAGE_MESSAGES = 8
|
||||
/** First-attach walk page size: cold reads re-expand chunk-rows into one
|
||||
* event per streamed delta, so a single answer can be hundreds of events —
|
||||
* the walk must page big or earlier tool/call rows fall out of the window. */
|
||||
const WALK_PAGE_EVENTS = 200
|
||||
/** Poll cadence while the selected thread is running and the tab visible. */
|
||||
const POLL_MS = 2000
|
||||
/** Textarea auto-grow ceiling (px) — the composer scrolls beyond it. */
|
||||
const COMPOSER_MAX_HEIGHT = 132
|
||||
|
||||
/** The thread a tab is bound to (durable in tab.meta across refreshes). */
|
||||
export function sidechatThreadIdOf(tab: SidebarTab): string | undefined {
|
||||
const meta = tab.meta as { threadId?: unknown } | undefined
|
||||
return typeof meta?.threadId === 'string' ? meta.threadId : undefined
|
||||
}
|
||||
|
||||
/** The parked reopen target consumed by the descriptor's createTab (the
|
||||
* service's createTab receives no seed, so a thread-switch parks the id
|
||||
* here and openTab picks it up synchronously — exactly one consume per
|
||||
* park). */
|
||||
let parkedReopen: string | undefined
|
||||
|
||||
/** Park a thread id for the NEXT sidechat openTab to reattach. */
|
||||
export function parkSidechatReopen(threadId: string): void {
|
||||
parkedReopen = threadId
|
||||
}
|
||||
|
||||
/** Consume the parked reopen target (undefined = mint a fresh thread tab). */
|
||||
export function consumeSidechatSeed(): string | undefined {
|
||||
const value = parkedReopen
|
||||
parkedReopen = undefined
|
||||
return value
|
||||
}
|
||||
|
||||
/** In-flight thread creations keyed by tab id (double-mount guard: React
|
||||
* StrictMode / HMR must not mint two threads for one tab). */
|
||||
const inFlightStarts = new Set<string>()
|
||||
|
||||
/** Per-thread transcript cache: seed boundary + thread-own events merged by
|
||||
* seq (streaming polls never re-download the inherited seed). */
|
||||
interface ThreadCache {
|
||||
seedBoundary: number | null
|
||||
entries: SidebarHistoryEntry[]
|
||||
}
|
||||
|
||||
/** Row-render labels (locale-dependent, memoized once per mount). */
|
||||
interface RowLabels {
|
||||
copyLabel: string
|
||||
copiedLabel: string
|
||||
thinkLabel: string
|
||||
injectionLabel: string
|
||||
}
|
||||
|
||||
/** Merge history entries by event seq (newest wins), log order preserved. */
|
||||
function mergeBySeq(
|
||||
previous: readonly SidebarHistoryEntry[],
|
||||
incoming: readonly SidebarHistoryEntry[],
|
||||
): SidebarHistoryEntry[] {
|
||||
const bySeq = new Map<number, SidebarHistoryEntry>()
|
||||
for (const entry of previous) bySeq.set(entry.event.seq, entry)
|
||||
for (const entry of incoming) bySeq.set(entry.event.seq, entry)
|
||||
return [...bySeq.values()].sort((a, b) => a.event.seq - b.event.seq)
|
||||
}
|
||||
|
||||
/** The display title of a thread: the durable label minus the 'Side: '
|
||||
* prefix, with the fresh-thread placeholder localized. */
|
||||
function threadDisplayTitle(title: string): string {
|
||||
if (title === SIDE_NEW_THREAD_TITLE) return t('sideChatUntitled')
|
||||
return title.startsWith(SIDE_LABEL_PREFIX) ? title.slice(SIDE_LABEL_PREFIX.length) : title
|
||||
}
|
||||
|
||||
/**
|
||||
* One collapsible context row — the shared Codex-style chrome of tool
|
||||
* calls, thinking and context injections: a single quiet line (chevron +
|
||||
* label + one-line summary) that expands into an indented body hung on a
|
||||
* hairline thread. Rows with nothing to reveal render as a static line.
|
||||
*/
|
||||
function CollapsibleRow(props: {
|
||||
label: string
|
||||
meta?: string
|
||||
mono?: boolean
|
||||
streaming?: boolean
|
||||
failed?: boolean
|
||||
children?: React.ReactNode
|
||||
}): React.ReactNode {
|
||||
const label = (
|
||||
<span
|
||||
className={clsx(
|
||||
css.sidechatRowLabel,
|
||||
props.mono === true && css.sidechatRowMono,
|
||||
props.streaming === true && css.sidechatShimmerText,
|
||||
)}
|
||||
>
|
||||
{props.label}
|
||||
</span>
|
||||
)
|
||||
const meta = props.meta !== undefined && props.meta !== ''
|
||||
? <span className={css.sidechatRowMeta}>{props.meta}</span>
|
||||
: null
|
||||
if (props.children === undefined) {
|
||||
return (
|
||||
<div className={clsx(css.sidechatRowLine, css.sidechatRowStatic, props.failed === true && css.sidechatRowFailed)}>
|
||||
{label}
|
||||
{meta}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<details className={css.sidechatRow}>
|
||||
<summary
|
||||
className={clsx(
|
||||
css.sidechatRowLine,
|
||||
css.sidechatRowSummary,
|
||||
props.failed === true && css.sidechatRowFailed,
|
||||
)}
|
||||
>
|
||||
<span className={css.sidechatRowChevron}>
|
||||
<IconChevronRightOutline14 size={12} />
|
||||
</span>
|
||||
{label}
|
||||
{meta}
|
||||
</summary>
|
||||
<div className={css.sidechatRowBody}>{props.children}</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
/** One row renderer (React keys ride the source event seq). */
|
||||
function renderRow(row: SidechatTranscriptRow, labels: RowLabels): React.ReactNode {
|
||||
switch (row.kind) {
|
||||
case 'user':
|
||||
return (
|
||||
<div key={`${row.kind}:${row.seq}`} className={css.sidechatUser}>
|
||||
<MarkdownText text={row.text} codeLabels={labels} />
|
||||
</div>
|
||||
)
|
||||
case 'assistant':
|
||||
return (
|
||||
<div key={`${row.kind}:${row.seq}`} className={css.sidechatAssistant}>
|
||||
<MarkdownText text={row.text} codeLabels={labels} />
|
||||
</div>
|
||||
)
|
||||
case 'reasoning':
|
||||
return (
|
||||
<CollapsibleRow
|
||||
key={`${row.kind}:${row.seq}`}
|
||||
label={labels.thinkLabel}
|
||||
streaming={!row.settled}
|
||||
>
|
||||
<div className={css.sidechatRowProse}>{row.text}</div>
|
||||
</CollapsibleRow>
|
||||
)
|
||||
case 'injection':
|
||||
return (
|
||||
<CollapsibleRow key={`${row.kind}:${row.seq}`} label={labels.injectionLabel}>
|
||||
<div className={css.sidechatRowProse}>{row.text}</div>
|
||||
</CollapsibleRow>
|
||||
)
|
||||
case 'tool': {
|
||||
const body = (
|
||||
<>
|
||||
{row.args !== undefined && <pre className={css.sidechatRowCode}>{row.args}</pre>}
|
||||
{row.resultText !== undefined && <pre className={css.sidechatRowCode}>{row.resultText}</pre>}
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<CollapsibleRow
|
||||
key={`${row.kind}:${row.seq}`}
|
||||
label={row.name}
|
||||
meta={toolArgsSummary(row.args)}
|
||||
mono
|
||||
streaming={row.executing === true}
|
||||
failed={row.failed}
|
||||
{...(row.args === undefined && row.resultText === undefined ? {} : { children: body })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One side conversation tab (one thread per tab, Codex-style). */
|
||||
export function SideChatView(props: {
|
||||
ctx: Context
|
||||
scope: SessionScope
|
||||
tab: SidebarTab
|
||||
visible: boolean
|
||||
}): React.ReactNode {
|
||||
const { ctx, scope, tab, visible } = props
|
||||
const rowLabels = useMemo<RowLabels>(
|
||||
() => ({
|
||||
copyLabel: t('copy'),
|
||||
copiedLabel: t('copied'),
|
||||
thinkLabel: t('sideChatThink'),
|
||||
injectionLabel: t('sideChatInjection'),
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
// The session list feed: thread rows (the header menu) + running states.
|
||||
const list = useSyncExternalStore(
|
||||
useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]),
|
||||
useCallback(() => ctx.sessions.list.getSnapshot(), [ctx]),
|
||||
)
|
||||
const threads = useMemo(
|
||||
() => sideThreadRows(list.byId, scope.sessionId),
|
||||
[list, scope.sessionId],
|
||||
)
|
||||
|
||||
// The thread this tab is bound to rides tab.meta (refresh-restored).
|
||||
const threadId = sidechatThreadIdOf(tab)
|
||||
const autoCreate = (tab.meta as { autoCreate?: unknown } | undefined)?.autoCreate === true
|
||||
|
||||
const [composer, setComposer] = useState('')
|
||||
const [busy, setBusy] = useState<'starting' | 'sending' | 'saving' | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [revision, setRevision] = useState(0)
|
||||
const [info, setInfo] = useState<SidechatThreadInfo | null>(null)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
const cacheRef = useRef<ThreadCache>({ seedBoundary: null, entries: [] })
|
||||
const controllerRef = useRef<AbortController | null>(null)
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const composerRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
|
||||
const summary = threadId === undefined ? undefined : list.byId[threadId]
|
||||
const running = summary?.running === true
|
||||
|
||||
/** The agent-identity badge of the thread header (preset · model). */
|
||||
const agentBadge = useMemo(() => {
|
||||
if (info === null) return ''
|
||||
return [info.preset, info.model ?? info.provider].filter(Boolean).join(' · ')
|
||||
}, [info])
|
||||
|
||||
/** Create this tab's thread (immediate-create tabs and hero retries). */
|
||||
const startThread = useCallback(async (): Promise<void> => {
|
||||
if (inFlightStarts.has(tab.id)) return
|
||||
inFlightStarts.add(tab.id)
|
||||
setBusy('starting')
|
||||
setError(null)
|
||||
try {
|
||||
const { childId } = await api.sidechatStart(scope.sessionId)
|
||||
ctx.betterSidebar?.updateTab(tab.id, { meta: { threadId: childId } })
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : String(cause))
|
||||
} finally {
|
||||
inFlightStarts.delete(tab.id)
|
||||
setBusy(null)
|
||||
}
|
||||
}, [ctx, scope.sessionId, tab.id])
|
||||
|
||||
// Codex-style immediate create: an autoCreate tab spawns its thread as
|
||||
// soon as it first renders.
|
||||
useEffect(() => {
|
||||
if (threadId !== undefined || !autoCreate || !visible) return
|
||||
void startThread()
|
||||
}, [threadId, autoCreate, visible, startThread])
|
||||
|
||||
// The tab title follows the thread's durable label (the first prompt
|
||||
// renames the thread; the strip picks it up here).
|
||||
useEffect(() => {
|
||||
const display = summary?.displayTitle
|
||||
if (display === undefined) return
|
||||
const title = threadDisplayTitle(display)
|
||||
if (title !== '' && title !== tab.title) {
|
||||
try {
|
||||
ctx.betterSidebar?.updateTab(tab.id, { title })
|
||||
} catch {
|
||||
// A stale title is cosmetic; the thread keeps working.
|
||||
}
|
||||
}
|
||||
}, [summary, tab.id, tab.title, ctx])
|
||||
|
||||
/** One transcript pull: the first read walks back to the seed boundary
|
||||
* (big pages — chunk deltas re-expand on cold reads), later reads fetch
|
||||
* one tail page and merge (seq-deduped). */
|
||||
const fetchThread = useCallback(async (childId: string): Promise<void> => {
|
||||
controllerRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
controllerRef.current = controller
|
||||
const cache = cacheRef.current
|
||||
try {
|
||||
if (cache.seedBoundary === null) {
|
||||
const walk = await collectOwnEvents(async (beforeSeq) => {
|
||||
const response = await ctx.connection.api.sessions.history(
|
||||
{
|
||||
sessionId: childId,
|
||||
maxMessages: WALK_PAGE_EVENTS,
|
||||
...(beforeSeq === undefined ? {} : { beforeSeq }),
|
||||
},
|
||||
controller.signal,
|
||||
)
|
||||
if (!response.result.ok) throw new Error('history walk failed')
|
||||
return response.result.value.events
|
||||
})
|
||||
cache.seedBoundary = walk.seedBoundary
|
||||
cache.entries = mergeBySeq(cache.entries, walk.entries)
|
||||
} else {
|
||||
const response = await ctx.connection.api.sessions.history(
|
||||
{ sessionId: childId, maxMessages: PAGE_MESSAGES },
|
||||
controller.signal,
|
||||
)
|
||||
if (!response.result.ok) return
|
||||
cache.entries = mergeBySeq(cache.entries, response.result.value.events)
|
||||
}
|
||||
setRevision(value => value + 1)
|
||||
} catch {
|
||||
// Aborted by a newer pull or a wire failure: keep the last rows.
|
||||
}
|
||||
}, [ctx])
|
||||
|
||||
/** The thread header badge pull (live state + preset/model identity). */
|
||||
const fetchInfo = useCallback(async (childId: string): Promise<void> => {
|
||||
try {
|
||||
setInfo(await api.sidechatInfo(childId))
|
||||
} catch {
|
||||
// The badge is decorative; a wire failure keeps the last value.
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Reset the transcript cache whenever the binding changes, then focus
|
||||
// the composer — it owns the first message of a fresh thread.
|
||||
useEffect(() => {
|
||||
cacheRef.current = { seedBoundary: null, entries: [] }
|
||||
controllerRef.current?.abort()
|
||||
setError(null)
|
||||
setSaved(false)
|
||||
setInfo(null)
|
||||
if (threadId !== undefined) {
|
||||
void fetchInfo(threadId)
|
||||
window.setTimeout(() => composerRef.current?.focus(), 0)
|
||||
}
|
||||
}, [threadId, fetchInfo])
|
||||
|
||||
// Poll while the tab is visible and the thread runs.
|
||||
useEffect(() => {
|
||||
if (!visible || threadId === undefined) return
|
||||
void fetchThread(threadId)
|
||||
if (!running) return
|
||||
const timer = window.setInterval(() => {
|
||||
void fetchThread(threadId)
|
||||
void fetchInfo(threadId)
|
||||
}, POLL_MS)
|
||||
return () => { window.clearInterval(timer) }
|
||||
}, [visible, threadId, running, fetchThread, fetchInfo])
|
||||
|
||||
useEffect(() => () => { controllerRef.current?.abort() }, [])
|
||||
|
||||
const rows = useMemo(
|
||||
() => (threadId === undefined ? [] : transcriptRows(cacheRef.current.entries)),
|
||||
// The cache is a ref; revision bumps on every successful pull.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[threadId, revision],
|
||||
)
|
||||
const canSave = threadId !== undefined && threadHasCompletedTurn(cacheRef.current.entries)
|
||||
const trailingPending = threadId !== undefined && threadTrailingPending(cacheRef.current.entries)
|
||||
const freshThread = threadId !== undefined && rows.length === 0
|
||||
|
||||
// Follow the stream: stick to the bottom while the log grows.
|
||||
useEffect(() => {
|
||||
const scroller = scrollRef.current
|
||||
if (scroller === null) return
|
||||
scroller.scrollTop = scroller.scrollHeight
|
||||
}, [rows.length, threadId])
|
||||
|
||||
/** Open a NEW thread tab (createTab mints the autoCreate tab; its view
|
||||
* creates the thread on mount). */
|
||||
const openNewThread = (): void => {
|
||||
setMenuOpen(false)
|
||||
ctx.betterSidebar?.openTab({ type: 'sidechat' }, scope)
|
||||
}
|
||||
|
||||
/** Switch to an existing thread: parked for createTab, deduped to the
|
||||
* already-open tab when there is one. */
|
||||
const openExistingThread = (id: string): void => {
|
||||
setMenuOpen(false)
|
||||
if (id === threadId) return
|
||||
parkSidechatReopen(id)
|
||||
ctx.betterSidebar?.openTab({ type: 'sidechat' }, scope)
|
||||
}
|
||||
|
||||
const menuItems = useMemo<MenuEntry[]>(() => {
|
||||
const items: MenuEntry[] = [
|
||||
{ id: '$new', label: t('sideChatNew'), icon: <IconPlusOutline16 /> },
|
||||
]
|
||||
if (threads.length > 0) {
|
||||
items.push({ type: 'separator', id: '$sep' })
|
||||
for (const row of threads) {
|
||||
items.push({
|
||||
id: row.id,
|
||||
label: threadDisplayTitle(row.title),
|
||||
...(row.running ? { icon: <StateDot state="ongoing" size={8} /> } : {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
return items
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [threads])
|
||||
|
||||
const growComposer = (): void => {
|
||||
const field = composerRef.current
|
||||
if (field === null) return
|
||||
field.style.height = '0px'
|
||||
field.style.height = `${Math.min(field.scrollHeight, COMPOSER_MAX_HEIGHT)}px`
|
||||
}
|
||||
|
||||
const handleSend = async (): Promise<void> => {
|
||||
const text = composer.trim()
|
||||
if (text === '' || threadId === undefined || busy !== null) return
|
||||
setBusy('sending')
|
||||
setError(null)
|
||||
try {
|
||||
await api.sidechatPrompt(threadId, text)
|
||||
setComposer('')
|
||||
const field = composerRef.current
|
||||
if (field !== null) field.style.height = ''
|
||||
void fetchThread(threadId)
|
||||
void fetchInfo(threadId)
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : String(cause))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = async (): Promise<void> => {
|
||||
if (threadId === undefined || busy !== null) return
|
||||
try {
|
||||
await api.sidechatCancel(threadId)
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : String(cause))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
if (threadId === undefined || !canSave || busy !== null) return
|
||||
setBusy('saving')
|
||||
setError(null)
|
||||
setSaved(false)
|
||||
try {
|
||||
// NOTE: fork must stay a METHOD call — `ctx.sessions.fork` is the
|
||||
// client-runtime sessions service, and an unbound reference loses
|
||||
// `this` (its fork reads this.list for the title bump).
|
||||
if (ctx.sessions.fork === undefined) throw new Error('session fork is unavailable')
|
||||
const newId = await ctx.sessions.fork({ sessionId: threadId, increaseTitle: true })
|
||||
const title = summary === undefined ? '' : threadDisplayTitle(summary.displayTitle).trim()
|
||||
const binding = ctx.sessions.binding?.(newId)
|
||||
if (binding !== undefined && title !== '') {
|
||||
await binding.session.rename(title)
|
||||
}
|
||||
ctx.sessions.open?.(newId)
|
||||
setSaved(true)
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : String(cause))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
// ── unbound tab: the hero (fresh autoCreate tabs flash a creating state
|
||||
// until the thread lands; legacy persisted tabs offer a manual start) ──
|
||||
if (threadId === undefined) {
|
||||
return (
|
||||
<div className={css.sidechat}>
|
||||
<div className={css.sidechatHero}>
|
||||
<IconNewChatOutline16 />
|
||||
<div
|
||||
className={clsx(
|
||||
css.sidechatHeroTitle,
|
||||
busy === 'starting' && css.sidechatShimmerText,
|
||||
)}
|
||||
>
|
||||
{busy === 'starting' ? t('sideChatCreating') : t('sideChatEmpty')}
|
||||
</div>
|
||||
<div className={css.sidechatHeroDesc}>{t('sideChatEmptyDesc')}</div>
|
||||
{error !== null && <div className={css.sidechatError}>{t('sideChatError', { message: error })}</div>}
|
||||
{busy !== 'starting' && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.sidechatPrimaryBtn}
|
||||
onClick={() => void startThread()}
|
||||
>
|
||||
{error === null ? t('sideChatNew') : t('sideChatRetry')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.sidechat}>
|
||||
<div className={css.sidechatDetailHeader}>
|
||||
{running && <StateDot state="ongoing" size={8} className={css.sidechatHeaderDot} />}
|
||||
{agentBadge !== '' && <span className={css.sidechatAgentBadge}>{agentBadge}</span>}
|
||||
<span className={css.sidechatHeaderSpacer} />
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.sidechatIconBtn}
|
||||
onClick={() => { setMenuOpen(value => !value) }}
|
||||
title={t('sideChatThreads')}
|
||||
>
|
||||
<IconHistoryOutline16 />
|
||||
</button>
|
||||
)}
|
||||
items={menuItems}
|
||||
selectedId={threadId}
|
||||
onSelect={(id) => { id === '$new' ? openNewThread() : openExistingThread(id) }}
|
||||
onClose={() => { setMenuOpen(false) }}
|
||||
align="end"
|
||||
portal
|
||||
dense
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={css.sidechatIconBtn}
|
||||
onClick={() => void handleSave()}
|
||||
disabled={!canSave || busy !== null}
|
||||
title={`${t('sideChatSave')} — ${t('sideChatSaveTitle')}`}
|
||||
>
|
||||
<IconSaveOutline16 />
|
||||
</button>
|
||||
</div>
|
||||
{!canSave && !freshThread && <div className={css.sidechatHint}>{t('sideChatNoTurn')}</div>}
|
||||
{canSave && trailingPending
|
||||
&& <div className={css.sidechatHint}>{t('sideChatPendingDrop')}</div>}
|
||||
{saved && <div className={css.sidechatHint}>{t('sideChatSaved')}</div>}
|
||||
{error !== null && <div className={css.sidechatError}>{t('sideChatError', { message: error })}</div>}
|
||||
<div ref={scrollRef} className={css.sidechatScroll}>
|
||||
{rows.map(row => renderRow(row, rowLabels))}
|
||||
</div>
|
||||
{running && (
|
||||
<div className={css.sidechatStatus}>
|
||||
<StateDot state="ongoing" size={8} />
|
||||
<span className={css.sidechatStatusText}>{t('sideChatThinking')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={css.sidechatComposer}>
|
||||
<textarea
|
||||
ref={composerRef}
|
||||
className={css.sidechatComposerInput}
|
||||
value={composer}
|
||||
placeholder={freshThread ? t('sideChatFirstPlaceholder') : t('sideChatComposerPlaceholder')}
|
||||
rows={1}
|
||||
onChange={event => {
|
||||
setComposer(event.target.value)
|
||||
growComposer()
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return
|
||||
event.preventDefault()
|
||||
void handleSend()
|
||||
}}
|
||||
/>
|
||||
<div className={css.sidechatComposerBar}>
|
||||
<span className={css.sidechatComposerMeta}>
|
||||
{running ? '' : agentBadge}
|
||||
</span>
|
||||
{running ? (
|
||||
<button
|
||||
key="stop"
|
||||
type="button"
|
||||
className={css.sidechatSendBtn}
|
||||
onClick={() => void handleCancel()}
|
||||
disabled={busy !== null}
|
||||
title={t('sideChatCancelTitle')}
|
||||
>
|
||||
<IconStopFill16 />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
key="send"
|
||||
type="button"
|
||||
className={css.sidechatSendBtn}
|
||||
onClick={() => void handleSend()}
|
||||
disabled={composer.trim() === '' || busy !== null}
|
||||
title={t('sideChatSend')}
|
||||
>
|
||||
<IconSendOutline16 />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+796
-190
File diff suppressed because it is too large
Load Diff
@@ -273,3 +273,275 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* ── Background-job section (below the topology tree) ────────────────── */
|
||||
|
||||
.jobs {
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid var(--dsw-alias-border-l2);
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.jobsHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 26px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.jobsTitle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font: var(--dsw-font-xxxs-strong-11);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.jobsCount {
|
||||
flex: none;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.jobsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* One job row; the whole row lights up on hover, the expandable target
|
||||
stays transparent (the kill button rides on the row's right edge). */
|
||||
.jobsRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.jobsRow:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.jobsRowSettled {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* The row whose output is shown in the bottom dock. */
|
||||
.jobsRowSelected,
|
||||
.jobsRowSelected:hover {
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
}
|
||||
|
||||
.jobsRowMain {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 6px 8px 6px 11px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
font: var(--dsw-font-s-14);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.jobsRowMain:focus-visible {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.jobsDot {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.jobsContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
/* The command line: a kind badge followed by the command itself in the code
|
||||
face, like the terminal's live lines. */
|
||||
.jobsLabelLine {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.jobsKind {
|
||||
flex: none;
|
||||
max-width: 90px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 0 5px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 4px;
|
||||
font: var(--dsw-font-xxxs-strong-11);
|
||||
line-height: 14px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.jobsLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: var(--dsw-font-xxxs-11-font-size);
|
||||
line-height: var(--dsw-font-xxxs-11-line-height);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.jobsSecondary {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.jobsKill {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-right: 4px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.jobsKill:hover {
|
||||
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* The armed (first-click) state: a filled confirm chip instead of the icon. */
|
||||
.jobsKillArmed,
|
||||
.jobsKillArmed:hover {
|
||||
width: auto;
|
||||
height: 20px;
|
||||
padding: 0 8px;
|
||||
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font: var(--dsw-font-xxxs-strong-11);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.jobsKill:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.jobsKillError {
|
||||
flex: none;
|
||||
margin-right: 4px;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* ── The shared output dock (bottom of the page, sticky like a terminal) ── */
|
||||
|
||||
/* Direct child of the scrolling body, so it docks at the scrollport's bottom
|
||||
edge while the job list scrolls above it; a solid background keeps the
|
||||
tree from showing through. */
|
||||
.jobsPane {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
margin-top: 4px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
box-shadow: 0 -6px 12px -8px rgba(0, 0, 0, 0.35);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.jobsPaneHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 28px;
|
||||
padding: 0 4px 0 10px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
.jobsPaneDot {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.jobsPaneLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: var(--dsw-font-xxxs-11-font-size);
|
||||
line-height: var(--dsw-font-xxxs-11-line-height);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.jobsPaneStatus {
|
||||
flex: none;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.jobsPaneClose {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.jobsPaneClose:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.jobsPanePre {
|
||||
margin: 0;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
padding: 6px 10px;
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: var(--dsw-font-xxxs-11-font-size);
|
||||
line-height: 1.5;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.jobsPaneHint {
|
||||
padding: 8px 10px;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.jobsPaneError {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
+426
-127
@@ -27,18 +27,33 @@ import {
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {
|
||||
Context,
|
||||
SidebarSessionList,
|
||||
SidebarSessionSummary,
|
||||
SidebarSubagentAddress,
|
||||
SidebarSubagentCatalog,
|
||||
SidebarSubagentChildEntry,
|
||||
SidebarSubagentDiagnosticEntry,
|
||||
SidebarJobView,
|
||||
} from '../context-types.ts'
|
||||
import {
|
||||
collectBranchIds,
|
||||
countSubagentDescendants,
|
||||
isSideThreadSummary,
|
||||
rootAncestor,
|
||||
} from './subagent-detect.ts'
|
||||
import { lastActivity } from './subagent-activity.ts'
|
||||
import { type LastActivity } from '../subagent-activity.ts'
|
||||
import { SIDE_LABEL_PREFIX } from '../sidechat-core.ts'
|
||||
import {
|
||||
collectTreeJobs,
|
||||
formatJobDuration,
|
||||
isJobLive,
|
||||
orderJobs,
|
||||
jobDotState,
|
||||
jobStatusLabel,
|
||||
type TreeJob,
|
||||
} from './subagent-jobs.ts'
|
||||
import { api, type JobOutputResult } from './api.ts'
|
||||
import { IconStopOutline16 } from './icons.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import css from './SubagentView.module.css'
|
||||
|
||||
@@ -46,14 +61,21 @@ import css from './SubagentView.module.css'
|
||||
const POLL_MS = 3000
|
||||
/** Preview cap of one tool-call argument line. */
|
||||
const ARGS_PREVIEW = 60
|
||||
/** Refresh cadence of an expanded job-output panel while its job runs. */
|
||||
const JOB_POLL_MS = 2000
|
||||
/** How long the kill button stays armed before it needs re-confirming. */
|
||||
const JOB_KILL_ARM_MS = 3000
|
||||
|
||||
/** The direct subagent children of one parent (durable `origin` rows). */
|
||||
/** The direct subagent children of one parent (durable `origin` rows;
|
||||
* Side Chat threads ride the same origin but are tab-strip conversations,
|
||||
* never topology). */
|
||||
function directChildren(
|
||||
byId: Readonly<Record<string, SidebarSessionSummary>>,
|
||||
parentSessionId: string,
|
||||
): SidebarSessionSummary[] {
|
||||
return Object.values(byId).filter(
|
||||
summary => summary.origin === 'subagent' && summary.parentId === parentSessionId,
|
||||
summary => summary.origin === 'subagent' && summary.parentId === parentSessionId
|
||||
&& !isSideThreadSummary(summary),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -128,56 +150,14 @@ function CatalogLoadingRows(props: {
|
||||
}
|
||||
|
||||
/**
|
||||
* The live lines of one RUNNING subagent card: the last text output and the
|
||||
* last tool call of the child's history tail, refreshed every few seconds
|
||||
* while the page is visible. Idle cards render nothing (a quiet topology); a
|
||||
* running child with neither output yet reads "thinking…".
|
||||
* The live lines of one RUNNING subagent card: a pure presentation of the
|
||||
* batch `subagents.live` activity. The polling lives in one place (the
|
||||
* SubagentView hook), not per card. A running child with neither output yet
|
||||
* reads "thinking…".
|
||||
*/
|
||||
function SubagentLiveLines(props: {
|
||||
ctx: Context
|
||||
parentSessionId: string
|
||||
childSessionId: string
|
||||
mode: SidebarSubagentAddress['mode']
|
||||
running: boolean
|
||||
/** The page is visible (active tab + open panel): skip polling otherwise. */
|
||||
active: boolean
|
||||
}) {
|
||||
const { ctx, parentSessionId, childSessionId, mode, running, active } = props
|
||||
const [live, setLive] = useState<ReturnType<typeof lastActivity>>({})
|
||||
const controllerRef = useRef<AbortController | undefined>(undefined)
|
||||
const address = useMemo(
|
||||
() => ({ parentSessionId, childSessionId, mode }),
|
||||
[parentSessionId, childSessionId, mode],
|
||||
)
|
||||
|
||||
const load = useCallback(async (): Promise<void> => {
|
||||
controllerRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
controllerRef.current = controller
|
||||
try {
|
||||
const response = await ctx.connection.api.subagents.history(
|
||||
{ ...address, maxMessages: 12 },
|
||||
controller.signal,
|
||||
)
|
||||
if (!response.result.ok) return
|
||||
setLive(lastActivity(response.result.value.events))
|
||||
} catch {
|
||||
// Aborted by a newer pull or a wire failure: keep the last known lines.
|
||||
}
|
||||
}, [ctx, address])
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
void load()
|
||||
if (!running) return
|
||||
const timer = window.setInterval(() => { void load() }, POLL_MS)
|
||||
return () => { window.clearInterval(timer) }
|
||||
}, [load, running, active])
|
||||
|
||||
useEffect(() => () => { controllerRef.current?.abort() }, [])
|
||||
|
||||
if (!running) return null
|
||||
if (live.text === undefined && live.tool === undefined) {
|
||||
function SubagentLiveLines(props: { live: LastActivity | undefined }) {
|
||||
const { live } = props
|
||||
if (live?.text === undefined && live?.tool === undefined) {
|
||||
return <span className={css.subagentLive}>{t('subagentThinking')}</span>
|
||||
}
|
||||
return (
|
||||
@@ -197,6 +177,59 @@ function SubagentLiveLines(props: {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One shared live-preview poller for the whole Subagent tree. Unlike the old
|
||||
* per-card `subagents.history` timers, this sends at most ONE `subagents.live`
|
||||
* request at a time: a recursive timeout starts only after the previous
|
||||
* request settles, so a slow host never sees abort/restart storms.
|
||||
*/
|
||||
function useSubagentLive(
|
||||
rootId: string | undefined,
|
||||
active: boolean,
|
||||
): Readonly<Record<string, LastActivity>> {
|
||||
const [live, setLive] = useState<Record<string, LastActivity>>({})
|
||||
const controllerRef = useRef<AbortController | undefined>(undefined)
|
||||
|
||||
// A new tree must never inherit another root's live previews.
|
||||
useEffect(() => { setLive({}) }, [rootId])
|
||||
|
||||
useEffect(() => {
|
||||
if (rootId === undefined || !active) return
|
||||
const targetRootId = rootId
|
||||
let disposed = false
|
||||
let timer: number | undefined
|
||||
|
||||
const schedule = (): void => {
|
||||
if (disposed) return
|
||||
timer = window.setTimeout(() => { void load() }, POLL_MS)
|
||||
}
|
||||
async function load(): Promise<void> {
|
||||
if (disposed) return
|
||||
const controller = new AbortController()
|
||||
controllerRef.current = controller
|
||||
try {
|
||||
const result = await api.subagentsLive(targetRootId, controller.signal)
|
||||
if (!disposed) setLive(result.live)
|
||||
} catch {
|
||||
// Keep the last known live map; the next scheduled poll retries.
|
||||
} finally {
|
||||
if (controllerRef.current === controller) controllerRef.current = undefined
|
||||
if (!disposed) schedule()
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => {
|
||||
disposed = true
|
||||
if (timer !== undefined) window.clearTimeout(timer)
|
||||
controllerRef.current?.abort()
|
||||
controllerRef.current = undefined
|
||||
}
|
||||
}, [rootId, active])
|
||||
|
||||
return live
|
||||
}
|
||||
|
||||
interface RowsProps {
|
||||
parentSessionId: string
|
||||
catalog: SidebarSubagentCatalog | undefined
|
||||
@@ -205,19 +238,26 @@ interface RowsProps {
|
||||
level: number
|
||||
/** The currently-open session id (highlighted in the topology). */
|
||||
currentSessionId: string
|
||||
/** The page is visible (active tab + open panel): live polling pauses otherwise. */
|
||||
active: boolean
|
||||
ctx: Context
|
||||
/** The batch live-preview map (child id → latest activity). */
|
||||
live: Readonly<Record<string, LastActivity>>
|
||||
openChild: (address: SidebarSubagentAddress) => void
|
||||
refresh: (parentSessionId: string) => void
|
||||
}
|
||||
|
||||
/** Render one topology level; branches are always expanded (lazy catalogs). */
|
||||
function CatalogRows({
|
||||
parentSessionId, catalog, catalogs, byId, level, currentSessionId, active, ctx,
|
||||
parentSessionId, catalog, catalogs, byId, level, currentSessionId, live,
|
||||
openChild, refresh,
|
||||
}: RowsProps) {
|
||||
const emptyLoading = catalog?.state === 'loading' && catalog.entries.length === 0
|
||||
// Side Chat threads are honest catalog citizens (durable descriptor, 'Side: '
|
||||
// label) but they are NOT subagent topology — filter them out here (the tab
|
||||
// strip owns them). Legacy threads created before the descriptor fix still
|
||||
// arrive as corrupt diagnostics; they are recognized by summary title.
|
||||
const visibleEntries = (catalog?.entries ?? []).filter((entry) => {
|
||||
if (entry.kind === 'child') return !(entry.label?.startsWith(SIDE_LABEL_PREFIX) ?? false)
|
||||
return !(byId[entry.id]?.displayTitle.startsWith(SIDE_LABEL_PREFIX) ?? false)
|
||||
})
|
||||
return (
|
||||
<>
|
||||
{emptyLoading && (
|
||||
@@ -236,7 +276,7 @@ function CatalogRows({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{(catalog?.entries ?? []).map((entry) => {
|
||||
{visibleEntries.map((entry) => {
|
||||
if (entry.kind === 'diagnostic') {
|
||||
return (
|
||||
<div key={entry.id} className={css.subagentNode}>
|
||||
@@ -297,14 +337,9 @@ function CatalogRows({
|
||||
<span className={css.subagentContent}>
|
||||
<span className={css.subagentLabel}>{label}</span>
|
||||
<span className={css.subagentSecondary}>{secondary}</span>
|
||||
<SubagentLiveLines
|
||||
ctx={ctx}
|
||||
parentSessionId={parentSessionId}
|
||||
childSessionId={entry.id}
|
||||
mode={entry.mode}
|
||||
running={entry.activity === 'running'}
|
||||
active={active}
|
||||
/>
|
||||
{entry.activity === 'running' && (
|
||||
<SubagentLiveLines live={live[entry.id]} />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{!knownLeaf && (
|
||||
@@ -325,8 +360,7 @@ function CatalogRows({
|
||||
byId={byId}
|
||||
level={level + 1}
|
||||
currentSessionId={currentSessionId}
|
||||
active={active}
|
||||
ctx={ctx}
|
||||
live={live}
|
||||
openChild={openChild}
|
||||
refresh={refresh}
|
||||
/>
|
||||
@@ -340,6 +374,262 @@ function CatalogRows({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared output dock of the jobs section: ONE pane at the bottom of the
|
||||
* sidebar body (sticky, terminal-like) shows the SELECTED job's output as
|
||||
* the MODEL has read it so far (replayed from the owner session's event
|
||||
* log), refreshed every {@link JOB_POLL_MS} while the job runs and the
|
||||
* page is visible. The model's `job_output` cursor is never touched — the
|
||||
* pane can never steal the agent's bytes, and it stays empty until the
|
||||
* agent reads the job. A single dock — not a panel per row — keeps the
|
||||
* job list compact and stable when many jobs are running.
|
||||
*/
|
||||
function JobOutputPane(props: {
|
||||
ownerSessionId: string
|
||||
job: SidebarJobView
|
||||
/** The page is visible (active tab + open panel): skip polling otherwise. */
|
||||
active: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { ownerSessionId, job, active, onClose } = props
|
||||
const [state, setState] = useState<'loading' | JobOutputResult | 'error'>('loading')
|
||||
const controllerRef = useRef<AbortController | undefined>(undefined)
|
||||
const preRef = useRef<HTMLPreElement>(null)
|
||||
|
||||
const load = useCallback(async (): Promise<void> => {
|
||||
controllerRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
controllerRef.current = controller
|
||||
try {
|
||||
const result = await api.jobOutput({ sessionId: ownerSessionId }, job.id, controller.signal)
|
||||
setState(result)
|
||||
} catch {
|
||||
// A newer pull aborted this one, or the wire failed: keep the last
|
||||
// known output; only a dock that never loaded anything shows an error.
|
||||
setState(current => (current === 'loading' ? 'error' : current))
|
||||
}
|
||||
}, [ownerSessionId, job.id])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
if (!active || !isJobLive(job)) return
|
||||
const timer = window.setInterval(() => { void load() }, JOB_POLL_MS)
|
||||
return () => { window.clearInterval(timer) }
|
||||
}, [load, active, job.status])
|
||||
|
||||
useEffect(() => () => { controllerRef.current?.abort() }, [])
|
||||
|
||||
// Terminal-tail behavior: while the job runs, each refresh pins the view
|
||||
// to the newest output; a settled dock leaves scrolling to the reader.
|
||||
useEffect(() => {
|
||||
if (!isJobLive(job) || typeof state !== 'object' || state.text.length === 0) return
|
||||
const pre = preRef.current
|
||||
if (pre !== null) pre.scrollTop = pre.scrollHeight
|
||||
}, [state, job.status])
|
||||
|
||||
return (
|
||||
<div className={css.jobsPane} role="region" aria-label={`${job.label} ${t('jobs')}`}>
|
||||
<div className={css.jobsPaneHeader}>
|
||||
<StateDot state={jobDotState(job.status)} className={css.jobsPaneDot} />
|
||||
<span className={css.jobsPaneLabel} title={job.label}>{job.label}</span>
|
||||
<span className={css.jobsPaneStatus}>
|
||||
{jobStatusLabel(job.status, t)}
|
||||
{job.detail !== undefined && job.detail !== '' ? ` · ${job.detail}` : ''}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.jobsPaneClose}
|
||||
aria-label={t('close')}
|
||||
title={t('close')}
|
||||
onClick={onClose}
|
||||
>
|
||||
<IconStopOutline16 size={10} />
|
||||
</button>
|
||||
</div>
|
||||
{state === 'loading' && <div className={css.jobsPaneHint}>{t('loading')}</div>}
|
||||
{state === 'error' && (
|
||||
<div className={`${css.jobsPaneHint} ${css.jobsPaneError}`}>{t('jobOutputError')}</div>
|
||||
)}
|
||||
{typeof state === 'object' && (
|
||||
<>
|
||||
{state.text.length > 0
|
||||
? <pre ref={preRef} className={css.jobsPanePre}>{state.text}</pre>
|
||||
: state.read
|
||||
? <div className={css.jobsPaneHint}>{t('jobNoOutput')}</div>
|
||||
: <div className={css.jobsPaneHint}>{t('jobNotReadYet')}</div>}
|
||||
{state.truncated && <div className={css.jobsPaneHint}>{t('jobOutputTruncated')}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The background-job section of the Subagent page: every job of the whole
|
||||
* current tree (main agent + subagents, owner-labeled), fed by the harness
|
||||
* `session/jobs` push mirror. Clicking a row feeds its model-read output to
|
||||
* the shared bottom dock (event replay — never the model's cursor); live
|
||||
* rows carry a two-click-confirm kill button. Renders nothing while the
|
||||
* tree has no jobs.
|
||||
*/
|
||||
function JobsSection(props: {
|
||||
byId: SidebarSessionList['byId']
|
||||
jobsBySession: SidebarSessionList['jobsBySession']
|
||||
rootId: string | undefined
|
||||
/** The page is visible (active tab + open panel): skip polling otherwise. */
|
||||
active: boolean
|
||||
}) {
|
||||
const { byId, jobsBySession, rootId, active } = props
|
||||
const rows = useMemo(
|
||||
() => orderJobs(collectTreeJobs(byId, jobsBySession, rootId)),
|
||||
[byId, jobsBySession, rootId],
|
||||
)
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>(undefined)
|
||||
const [armedId, setArmedId] = useState<string | undefined>(undefined)
|
||||
const [killingId, setKillingId] = useState<string | undefined>(undefined)
|
||||
const [killErrorId, setKillErrorId] = useState<string | undefined>(undefined)
|
||||
// The duration clock only runs while a live row is on screen.
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
const selectedRow = useMemo(
|
||||
() => (selectedId === undefined ? undefined : rows.find(row => row.job.id === selectedId)),
|
||||
[rows, selectedId],
|
||||
)
|
||||
|
||||
const liveCount = useMemo(
|
||||
() => rows.reduce((count, row) => count + (isJobLive(row.job) ? 1 : 0), 0),
|
||||
[rows],
|
||||
)
|
||||
const multiOwner = useMemo(
|
||||
() => new Set(rows.map(row => row.ownerSessionId)).size > 1,
|
||||
[rows],
|
||||
)
|
||||
|
||||
// The kill button stays armed only briefly; a stray click must never kill.
|
||||
useEffect(() => {
|
||||
if (armedId === undefined) return
|
||||
const timer = window.setTimeout(() => { setArmedId(undefined) }, JOB_KILL_ARM_MS)
|
||||
return () => { window.clearTimeout(timer) }
|
||||
}, [armedId])
|
||||
|
||||
useEffect(() => {
|
||||
if (liveCount === 0) return
|
||||
setNow(Date.now())
|
||||
const timer = window.setInterval(() => { setNow(Date.now()) }, 1_000)
|
||||
return () => { window.clearInterval(timer) }
|
||||
}, [liveCount])
|
||||
|
||||
// The docked output pane follows its job: when the selected job leaves
|
||||
// the mirror (settled and dropped, or the tree switched), close the dock.
|
||||
useEffect(() => {
|
||||
if (selectedId !== undefined && selectedRow === undefined) setSelectedId(undefined)
|
||||
}, [selectedId, selectedRow])
|
||||
|
||||
// NOTE: every hook must live ABOVE the empty-state return — a hook below it
|
||||
// would flip this component's hook count when the mirror empties and crash
|
||||
// React with "Rendered fewer hooks than expected" (the #300 regression).
|
||||
const kill = useCallback(async (row: TreeJob): Promise<void> => {
|
||||
setKillingId(row.job.id)
|
||||
setKillErrorId(undefined)
|
||||
try {
|
||||
await api.jobKill({ sessionId: row.ownerSessionId }, row.job.id)
|
||||
} catch {
|
||||
setKillErrorId(row.job.id)
|
||||
} finally {
|
||||
setKillingId(undefined)
|
||||
setArmedId(undefined)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (rows.length === 0) return null
|
||||
|
||||
const countLabel = liveCount > 0
|
||||
? t('jobsCountRunning', { count: rows.length, running: liveCount })
|
||||
: t('jobsCount', { count: rows.length })
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className={css.jobs} aria-label={t('jobs')}>
|
||||
<div className={css.jobsHeader}>
|
||||
<span className={css.jobsTitle}>{t('jobs')}</span>
|
||||
<span className={css.jobsCount}>{countLabel}</span>
|
||||
</div>
|
||||
<ul className={css.jobsList} aria-label={t('jobs')}>
|
||||
{rows.map((row) => {
|
||||
const { job } = row
|
||||
const live = isJobLive(job)
|
||||
const selected = selectedId === job.id
|
||||
const armed = armedId === job.id
|
||||
const killing = killingId === job.id
|
||||
const killFailed = killErrorId === job.id
|
||||
const elapsed = live
|
||||
? now - job.startedAt
|
||||
: (job.finishedAt ?? job.startedAt) - job.startedAt
|
||||
const secondary = [
|
||||
...(multiOwner ? [row.ownerTitle] : []),
|
||||
jobStatusLabel(job.status, t),
|
||||
...(job.detail !== undefined && job.detail !== '' ? [job.detail] : []),
|
||||
formatJobDuration(elapsed, t),
|
||||
].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<li
|
||||
key={job.id}
|
||||
className={clsx(
|
||||
css.jobsRow,
|
||||
!live && css.jobsRowSettled,
|
||||
selected && css.jobsRowSelected,
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={css.jobsRowMain}
|
||||
aria-pressed={selected}
|
||||
aria-label={`${job.label} ${secondary}`}
|
||||
onClick={() => { setSelectedId(selected ? undefined : job.id) }}
|
||||
>
|
||||
<StateDot state={jobDotState(job.status)} className={css.jobsDot} />
|
||||
<span className={css.jobsContent}>
|
||||
<span className={css.jobsLabelLine}>
|
||||
<span className={css.jobsKind}>{job.kind}</span>
|
||||
<span className={css.jobsLabel} title={job.label}>{job.label}</span>
|
||||
</span>
|
||||
<span className={css.jobsSecondary}>{secondary}</span>
|
||||
</span>
|
||||
</button>
|
||||
{job.status === 'running' && (
|
||||
<button
|
||||
type="button"
|
||||
className={armed ? `${css.jobsKill} ${css.jobsKillArmed}` : css.jobsKill}
|
||||
aria-label={armed ? t('jobKillConfirm') : t('jobKill')}
|
||||
title={armed ? t('jobKillConfirm') : t('jobKill')}
|
||||
disabled={killing}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
if (armed) void kill(row)
|
||||
else setArmedId(job.id)
|
||||
}}
|
||||
>
|
||||
{armed ? t('jobKillConfirm') : <IconStopOutline16 size={12} />}
|
||||
</button>
|
||||
)}
|
||||
{killFailed && <span className={css.jobsKillError}>{t('jobKillError')}</span>}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
{selectedRow !== undefined && (
|
||||
<JobOutputPane
|
||||
ownerSessionId={selectedRow.ownerSessionId}
|
||||
job={selectedRow.job}
|
||||
active={active}
|
||||
onClose={() => { setSelectedId(undefined) }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The sidebar's Subagent topology page.
|
||||
* @param props - current session id, whether the page is actually visible
|
||||
@@ -372,6 +662,7 @@ export function SubagentView(props: {
|
||||
const rootId = useMemo(() => rootAncestor(byId, sessionId), [byId, sessionId])
|
||||
const rootCatalog = rootId === undefined ? undefined : catalogs[rootId]
|
||||
const rootSummary = rootId === undefined ? undefined : byId[rootId]
|
||||
const live = useSubagentLive(rootId, active)
|
||||
|
||||
/** Catalog owners currently consuming live membership updates. */
|
||||
const observedRef = useRef(new Set<string>())
|
||||
@@ -515,69 +806,77 @@ export function SubagentView(props: {
|
||||
<div
|
||||
ref={bodyRef}
|
||||
className={css.subagentBody}
|
||||
role="tree"
|
||||
aria-label={t('subagent')}
|
||||
aria-busy={summaryBackedLoading || undefined}
|
||||
onKeyDown={onTreeKeyDown}
|
||||
>
|
||||
{rootId !== undefined && rootSummary !== undefined && (
|
||||
<div
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
aria-level={0}
|
||||
aria-label={`${rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')} ${t('subagentMainAgent')}`}
|
||||
aria-current={rootId === sessionId ? 'true' : undefined}
|
||||
className={clsx(css.subagentRow, rootId === sessionId && css.subagentRowActive)}
|
||||
onClick={openMain}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
openMain()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<StateDot
|
||||
state={rootSummary.running === true ? 'ongoing' : 'done'}
|
||||
className={css.subagentDot}
|
||||
/>
|
||||
<span className={css.subagentContent}>
|
||||
<span className={css.subagentLabel}>
|
||||
{rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')}
|
||||
</span>
|
||||
<span className={css.subagentSecondary}>
|
||||
{`${t('subagentMainAgent')} · ${rootSummary.running === true ? t('subagentRunning') : t('subagentInactive')}`}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{rootId !== undefined && (
|
||||
<div className={css.subagentChildren} role="group" aria-busy={summaryBackedLoading || undefined}>
|
||||
{summaryBackedLoading && (
|
||||
<CatalogLoadingRows parentSessionId={rootId} byId={byId} level={1} />
|
||||
)}
|
||||
{!summaryBackedLoading && (
|
||||
<CatalogRows
|
||||
parentSessionId={rootId}
|
||||
catalog={rootCatalog}
|
||||
catalogs={catalogs}
|
||||
byId={byId}
|
||||
level={1}
|
||||
currentSessionId={sessionId}
|
||||
active={active}
|
||||
ctx={ctx}
|
||||
openChild={openChild}
|
||||
refresh={refresh}
|
||||
<div
|
||||
role="tree"
|
||||
aria-label={t('subagent')}
|
||||
aria-busy={summaryBackedLoading || undefined}
|
||||
>
|
||||
{rootId !== undefined && rootSummary !== undefined && (
|
||||
<div
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
aria-level={0}
|
||||
aria-label={`${rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')} ${t('subagentMainAgent')}`}
|
||||
aria-current={rootId === sessionId ? 'true' : undefined}
|
||||
className={clsx(css.subagentRow, rootId === sessionId && css.subagentRowActive)}
|
||||
onClick={openMain}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
openMain()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<StateDot
|
||||
state={rootSummary.running === true ? 'ongoing' : 'done'}
|
||||
className={css.subagentDot}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{readyEmpty && (
|
||||
<div className={css.subagentEmpty}>
|
||||
<div>{t('subagentEmpty')}</div>
|
||||
<div className={css.subagentEmptyHint}>{t('subagentEmptyDesc')}</div>
|
||||
</div>
|
||||
)}
|
||||
<span className={css.subagentContent}>
|
||||
<span className={css.subagentLabel}>
|
||||
{rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')}
|
||||
</span>
|
||||
<span className={css.subagentSecondary}>
|
||||
{`${t('subagentMainAgent')} · ${rootSummary.running === true ? t('subagentRunning') : t('subagentInactive')}`}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{rootId !== undefined && (
|
||||
<div className={css.subagentChildren} role="group" aria-busy={summaryBackedLoading || undefined}>
|
||||
{summaryBackedLoading && (
|
||||
<CatalogLoadingRows parentSessionId={rootId} byId={byId} level={1} />
|
||||
)}
|
||||
{!summaryBackedLoading && (
|
||||
<CatalogRows
|
||||
parentSessionId={rootId}
|
||||
catalog={rootCatalog}
|
||||
catalogs={catalogs}
|
||||
byId={byId}
|
||||
level={1}
|
||||
currentSessionId={sessionId}
|
||||
live={live}
|
||||
openChild={openChild}
|
||||
refresh={refresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{readyEmpty && (
|
||||
<div className={css.subagentEmpty}>
|
||||
<div>{t('subagentEmpty')}</div>
|
||||
<div className={css.subagentEmptyHint}>{t('subagentEmptyDesc')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<JobsSection
|
||||
byId={byId}
|
||||
jobsBySession={list.jobsBySession}
|
||||
rootId={rootId}
|
||||
active={active}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+67
-6
@@ -5,7 +5,7 @@
|
||||
* terminal). Tabs are draggable; dropping onto another tab inserts before it,
|
||||
* dropping on the strip background appends to this pane.
|
||||
*/
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconCloseFill14, IconPlusOutline16, Menu,
|
||||
@@ -63,12 +63,68 @@ export function TabBar(props: {
|
||||
onDropTab: (payload: TabDragPayload, before: string | null) => void
|
||||
/** Icon resolver for tab labels (reads from the tab descriptor registry). */
|
||||
getTabIcon?: (tab: SidebarTab) => ReactNode
|
||||
/** Badge resolver for tab labels (reads the descriptor's `badge`; the
|
||||
* resolver returns the rendered pill or null). */
|
||||
getTabBadge?: (tab: SidebarTab) => ReactNode
|
||||
}) {
|
||||
const {
|
||||
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon,
|
||||
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon, getTabBadge,
|
||||
} = props
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
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
|
||||
// root, where preventDefault() is a no-op. Modifier keys keep their native
|
||||
// meaning (shift = horizontal scroll, ctrl/cmd = zoom), and a strip that
|
||||
// does not overflow leaves the event alone so the page scrolls normally.
|
||||
useEffect(() => {
|
||||
const el = listRef.current
|
||||
if (el === null) return
|
||||
const onWheel = (event: WheelEvent): void => {
|
||||
if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return
|
||||
if (el.scrollWidth <= el.clientWidth) return
|
||||
event.preventDefault()
|
||||
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? el.clientWidth : 1
|
||||
el.scrollLeft += (event.deltaX + event.deltaY) * unit
|
||||
}
|
||||
el.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => { el.removeEventListener('wheel', onWheel) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const clear = (): void => { setTabDragging(false); setDragOver(false) }
|
||||
@@ -104,7 +160,7 @@ export function TabBar(props: {
|
||||
if (payload !== null) onDropTab(payload, null)
|
||||
}}
|
||||
>
|
||||
<div className={css.tabList}>
|
||||
<div ref={listRef} className={css.tabList}>
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
@@ -127,15 +183,20 @@ 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 }
|
||||
}
|
||||
}}
|
||||
>
|
||||
{getTabIcon?.(tab) ?? null}
|
||||
{getTabBadge?.(tab) ?? null}
|
||||
<span className={css.tabTitle}>{tab.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
+174
-24
@@ -8,30 +8,56 @@
|
||||
* retry, and repeated unreasoned failures surface the close code after three
|
||||
* attempts, so the banner never spins forever.
|
||||
*
|
||||
* Three control frames shape the pty lifecycle on unmount:
|
||||
* - `{type:'close'}` — the user closed the tab. The host kills the pty
|
||||
* immediately (quota released).
|
||||
* - `{type:'park'}` — the user switched to another conversation. The tab is
|
||||
* still open in its session's persisted state but its view unmounted; the
|
||||
* host keeps the pty alive indefinitely (no grace countdown), so switching
|
||||
* back reattaches the same shell instead of respawning one.
|
||||
* - bare socket drop (no frame) — page refresh, crash, plugin teardown, or a
|
||||
* same-session re-render. The host's reconnect grace keeps the shell alive
|
||||
* for a quick reconnect.
|
||||
*
|
||||
* Two attach modes share one upgrade endpoint:
|
||||
* - `tabId` starting with `agent:` is an agent-owned terminal (created by
|
||||
* the `terminal_create` tool). The uuid is the suffix after `agent:`; the
|
||||
* view connects with `?uuid=...`. A close frame kills the pty (the agent's
|
||||
* terminal closes when the user closes the tab); a bare socket drop
|
||||
* leaves the pty alive (the agent owns the lifetime).
|
||||
* leaves the pty alive (the agent owns the lifetime) — agent terminals
|
||||
* never send park (their lifetime is already indefinite on bare drop).
|
||||
* - Any other `tabId` is a UI-tab terminal (the user created it from the +
|
||||
* menu). The view connects with `?tab=...&sessionId=...&cwd=...`. A close
|
||||
* frame schedules a 0-ms close; a bare socket drop gets the host's
|
||||
* reconnect grace.
|
||||
* frame schedules a 0-ms close; a park frame marks the pty as parked; a
|
||||
* bare socket drop gets the host's 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 type { SessionScope } from './api.ts'
|
||||
import { openWhenSized } from './open-when-sized.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
|
||||
@@ -59,8 +85,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,
|
||||
@@ -76,16 +108,20 @@ 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)
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current
|
||||
if (host === null) return
|
||||
// The custom font prefs (side card settings, terminal card) resolve at
|
||||
// mount; store changes re-apply them live below.
|
||||
const font = resolveTerminalFont(store.getPrefs(), tokenValue('--ds-font-family-code'))
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 13,
|
||||
fontFamily: tokenValue('--ds-font-family-code') || '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
fontSize: font.fontSize,
|
||||
fontFamily: font.fontFamily,
|
||||
allowTransparency: true,
|
||||
convertEol: false,
|
||||
scrollback: 4000,
|
||||
@@ -93,8 +129,6 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
||||
})
|
||||
const fit = new FitAddon()
|
||||
term.loadAddon(fit)
|
||||
term.open(host)
|
||||
fit.fit()
|
||||
// Re-theme in place when the app's scheme flips (tokens + palette).
|
||||
const applyTheme = (): void => {
|
||||
term.options.theme = xtermTheme()
|
||||
@@ -148,6 +182,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 !== '') {
|
||||
@@ -185,25 +238,79 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
||||
})
|
||||
observer.observe(host)
|
||||
|
||||
// Custom font prefs (the terminal card's secondary settings) apply LIVE:
|
||||
// on any store change re-resolve and diff the two options, re-fitting
|
||||
// when they moved (the grid dimensions may change with the font). The
|
||||
// subscribe fires on every store change (tabs, panels…), so the diff is
|
||||
// what keeps this cheap.
|
||||
const fontSub = store.subscribe(() => {
|
||||
const next = resolveTerminalFont(store.getPrefs(), tokenValue('--ds-font-family-code'))
|
||||
if (next.fontFamily !== term.options.fontFamily || next.fontSize !== term.options.fontSize) {
|
||||
term.options.fontFamily = next.fontFamily
|
||||
term.options.fontSize = next.fontSize
|
||||
try {
|
||||
fit.fit()
|
||||
sendResize()
|
||||
} catch {
|
||||
// The terminal may be mid-dispose; ignore.
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// The terminal must not be opened in a zero-size container: xterm's
|
||||
// renderer creation fails there and the next Viewport refresh crashes
|
||||
// reading `.dimensions` off the undefined renderer (blank terminal on
|
||||
// WKWebView when the bottom panel's expand slide leaves the host at
|
||||
// height 0; any display:none-hidden ancestor does the same). Defer
|
||||
// open+fit until the host has a real size — writes arriving meanwhile
|
||||
// are buffered by xterm's WriteBuffer and render once open, and
|
||||
// FitAddon.fit() is a safe no-op before open. sendResize() here covers
|
||||
// the deferred path where the socket may already be open with the
|
||||
// default 80x24 dims.
|
||||
const cancelOpen = openWhenSized(host, () => {
|
||||
try {
|
||||
term.open(host)
|
||||
fit.fit()
|
||||
sendResize()
|
||||
} catch (error) {
|
||||
console.error('[dsh-better-sidebar] xterm open failed:', error)
|
||||
}
|
||||
})
|
||||
|
||||
connect()
|
||||
return () => {
|
||||
closed = true
|
||||
cancelOpen()
|
||||
window.clearTimeout(retry)
|
||||
observer.disconnect()
|
||||
fontSub()
|
||||
schemeSub()
|
||||
inputSub.dispose()
|
||||
// The close frame tells the host the owning tab is GONE (immediate
|
||||
// pty release). A bare unmount — conversation switch, re-render,
|
||||
// page unload — leaves the tab open, so the socket drop alone hands
|
||||
// the process to the host's reconnect grace: switching back or
|
||||
// refreshing reattaches the SAME shell instead of respawning one.
|
||||
// (The host respawns on its own when the authoritative cwd changed.)
|
||||
// Agent terminals follow the same rule: a close frame kills the pty
|
||||
// (the user closed the sidebar tab); a bare socket drop leaves it
|
||||
// alive (the agent owns the lifetime).
|
||||
if (!store.tabOpen(scope.sessionId, tabId)
|
||||
// Three unmount cases, distinguished by the store's tab/open state and
|
||||
// the active session id:
|
||||
// 1. The tab was closed by the user (NOT in its session's state): send
|
||||
// `{type:'close'}` — the host releases the pty immediately.
|
||||
// 2. The user switched to another conversation (the tab IS still open
|
||||
// in scope.sessionId's state, but the active session is now a
|
||||
// different one): send `{type:'park'}` — the host keeps the pty
|
||||
// alive indefinitely (no grace countdown), so switching back
|
||||
// reattaches the SAME shell. Without this, the bare socket drop
|
||||
// would start the 30s reconnect-grace countdown and kill the shell
|
||||
// while the user is still actively working in the other session.
|
||||
// 3. A same-session unmount (page refresh, crash, plugin teardown, a
|
||||
// re-render that re-mounts the view): bare socket drop — the host's
|
||||
// reconnect grace keeps the shell alive for a quick reconnect.
|
||||
// Agent terminals follow the close-frame rule; their lifetime is owned
|
||||
// by the agent, so a bare drop (case 3) already leaves them alive
|
||||
// indefinitely — no park frame needed.
|
||||
const tabStillOpen = store.tabOpen(scope.sessionId, tabId)
|
||||
const sessionSwitched = store.getSnapshot().sessionId !== scope.sessionId
|
||||
if (!tabStillOpen
|
||||
&& socket !== null && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'close' }))
|
||||
} else if (tabStillOpen && sessionSwitched && !isAgentTabId(tabId)
|
||||
&& socket !== null && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'park' }))
|
||||
}
|
||||
socket?.close()
|
||||
term.dispose()
|
||||
@@ -213,6 +320,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}
|
||||
@@ -226,8 +336,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>
|
||||
)
|
||||
}
|
||||
|
||||
+209
-7
@@ -7,9 +7,15 @@
|
||||
* 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'
|
||||
import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view'
|
||||
@@ -20,13 +26,34 @@ import { languageForPath } from './lang.ts'
|
||||
import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
|
||||
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). */
|
||||
type ViewMode = 'preview' | 'edit'
|
||||
|
||||
/** The floating "add to conversation" action: payload + viewport anchor. */
|
||||
interface SelectionPopup {
|
||||
insert: string
|
||||
left: number
|
||||
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
|
||||
@@ -37,7 +64,7 @@ type ViewMode = 'preview' | 'edit'
|
||||
export const HTML_IFRAME_SANDBOX = 'allow-scripts allow-popups allow-downloads allow-modals'
|
||||
|
||||
export function TextEditor(props: FileViewerProps) {
|
||||
const { scope, path, viewerId, content, truncated } = props
|
||||
const { ctx, scope, path, viewerId, content, truncated } = props
|
||||
const [mode, setMode] = useState<ViewMode>('preview')
|
||||
/** The editor's current text (null while clean); preview renders this. */
|
||||
const [draft, setDraft] = useState<string | null>(null)
|
||||
@@ -50,6 +77,36 @@ export function TextEditor(props: FileViewerProps) {
|
||||
const themeCompRef = useRef<CmThemeCompartment | null>(null)
|
||||
/** The app's resolved color scheme; the editor re-themes in place on flips. */
|
||||
const [dark, setDark] = useState(() => isDarkScheme())
|
||||
/** The floating "add to conversation" popup (viewport-anchored; null = hidden). */
|
||||
const [popup, setPopup] = useState<SelectionPopup | null>(null)
|
||||
/** Live mirror of the popup state for click-time reads (no re-render race). */
|
||||
const popupRef = useRef<SelectionPopup | null>(null)
|
||||
/** The markdown preview container (selection-containment + line lookup). */
|
||||
const mdRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const hidePopup = (): void => {
|
||||
popupRef.current = null
|
||||
setPopup(null)
|
||||
}
|
||||
|
||||
/** Anchor the popup above the selection center; clamp inside the viewport. */
|
||||
const showPopup = (insert: string, left: number, top: number): void => {
|
||||
const next: SelectionPopup = {
|
||||
insert,
|
||||
left: Math.min(Math.max(left, 80), window.innerWidth - 80),
|
||||
top,
|
||||
}
|
||||
popupRef.current = next
|
||||
setPopup(next)
|
||||
}
|
||||
|
||||
/** The popup button's click: insert the stored payload into the draft. */
|
||||
const commitPopup = (): void => {
|
||||
const current = popupRef.current
|
||||
if (current === null) return
|
||||
appendToDraft(ctx, scope.sessionId, current.insert)
|
||||
hidePopup()
|
||||
}
|
||||
|
||||
useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
|
||||
|
||||
@@ -59,6 +116,7 @@ export function TextEditor(props: FileViewerProps) {
|
||||
setDraft(null)
|
||||
setDirty(false)
|
||||
setSaveState('idle')
|
||||
hidePopup()
|
||||
}, [content])
|
||||
|
||||
// Create the CodeMirror editor once the content is loaded. The view owns
|
||||
@@ -100,6 +158,49 @@ export function TextEditor(props: FileViewerProps) {
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
]),
|
||||
// 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 === 'markdown' ? [
|
||||
CodeMirrorView.updateListener.of((update) => {
|
||||
if (update.geometryChanged || update.viewportChanged) {
|
||||
hidePopup()
|
||||
return
|
||||
}
|
||||
if (!update.view.hasFocus) {
|
||||
hidePopup()
|
||||
return
|
||||
}
|
||||
if (!(update.selectionSet || update.docChanged || update.focusChanged)) return
|
||||
const sel = update.state.selection.main
|
||||
if (sel.empty) {
|
||||
hidePopup()
|
||||
return
|
||||
}
|
||||
const text = update.state.sliceDoc(sel.from, sel.to)
|
||||
if (text.trim() === '') {
|
||||
hidePopup()
|
||||
return
|
||||
}
|
||||
// Page coordinates (the document root may scroll); the popup is
|
||||
// position:fixed, so convert to viewport coordinates.
|
||||
const rect = update.view.coordsAtPos(sel.head)
|
||||
if (rect === null) {
|
||||
hidePopup()
|
||||
return
|
||||
}
|
||||
const doc = update.state.doc
|
||||
showPopup(
|
||||
buildSelectionInsert(path, scope.cwd, {
|
||||
start: doc.lineAt(sel.from).number,
|
||||
end: doc.lineAt(sel.to).number,
|
||||
}, text),
|
||||
rect.left - window.scrollX + (rect.right - rect.left) / 2,
|
||||
rect.top - window.scrollY,
|
||||
)
|
||||
}),
|
||||
] : []),
|
||||
],
|
||||
})
|
||||
const view = new CodeMirrorView({ state, parent: host })
|
||||
@@ -124,8 +225,10 @@ export function TextEditor(props: FileViewerProps) {
|
||||
}, [dark])
|
||||
|
||||
// The editor may have been display:none while previewing; re-measure when
|
||||
// it becomes visible again (CodeMirror sizes itself on reveal).
|
||||
// it becomes visible again (CodeMirror sizes itself on reveal). A mode
|
||||
// flip also invalidates any anchored selection popup.
|
||||
useEffect(() => {
|
||||
hidePopup()
|
||||
if (mode === 'edit') viewRef.current?.requestMeasure()
|
||||
}, [mode])
|
||||
|
||||
@@ -147,6 +250,52 @@ 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
|
||||
* container anchors the floating "add to conversation" button above the
|
||||
* selection. Line numbers come from a best-effort reverse-search of the
|
||||
* selected text in the source ({@link linesOfSelection} — an ambiguous or
|
||||
* missing hit omits them). The button's own mousedown preventDefaults so
|
||||
* the selection survives until the click commits.
|
||||
*/
|
||||
const handlePreviewMouseUp = (): void => {
|
||||
const sel = window.getSelection()
|
||||
if (sel === null || sel.isCollapsed || sel.anchorNode === null || sel.focusNode === null) {
|
||||
hidePopup()
|
||||
return
|
||||
}
|
||||
const host = mdRef.current
|
||||
if (host === null || !host.contains(sel.anchorNode) || !host.contains(sel.focusNode)) {
|
||||
hidePopup()
|
||||
return
|
||||
}
|
||||
const text = sel.toString()
|
||||
if (text.trim() === '') {
|
||||
hidePopup()
|
||||
return
|
||||
}
|
||||
const rect = sel.getRangeAt(0).getBoundingClientRect()
|
||||
const lines = linesOfSelection(mdText, text)
|
||||
showPopup(
|
||||
buildSelectionInsert(path, scope.cwd, lines ?? undefined, text),
|
||||
rect.left + rect.width / 2,
|
||||
rect.top,
|
||||
)
|
||||
}
|
||||
const editable = content !== undefined
|
||||
const saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : ''
|
||||
// Per-feature sandbox escape hatch: the global side card setting (warned)
|
||||
@@ -159,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}>
|
||||
@@ -194,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>}
|
||||
@@ -204,8 +377,23 @@ export function TextEditor(props: FileViewerProps) {
|
||||
</>
|
||||
)}
|
||||
{markdown && mode === 'preview' && (
|
||||
<div className={css.editorMd}>
|
||||
<MarkdownText text={draft ?? content ?? ''} />
|
||||
<div
|
||||
className={css.editorMd}
|
||||
ref={mdRef}
|
||||
onMouseUp={handlePreviewMouseUp}
|
||||
onScroll={hidePopup}
|
||||
>
|
||||
{/* The fence copy-button labels must come from this plugin's own
|
||||
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. 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' && (
|
||||
@@ -231,6 +419,20 @@ export function TextEditor(props: FileViewerProps) {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{popup !== null && createPortal(
|
||||
<button
|
||||
type="button"
|
||||
className={css.selectionPopup}
|
||||
style={{ left: popup.left, top: popup.top }}
|
||||
// Keep the selection (and CodeMirror focus) alive until the click
|
||||
// commits — without this the popup unmounts before click lands.
|
||||
onMouseDown={(event) => { event.preventDefault() }}
|
||||
onClick={commitPopup}
|
||||
>
|
||||
{t('addToConversation')}
|
||||
</button>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Uploads (header pickers, the tree's drag-drop and "upload here" menu)
|
||||
* all funnel through here: one session at a time, shown in a full-window
|
||||
* progress overlay with cancel, followed by a tree refresh and a one-line
|
||||
* hint under the search row (success fades, failures and cancels stay).
|
||||
* OS file drags are shielded at the panel host (see Sidebar.tsx), so a
|
||||
* drop over the file window uploads here and never reaches DSH's chat
|
||||
* intake.
|
||||
*/
|
||||
import { useEffect, useRef, useState, type InputHTMLAttributes } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconFolderOpen16, IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { api } from './api.ts'
|
||||
import { FileTree } from './FileTree.tsx'
|
||||
import { IconUploadOutline16 } from './icons.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import { resolveSidebarPath } from './produced-files.ts'
|
||||
import { UploadOverlay } from './UploadOverlay.tsx'
|
||||
import {
|
||||
summarizeResults, uploadHintText, uploadItemsFromFiles, uploadToDir,
|
||||
UPLOAD_HINT_MS, type UploadItem,
|
||||
} from './upload.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** One in-flight upload session (the overlay's progress source). */
|
||||
interface UploadSession {
|
||||
dir: string
|
||||
done: number
|
||||
total: number
|
||||
/** Relative path of the file being uploaded ('' when none is in flight). */
|
||||
current: string
|
||||
controller: AbortController
|
||||
}
|
||||
|
||||
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)
|
||||
/** One-line upload status under the search row ('' hides the hint). */
|
||||
const [uploadStatus, setUploadStatus] = useState('')
|
||||
/** Whether the status line is a failure/cancel (error color, stays visible). */
|
||||
const [uploadFailed, setUploadFailed] = useState(false)
|
||||
/** The in-flight upload session (null → no overlay, buttons enabled). */
|
||||
const [upload, setUpload] = useState<UploadSession | null>(null)
|
||||
/** True between the cancel click and the session settling (button disabled). */
|
||||
const [cancelling, setCancelling] = useState(false)
|
||||
/** Set by cancelUpload; the settle path shows 'upload cancelled' instead of
|
||||
* summarizing the partial results. */
|
||||
const cancelledRef = useRef(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const folderInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
/** Start one upload session into `dir` (absolute, inside the workspace). */
|
||||
const startUpload = (dir: string, items: UploadItem[]): void => {
|
||||
if (items.length === 0 || cwd === undefined || upload !== null) return
|
||||
cancelledRef.current = false
|
||||
const controller = new AbortController()
|
||||
setUploadFailed(false)
|
||||
setUploadStatus(uploadHintText(0, items.length, '', dir, t))
|
||||
setUpload({ dir, done: 0, total: items.length, current: '', controller })
|
||||
void uploadToDir({ sessionId, cwd }, dir, items, (done, total, current) => {
|
||||
if (current !== '') setUploadStatus(uploadHintText(done, total, current, dir, t))
|
||||
setUpload(session => session === null ? session : { ...session, done, total, current })
|
||||
}, controller.signal).then((results) => {
|
||||
setUpload(null)
|
||||
setCancelling(false)
|
||||
// Reload the tree whatever the outcome: files may have landed before a
|
||||
// cancel, and failures leave whatever did succeed visible.
|
||||
setRefreshTick(tick => tick + 1)
|
||||
if (cancelledRef.current) {
|
||||
setUploadStatus(t('uploadCancelled'))
|
||||
setUploadFailed(true)
|
||||
return
|
||||
}
|
||||
const status = summarizeResults(results, t)
|
||||
setUploadStatus(status)
|
||||
setUploadFailed(results.some(result => !result.ok))
|
||||
// Success messages are transient; failures stay until the next action.
|
||||
if (results.every(result => result.ok)) {
|
||||
window.setTimeout(() => {
|
||||
setUploadStatus(current => current === status ? '' : current)
|
||||
}, UPLOAD_HINT_MS)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Cancel the in-flight upload (aborts the request; the host drops its temp). */
|
||||
const cancelUpload = (): void => {
|
||||
if (upload === null || cancelling) return
|
||||
cancelledRef.current = true
|
||||
setCancelling(true)
|
||||
upload.controller.abort()
|
||||
}
|
||||
|
||||
const folderInputProps = { webkitdirectory: '' } as InputHTMLAttributes<HTMLInputElement>
|
||||
|
||||
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])
|
||||
|
||||
const busy = upload !== null
|
||||
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('uploadFiles')}
|
||||
title={t('uploadFiles')}
|
||||
disabled={busy}
|
||||
onClick={() => { fileInputRef.current?.click() }}
|
||||
>
|
||||
<IconUploadOutline16 size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('uploadFolder')}
|
||||
title={t('uploadFolder')}
|
||||
disabled={busy}
|
||||
onClick={() => { folderInputRef.current?.click() }}
|
||||
>
|
||||
<IconFolderOpen16 size={14} />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={(event) => {
|
||||
if (cwd !== undefined) startUpload(cwd, uploadItemsFromFiles(event.target.files ?? []))
|
||||
event.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={folderInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
{...folderInputProps}
|
||||
style={{ display: 'none' }}
|
||||
onChange={(event) => {
|
||||
if (cwd !== undefined) startUpload(cwd, uploadItemsFromFiles(event.target.files ?? []))
|
||||
event.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{uploadStatus !== '' && (
|
||||
<div className={clsx(css.editorSearchHint, uploadFailed && css.editorError)} title={uploadStatus}>{uploadStatus}</div>
|
||||
)}
|
||||
{needle === '' ? (
|
||||
<FileTree
|
||||
sessionId={sessionId}
|
||||
cwd={cwd}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
onOpenFile={onOpenFile}
|
||||
onOpenFileNewTab={onOpenFileNewTab}
|
||||
onOpenFileSide={onOpenFileSide}
|
||||
onReferenceFile={onReferenceFile}
|
||||
refreshTick={refreshTick}
|
||||
onUploadRequest={startUpload}
|
||||
busy={busy}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
{upload !== null && (
|
||||
<UploadOverlay
|
||||
dir={upload.dir}
|
||||
done={upload.done}
|
||||
total={upload.total}
|
||||
current={upload.current}
|
||||
onCancel={cancelUpload}
|
||||
cancelling={cancelling}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Full-window upload progress over the files tree: a blurred scrim (same mask
|
||||
* token as the repo's Modal primitive) with a card showing the target
|
||||
* directory, file-level progress, and a cancel button. Esc cancels too —
|
||||
* clicking the scrim does not, so a stray click can never abort an upload.
|
||||
* Rendered inside TreePanel (absolute inset-0), so it covers only the file
|
||||
* window and never the conversation column.
|
||||
*/
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import { IconUploadOutline16 } from './icons.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import { uploadHintText } from './upload.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
export function UploadOverlay(props: {
|
||||
/** Absolute upload directory (the session workspace or a tree directory). */
|
||||
dir: string
|
||||
done: number
|
||||
total: number
|
||||
/** Relative path of the file being uploaded ('' when none is in flight). */
|
||||
current: string
|
||||
onCancel: () => void
|
||||
/** True while cancellation is in flight (disables the cancel button). */
|
||||
cancelling?: boolean
|
||||
}): ReactNode {
|
||||
const { dir, done, total, current, onCancel, cancelling } = props
|
||||
// Esc cancels (MermaidModal-style native listener; the overlay is not a
|
||||
// focus-trapped dialog, so a window listener is the honest scope).
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') onCancel()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => { window.removeEventListener('keydown', onKey) }
|
||||
}, [onCancel])
|
||||
|
||||
const percent = total === 0 ? 0 : Math.min(100, Math.round((done / total) * 100))
|
||||
return (
|
||||
<div className={css.uploadOverlay} role="dialog" aria-modal="true" aria-label={t('uploadingTo', { dir })}>
|
||||
<div className={css.uploadOverlayCard}>
|
||||
<div className={css.uploadOverlayTitle} title={dir}>
|
||||
<IconUploadOutline16 size={16} />
|
||||
<span>{t('uploadingTo', { dir })}</span>
|
||||
</div>
|
||||
<div
|
||||
className={css.uploadOverlayProgress}
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={total}
|
||||
aria-valuenow={done}
|
||||
aria-valuetext={t('uploadProgress', { done, total, name: current })}
|
||||
>
|
||||
<div className={css.uploadOverlayProgressFill} style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
<div className={css.uploadOverlayStatus}>{uploadHintText(done, total, current, dir, t)}</div>
|
||||
<button type="button" className={css.uploadOverlayCancel} disabled={cancelling} onClick={onCancel}>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* The "add plugin" modals (Side card settings → the dashed cards at the
|
||||
* end of the 侧边栏内容 / 文件预览 grids): declare that the sidebar's
|
||||
* extension points — tab pages and file previewers — are open to plugins
|
||||
* (registered through `ctx.betterSidebar`), point at the GitHub topic page
|
||||
* for discovery, and show the repo's recommended plugin catalog of the
|
||||
* matching kind (name / url / description / install script).
|
||||
*
|
||||
* Per entry there are two actions:
|
||||
* - 「跳转」opens the plugin's repo in a REAL new browser tab (window.open
|
||||
* — a button, so the sidebar link takeover cannot reroute it);
|
||||
* - 「安装」only COPIES the install script to the clipboard (writeClipboard)
|
||||
* with a transient "已复制" feedback on the button — the user pastes and
|
||||
* runs it wherever they manage their DSH profile. No terminal is opened,
|
||||
* nothing is closed, nothing can fail outward.
|
||||
*
|
||||
* The body is extracted as {@link PluginListBody} so tests render it
|
||||
* directly — the Modal primitive runs hooks unconditionally, so an open
|
||||
* Modal must never be renderToString'd (same rule as the settingsFor popup
|
||||
* in SideCardSection); the modal itself mounts only while open.
|
||||
*/
|
||||
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'
|
||||
import { builtinTabPlugins } from './plugins-tabs.ts'
|
||||
import { builtinViewerPlugins } from './plugins-viewers.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './SideCardSection.module.css'
|
||||
|
||||
/** Which extension point the modal is adding a plugin for. */
|
||||
export type PluginKind = 'tab' | 'viewer'
|
||||
|
||||
/** The catalog of one kind (kept in two repo files: plugins-tabs.ts /
|
||||
* plugins-viewers.ts). */
|
||||
function catalogOf(kind: PluginKind): readonly PluginEntry[] {
|
||||
return kind === 'tab' ? builtinTabPlugins : builtinViewerPlugins
|
||||
}
|
||||
|
||||
/** How long the "已复制" feedback stays on the copy button. */
|
||||
const COPIED_FEEDBACK_MS = 1500
|
||||
|
||||
/** The modal body: the GitHub topic button + the recommended plugin list
|
||||
* with per-entry jump/copy buttons (extracted for direct testing). */
|
||||
export function PluginListBody(props: { service: BetterSidebarService; kind: PluginKind }) {
|
||||
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
|
||||
* successful write — when the clipboard is unavailable or denied
|
||||
* (writeClipboard resolves false) nothing is shown, so the user is never
|
||||
* told to paste a command that was not placed on the clipboard. Never
|
||||
* closes anything, never throws outward. */
|
||||
const copy = async (entry: PluginEntry): Promise<void> => {
|
||||
const written = await writeClipboard(entry.install)
|
||||
if (!written) return
|
||||
setCopiedId(entry.id)
|
||||
window.setTimeout(() => {
|
||||
setCopiedId(current => (current === entry.id ? null : current))
|
||||
}, COPIED_FEEDBACK_MS)
|
||||
}
|
||||
|
||||
/** Open the plugin's repo in a REAL new browser tab (window.open — a
|
||||
* button, so the sidebar link takeover cannot reroute it). */
|
||||
const jump = (entry: PluginEntry): void => {
|
||||
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
|
||||
type="button"
|
||||
className={css.pluginTopicBtn}
|
||||
onClick={() => { window.open(PLUGIN_TOPIC_URL, '_blank', 'noopener') }}
|
||||
>
|
||||
{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}>{filtered.length}</span>
|
||||
</div>
|
||||
{catalog.length === 0 ? (
|
||||
<div className={css.pluginEmpty}>{t('addPluginsEmpty')}</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className={css.pluginEmpty}>{t('addPluginsNoMatch')}</div>
|
||||
) : (
|
||||
<div className={css.pluginEntries}>
|
||||
{[...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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The modal itself (mounted only while open — see the module comment). */
|
||||
export function AddPluginModal(props: { service: BetterSidebarService; onClose: () => void; kind: PluginKind }) {
|
||||
const { service, onClose, kind } = props
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title={kind === 'tab' ? t('addPluginsTabCard') : t('addPluginsViewerCard')}
|
||||
description={kind === 'tab' ? t('addPluginsTabDesc') : t('addPluginsViewerDesc')}
|
||||
closeLabel={t('close')}
|
||||
className={css.pluginModal}
|
||||
footer={(
|
||||
<button type="button" className={css.done} onClick={onClose}>
|
||||
{t('settingsDone')}
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<PluginListBody service={service} kind={kind} />
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
+140
-4
@@ -7,6 +7,8 @@
|
||||
* request). Failures surface as {@link SidebarApiError} with the wire code.
|
||||
*/
|
||||
import { encodeHtmlUrl } from '../html-route.ts'
|
||||
import type { LastActivity } from '../subagent-activity.ts'
|
||||
import type { SidechatThreadInfo } from '../sidechat-core.ts'
|
||||
import type { BrowserProbeResult } from './browser.ts'
|
||||
|
||||
/** One wire failure. */
|
||||
@@ -25,6 +27,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). */
|
||||
@@ -60,6 +66,39 @@ export interface FsTextResult { kind: 'text'; content: string; truncated: boolea
|
||||
* `head` carries the first bytes (base64) for viewer detect sniffing. */
|
||||
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string }
|
||||
|
||||
/**
|
||||
* One jobs.output response: the output the MODEL has read so far for the
|
||||
* job (replayed from the owner session's event log — the model's
|
||||
* job_output cursor is never touched, so the pane can never steal the
|
||||
* agent's bytes). `read` is false until the model actually called
|
||||
* job_output for the job.
|
||||
*/
|
||||
export interface JobOutputResult {
|
||||
text: string
|
||||
/** True when the host capped the text at its output limit. */
|
||||
truncated: boolean
|
||||
/** Whether the model has read the job at least once. */
|
||||
read: boolean
|
||||
}
|
||||
|
||||
/** The `subagents.live` response: running child id → latest activity. */
|
||||
export type SubagentLiveResult = { live: Record<string, LastActivity> }
|
||||
|
||||
/** 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 {
|
||||
@@ -83,6 +122,45 @@ async function call<T>(method: string, payload: Record<string, unknown>, signal?
|
||||
return parsed.value as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload one file to the sidebar's raw upload route: the File goes straight
|
||||
* into the POST body (no JSON/base64 re-encoding — the host streams it into
|
||||
* the workspace). Failure surfaces as {@link SidebarApiError} with the wire
|
||||
* code, exactly like every `/sidebar/api` call. An aborted `signal` rejects
|
||||
* with the DOMException as-is (the caller decides whether that is an error).
|
||||
*/
|
||||
async function fetchUpload<T>(
|
||||
scope: SessionScope,
|
||||
dir: string,
|
||||
relativePath: string,
|
||||
body: Blob,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const params = new URLSearchParams({ sessionId: scope.sessionId, dir, relativePath })
|
||||
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`/sidebar/upload?${params.toString()}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/octet-stream' },
|
||||
body,
|
||||
signal,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') throw error
|
||||
throw new SidebarApiError('network', error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
const parsed: { ok?: boolean; value?: unknown; error?: { code?: string; message?: string } } | null
|
||||
= await response.json().catch(() => null)
|
||||
if (!response.ok || parsed === null || parsed.ok !== true || parsed.value === undefined) {
|
||||
throw new SidebarApiError(
|
||||
parsed?.error?.code ?? 'http',
|
||||
parsed?.error?.message ?? `HTTP ${response.status}`,
|
||||
)
|
||||
}
|
||||
return parsed.value as T
|
||||
}
|
||||
|
||||
/** One request's session scope: the conversation id plus its cwd when known. */
|
||||
export interface SessionScope {
|
||||
sessionId: string
|
||||
@@ -101,10 +179,18 @@ 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) =>
|
||||
call<{ ok: true }>('fs.write', scopePayload(scope, { path, content })),
|
||||
/** Upload one file's raw bytes into `dir` (keeps the folder tree via
|
||||
* `relativePath`); the host streams it under the session workspace. */
|
||||
uploadFile: (scope: SessionScope, dir: string, relativePath: string, body: Blob, signal?: AbortSignal) =>
|
||||
fetchUpload<{ path: string; size: number }>(scope, dir, relativePath, body, signal),
|
||||
gitStatus: (scope: SessionScope, signal?: AbortSignal) =>
|
||||
call<GitStatusResult>('git.status', scopePayload(scope, {}), signal),
|
||||
gitDiff: (scope: SessionScope, path: string | undefined, staged: boolean, signal?: AbortSignal) =>
|
||||
@@ -145,9 +231,54 @@ 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
|
||||
* cursor). The scope MUST be the job's OWNER session.
|
||||
*/
|
||||
jobOutput: (scope: SessionScope, id: string, signal?: AbortSignal) =>
|
||||
call<JobOutputResult>('jobs.output', scopePayload(scope, { id }), signal),
|
||||
/** Request cancellation of one background job (live jobs flip to stopping). */
|
||||
jobKill: (scope: SessionScope, id: string, reason?: string) =>
|
||||
call<{ ok: true; outcome: 'requested' | 'already-finished' }>('jobs.kill', scopePayload(scope, {
|
||||
id,
|
||||
...(reason !== undefined ? { reason } : {}),
|
||||
})),
|
||||
/**
|
||||
* One batch live-preview fetch for the whole Subagent tree. The payload is
|
||||
* the already-resolved topology ROOT (not a session scope); the host
|
||||
* enumerates descendants once and folds running children's activity.
|
||||
*/
|
||||
subagentsLive: (rootSessionId: string, signal?: AbortSignal) =>
|
||||
call<SubagentLiveResult>('subagents.live', { rootSessionId }, signal),
|
||||
/** Create a Side Chat thread: a child session seeded with the parent's
|
||||
* full log up to now. Empty question = immediate create (Codex-style):
|
||||
* the thread opens empty, the first prompt carries the boundary. */
|
||||
sidechatStart: (sessionId: string, question?: string) =>
|
||||
call<{ childId: string }>('sidechat.start', { sessionId, question: question ?? '' }),
|
||||
/** Deliver one follow-up message to a Side Chat thread. */
|
||||
sidechatPrompt: (childId: string, text: string) =>
|
||||
call<{ accepted: true }>('sidechat.prompt', { childId, text }),
|
||||
/** Abort a Side Chat thread's running turn (queued work is preserved). */
|
||||
sidechatCancel: (childId: string) =>
|
||||
call<{ accepted: true }>('sidechat.cancel', { childId }),
|
||||
/** Release a Side Chat thread's live agent (history stays persisted). */
|
||||
sidechatDispose: (childId: string) =>
|
||||
call<{ accepted: true }>('sidechat.dispose', { childId }),
|
||||
/** Live state + agent identity (provider/model/preset) of a thread. */
|
||||
sidechatInfo: (childId: string) =>
|
||||
call<SidechatThreadInfo>('sidechat.info', { childId }),
|
||||
/** 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', {
|
||||
@@ -179,9 +310,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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Narrow-viewport ("mobile") breakpoint for the sidebar. Width-based, shared
|
||||
* by the layout logic (JS) and the style gates (CSS). The CSS side pairs
|
||||
* with this file via `@media (max-width: 767px)` rules (sidebar.module.css)
|
||||
* — 767px ≡ widths below NARROW_MAX_WIDTH, documented at both ends.
|
||||
*
|
||||
* "Real narrow" on purpose: the mobile layout (one full-screen drawer, the
|
||||
* bottom panel's tabs merged into the right sidebar) is a phone / portrait
|
||||
* tablet experience. The value is deliberately NOT aligned to the DSH app
|
||||
* shell's own 1024px breakpoint — 1024px windows (small laptops, split
|
||||
* panes) keep the desktop two-panel layout.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/** Viewport widths strictly below this are "mobile" (paired CSS: max-width: 767px). */
|
||||
export const NARROW_MAX_WIDTH = 768
|
||||
|
||||
/** Whether a viewport width is narrow (mobile). */
|
||||
export function isNarrowWidth(width: number): boolean {
|
||||
return width < NARROW_MAX_WIDTH
|
||||
}
|
||||
|
||||
/**
|
||||
* Live narrow-viewport flag for components. Reads `window.innerWidth` and
|
||||
* re-measures on resize (rAF-throttled, the repo's existing drag pattern).
|
||||
* Deliberately avoids `matchMedia` (jsdom does not implement it) — the
|
||||
* resize listener is equally exact for a breakpoint that never changes
|
||||
* while the page is open.
|
||||
*/
|
||||
export function useNarrowViewport(): boolean {
|
||||
const [narrow, setNarrow] = useState(
|
||||
() => typeof window !== 'undefined' && isNarrowWidth(window.innerWidth),
|
||||
)
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
let frame: number | null = null
|
||||
const measure = (): void => {
|
||||
frame = null
|
||||
setNarrow(isNarrowWidth(window.innerWidth))
|
||||
}
|
||||
const onResize = (): void => {
|
||||
if (frame === null) frame = requestAnimationFrame(measure)
|
||||
}
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
if (frame !== null) cancelAnimationFrame(frame)
|
||||
}
|
||||
}, [])
|
||||
return narrow
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
+189
-41
@@ -1,29 +1,73 @@
|
||||
/**
|
||||
* The 7 built-in tab descriptors: the plugin registers its own pages
|
||||
* (explorer / git / terminal / browser / subagent / editor / diff) through
|
||||
* (editor / git / subagent / sidechat / terminal / browser / 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, IconNewChatOutline16, 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 { TerminalView } from '../TerminalView.tsx'
|
||||
import { lazyChunkComponent } from '../lazy-chunk.tsx'
|
||||
import { GitView } from '../GitView.tsx'
|
||||
import { DiffTab } from '../DiffTab.tsx'
|
||||
import { SubagentView } from '../SubagentView.tsx'
|
||||
import { consumeSidechatSeed, SideChatView, sidechatThreadIdOf } from '../SideChatView.tsx'
|
||||
import { api } from '../api.ts'
|
||||
import { BrowserView } from '../BrowserView.tsx'
|
||||
import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16 } from '../icons.tsx'
|
||||
import { TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN } from '../../prefs-shared.ts'
|
||||
import type { ComponentType } from 'react'
|
||||
import type { SessionScope } from '../api.ts'
|
||||
import type { SidebarStore } from '../state.ts'
|
||||
import type { TabDescriptor } from '../service.ts'
|
||||
|
||||
/**
|
||||
* Lazy wrapper over the terminal view: xterm (and its stylesheet) is fetched
|
||||
* only when a terminal tab is first opened (see chunk-loader.ts). The
|
||||
* wrapper keeps the descriptor contract `(props) => ReactNode` — Sidebar
|
||||
* calls it as a plain function.
|
||||
*
|
||||
* TerminalView's props are { scope, tabId, store } — `tabId` is NOT part of
|
||||
* TabComponentProps (it carries `tab: SidebarTab` instead), so the
|
||||
* descriptor maps it explicitly; a bare pass-through would leave tabId
|
||||
* undefined and TerminalView's isAgentTabId(tabId) would crash on
|
||||
* `undefined.startsWith` (regression-pinned in tests/lazy-chunk.spec.tsx).
|
||||
*/
|
||||
const LazyTerminal = lazyChunkComponent<TerminalViewProps>(
|
||||
'terminal',
|
||||
(mod) => mod.TerminalView as ComponentType<TerminalViewProps> | undefined,
|
||||
)
|
||||
|
||||
/** The terminal view's props (mirror of TerminalView's own signature). */
|
||||
interface TerminalViewProps {
|
||||
scope: SessionScope
|
||||
tabId: string
|
||||
store: SidebarStore
|
||||
}
|
||||
|
||||
/** 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)
|
||||
@@ -31,33 +75,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 */ })}
|
||||
/>
|
||||
),
|
||||
@@ -82,13 +145,17 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
icon: (size: number) => <IconThinkOutline16 size={size} />,
|
||||
order: 30,
|
||||
single: true,
|
||||
// Declarative settings: the auto-open switch renders under this row in
|
||||
// the Side card settings page (the Subagent page's own related setting).
|
||||
// Declarative settings: the auto-open switches render under this row in
|
||||
// the Side card settings page (the Jobs page's own related settings).
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'autoOpenSubagent',
|
||||
title: () => t('settingsSubagentTitle'),
|
||||
desc: () => t('settingsSubagentDesc'),
|
||||
}, {
|
||||
key: 'autoOpenJobs',
|
||||
title: () => t('settingsJobsTitle'),
|
||||
desc: () => t('settingsJobsDesc'),
|
||||
}],
|
||||
},
|
||||
component: ({ ctx, scope, visible, onSubagentJump }) => (
|
||||
@@ -100,16 +167,62 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'sidechat',
|
||||
title: () => t('sideChat'),
|
||||
icon: (size: number) => <IconNewChatOutline16 size={size} />,
|
||||
order: 35,
|
||||
// Codex-style: EVERY side conversation is its own tab. A plain open
|
||||
// mints a fresh tab flagged `autoCreate` (the view creates the EMPTY
|
||||
// thread on mount); a thread switch from the header menu parks the
|
||||
// target id for a deterministic `sidechat:<threadId>` reattach tab.
|
||||
createTab: () => {
|
||||
const threadId = consumeSidechatSeed()
|
||||
if (threadId !== undefined) {
|
||||
return {
|
||||
tab: {
|
||||
id: `sidechat:${threadId}`,
|
||||
type: 'sidechat',
|
||||
title: t('sideChat'),
|
||||
meta: { threadId },
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
tab: {
|
||||
id: `sidechat:new-${crypto.randomUUID()}`,
|
||||
type: 'sidechat',
|
||||
title: t('sideChatUntitled'),
|
||||
meta: { autoCreate: true },
|
||||
},
|
||||
}
|
||||
},
|
||||
// One tab per thread: an already-open thread focuses instead of
|
||||
// duplicating; unbound fresh tabs never dedupe (each mints its own).
|
||||
dedupeKey: (tab) => sidechatThreadIdOf(tab),
|
||||
// Closing the tab releases the thread's live agent; the session and
|
||||
// its history stay persisted (reopen from any thread's header menu).
|
||||
onClose: (tab) => {
|
||||
const threadId = sidechatThreadIdOf(tab)
|
||||
if (threadId !== undefined) {
|
||||
void api.sidechatDispose(threadId).catch(() => {})
|
||||
}
|
||||
},
|
||||
component: ({ ctx, scope, tab, visible }) => (
|
||||
<SideChatView ctx={ctx} scope={scope} tab={tab} visible={visible} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'terminal',
|
||||
title: () => t('terminal'),
|
||||
icon: (size: number) => <IconTerminalOutline16 size={size} />,
|
||||
order: 40,
|
||||
available: (_ctx, _scope, state) => uiTerminalCount(state) < TERMINAL_LIMIT,
|
||||
// Declarative settings: the model-facing terminal tools switch and the
|
||||
// bottom-panel first-expansion auto-terminal switch render under this
|
||||
// row in the Side card settings page (the Terminal page's own related
|
||||
// settings; the host gates the toolset on the tools one independently).
|
||||
// Declarative settings: the model-facing terminal tools switch, the
|
||||
// bottom-panel first-expansion auto-terminal switch, and the custom
|
||||
// font family/size rows render under this card in the Side card
|
||||
// settings page (the host gates the toolset on the tools one
|
||||
// independently; the font rows apply live to every terminal).
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'agentTerminalTools',
|
||||
@@ -119,6 +232,32 @@ 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',
|
||||
title: () => t('settingsFontFamilyTitle'),
|
||||
desc: () => t('settingsFontFamilyDesc'),
|
||||
placeholder: t('settingsFontFamilyPlaceholder'),
|
||||
}, {
|
||||
key: 'terminalFontSize',
|
||||
type: 'number',
|
||||
title: () => t('settingsFontSizeTitle'),
|
||||
desc: () => t('settingsFontSizeDesc'),
|
||||
min: TERMINAL_FONT_SIZE_MIN,
|
||||
max: TERMINAL_FONT_SIZE_MAX,
|
||||
unit: 'px',
|
||||
}],
|
||||
},
|
||||
createTab: (state) => {
|
||||
@@ -126,25 +265,26 @@ 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 },
|
||||
}
|
||||
},
|
||||
component: ({ scope, tab, store }) => (
|
||||
<TerminalView scope={scope} tabId={tab.id} store={store} />
|
||||
),
|
||||
component: ({ tab, scope, store }) => <LazyTerminal scope={scope} store={store} tabId={tab.id} />,
|
||||
},
|
||||
{
|
||||
id: 'browser',
|
||||
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',
|
||||
@@ -154,6 +294,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) => ({
|
||||
|
||||
@@ -1,35 +1,51 @@
|
||||
/**
|
||||
* The 9 built-in file viewer descriptors: every preview surface is a
|
||||
* registered viewer (image / pdf / docx / xlsx / pptx / markdown / html /
|
||||
* code / binary-download), exactly like external plugins register theirs.
|
||||
* The 6 built-in file viewer descriptors: every preview surface is a
|
||||
* registered viewer (image / pdf / markdown / html / code /
|
||||
* binary-download), exactly like external plugins register theirs. Office
|
||||
* previews (.docx / .xlsx / .pptx) are NOT built in anymore — they moved to
|
||||
* the recommended office plugin (see plugins-viewers.ts), which registers
|
||||
* the same ids through this service.
|
||||
*
|
||||
* The `binary-download` viewer sniffs NUL bytes via `detect` for unknown
|
||||
* binaries and serves doc/xls/ppt by extension; `code` is the catch-all
|
||||
* (`exts: []`, lowest priority) that claims any file no other viewer did.
|
||||
* binaries and serves legacy doc/xls/ppt by extension; `code` is the
|
||||
* catch-all (`exts: []`, lowest priority) that claims any file no other
|
||||
* viewer did.
|
||||
*
|
||||
* The heavy viewers (the CodeMirror-backed markdown/html/code) render
|
||||
* through {@link lazyChunkComponent} wrappers — their libraries are fetched
|
||||
* only when such a file is first opened (see chunk-loader.ts). The
|
||||
* descriptor metadata (id/exts/priority/detect) is identical either way,
|
||||
* so matching semantics and external-plugin overrides are unaffected; the
|
||||
* `component` wrapper keeps the descriptor contract `(props) => ReactNode`.
|
||||
*
|
||||
* Every viewer carries the declarative settings-surface fields — `title`
|
||||
* and `icon` — so the Side card settings page can render the enable/disable
|
||||
* inventory without hardcoding (eating our own dogfood).
|
||||
*/
|
||||
import { IconCodeOutline16, IconDownloadOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { DocxView, XlsxView } from '../office-view.tsx'
|
||||
import { lazyChunkComponent } from '../lazy-chunk.tsx'
|
||||
import { PdfView } from '../PdfView.tsx'
|
||||
import { PptxView } from '../PptxView.tsx'
|
||||
import { TextEditor } from '../TextEditor.tsx'
|
||||
import { BinaryDownload } from '../binary-download.tsx'
|
||||
import {
|
||||
IconImageOutline16,
|
||||
IconMarkdownOutline16,
|
||||
IconPdfOutline16,
|
||||
IconDocxOutline16,
|
||||
IconXlsxOutline16,
|
||||
IconPptxOutline16,
|
||||
IconHtmlOutline16,
|
||||
} from '../icons.tsx'
|
||||
import type { FileViewerDescriptor } from '../service.ts'
|
||||
import type { ComponentType } from 'react'
|
||||
import type { FileViewerDescriptor, FileViewerProps } from '../service.ts'
|
||||
import { t } from '../locales.ts'
|
||||
import css from '../sidebar.module.css'
|
||||
|
||||
/** The 9 built-in file viewer descriptors. */
|
||||
/**
|
||||
* Lazy wrapper over the chunk-resident viewer component. The `pick`
|
||||
* function is module-level (stable identity — the wrapper effect depends
|
||||
* on it); the cast bridges the chunk exports record to the descriptor prop
|
||||
* shape (the view reads only its own subset of FileViewerProps).
|
||||
*/
|
||||
const LazyTextEditor = lazyChunkComponent<FileViewerProps>('editor', (mod) => mod.TextEditor as ComponentType<FileViewerProps> | undefined)
|
||||
|
||||
/** The 6 built-in file viewer descriptors. */
|
||||
export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
return [
|
||||
{
|
||||
@@ -54,43 +70,13 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
<PdfView scope={scope} path={path} title={title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'docx',
|
||||
title: () => t('viewerDocx'),
|
||||
icon: (size: number) => <IconDocxOutline16 size={size} />,
|
||||
exts: ['docx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ scope, path, title }) => (
|
||||
<DocxView scope={scope} path={path} title={title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'xlsx',
|
||||
title: () => t('viewerXlsx'),
|
||||
icon: (size: number) => <IconXlsxOutline16 size={size} />,
|
||||
exts: ['xlsx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ scope, path, title }) => (
|
||||
<XlsxView scope={scope} path={path} title={title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'pptx',
|
||||
title: () => t('viewerPptx'),
|
||||
icon: (size: number) => <IconPptxOutline16 size={size} />,
|
||||
exts: ['pptx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ scope, path, title }) => (
|
||||
<PptxView scope={scope} path={path} title={title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'markdown',
|
||||
title: () => t('viewerMarkdown'),
|
||||
icon: (size: number) => <IconMarkdownOutline16 size={size} />,
|
||||
exts: ['md', 'markdown'],
|
||||
fetchStrategy: 'fsRead',
|
||||
component: (props) => <TextEditor {...props} />,
|
||||
component: (props) => <LazyTextEditor {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'html',
|
||||
@@ -112,7 +98,7 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
desc: () => t('settingsHtmlDefaultUnsafeDesc'),
|
||||
}],
|
||||
},
|
||||
component: (props) => <TextEditor {...props} />,
|
||||
component: (props) => <LazyTextEditor {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'code',
|
||||
@@ -121,7 +107,7 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
exts: [],
|
||||
priority: -100,
|
||||
fetchStrategy: 'fsRead',
|
||||
component: (props) => <TextEditor {...props} />,
|
||||
component: (props) => <LazyTextEditor {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'binary-download',
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Lazy chunk loader for the client bundle. The heavy preview/terminal
|
||||
* libraries (CodeMirror, xterm — the editor/terminal stacks, several MB)
|
||||
* live in separate build-time bundles (`lib/client-<name>.js`) fetched only
|
||||
* on first use of the feature that needs them, so startup downloads/parses
|
||||
* only the ~1MB core bundle. (The office stack — Univer / docx-preview /
|
||||
* pptx-renderer — is no longer bundled here: Office previews moved to the
|
||||
* recommended office plugin, see plugins-viewers.ts.)
|
||||
*
|
||||
* How a chunk script works (see tsdown.config.ts chunkBundle):
|
||||
*
|
||||
* globalThis.__dshChunks__ = globalThis.__dshChunks__ || {};
|
||||
* globalThis.__dshChunks__["terminal"] = (require) => { ...exports };
|
||||
*
|
||||
* The script registers its factory on a plugin-owned global registry (NOT
|
||||
* through window.__ModuleLoader__.load — the module loader's import() only
|
||||
* resolves seed words, shell-own modules, registered factories, and boot
|
||||
* graph rows; a chunk id is none of those, so resolution would be version-
|
||||
* dependent). Materialization is plugin-owned:
|
||||
*
|
||||
* 1. inject <script src="/sidebar/bundle/<name>.js"> (classic same-origin
|
||||
* script; the official /plugins/<id>/client.js route cannot serve
|
||||
* arbitrary file names, so the plugin's own host route serves the chunks),
|
||||
* 2. read the factory from the global registry,
|
||||
* 3. call it with a require that resolves the platform externals through
|
||||
* `__DSH_MODULES__.import(spec)` — the seed-word branch, the one part of
|
||||
* the module system that is stable across versions.
|
||||
*
|
||||
* 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. 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
|
||||
* re-injects and re-executes.
|
||||
* - HTTP: the bundle route revalidates every request (`cache-control:
|
||||
* 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 revalidateChunksOnReactivate},
|
||||
* which HEADs every loaded chunk against the bundle route and keeps the
|
||||
* in-memory cache for the ones whose ETag is unchanged (a hot-reloaded core
|
||||
* bundle therefore does not re-execute multi-MB chunk scripts). Chunk-only
|
||||
* source edits still need a manual page refresh (the HMR poll watches only
|
||||
* client.js); an edit that does land while a core HMR happens is caught by
|
||||
* the ETag comparison on the next activation.
|
||||
*/
|
||||
export type ChunkName = 'terminal' | 'editor' | 'mermaid'
|
||||
|
||||
/** The module exports a chunk factory provides (namespace-ish record). */
|
||||
export type ChunkExports = Record<string, unknown>
|
||||
|
||||
/** A chunk factory: (require) => exports (the chunk's CJS closure shape). */
|
||||
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. 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',
|
||||
'react/jsx-runtime',
|
||||
'react-dom',
|
||||
'react-dom/client',
|
||||
'cordis',
|
||||
'@deepseek-ai/dsh-client-ui-slots',
|
||||
'@deepseek-ai/dsh-client-ui-primitives',
|
||||
'@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`
|
||||
|
||||
/** 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>
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
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. */
|
||||
interface ChunkRegistry {
|
||||
[name: string]: ChunkFactory | undefined
|
||||
}
|
||||
|
||||
function chunkRegistry(): ChunkRegistry {
|
||||
const g = globalThis as { __dshChunks__?: ChunkRegistry }
|
||||
return g.__dshChunks__ ??= {}
|
||||
}
|
||||
|
||||
/** Script-load hook; tests replace it with a stub (the default needs a real DOM + network). */
|
||||
export type ChunkScriptLoader = (src: string) => Promise<void>
|
||||
|
||||
const defaultScriptLoader: ChunkScriptLoader = (src) => new Promise((resolve, reject) => {
|
||||
const el = document.createElement('script')
|
||||
el.async = true
|
||||
el.src = src
|
||||
el.addEventListener('load', () => {
|
||||
el.remove()
|
||||
resolve()
|
||||
}, { once: true })
|
||||
el.addEventListener('error', () => {
|
||||
el.remove()
|
||||
reject(new Error(`[dsh-better-sidebar] chunk script ${src} failed to load`))
|
||||
}, { once: true })
|
||||
document.head.append(el)
|
||||
})
|
||||
|
||||
let scriptLoader: ChunkScriptLoader = defaultScriptLoader
|
||||
|
||||
/** Test hook: replace the chunk-script loader (pass null to restore the default). */
|
||||
export function setChunkScriptLoaderForTests(loader: ChunkScriptLoader | null): void {
|
||||
scriptLoader = loader ?? defaultScriptLoader
|
||||
}
|
||||
|
||||
/** Test/dev hook: resolve a chunk without fetching a script (e.g. vitest). */
|
||||
const testLoaders = new Map<ChunkName, () => Promise<ChunkExports>>()
|
||||
export function registerChunkForTests(name: ChunkName, loader: () => Promise<ChunkExports>): void {
|
||||
testLoaders.set(name, loader)
|
||||
}
|
||||
|
||||
/** Memoized externals require, resolved once per page from the seed table. */
|
||||
let externalsRequire: ((spec: string) => unknown) | undefined
|
||||
|
||||
async function buildExternalsRequire(modules: ChunkModuleSystem): Promise<(spec: string) => unknown> {
|
||||
if (externalsRequire !== undefined) return externalsRequire
|
||||
// Per-spec tolerance: a spec the running DSH version cannot resolve (e.g.
|
||||
// the runtime/client exemption row) stays unresolved until a chunk
|
||||
// actually requires it — only then it is a loud error.
|
||||
const entries = await Promise.all(CHUNK_EXTERNALS.map(async (spec) => {
|
||||
try {
|
||||
return [spec, await modules.import(spec)] as const
|
||||
} catch {
|
||||
return [spec, undefined] as const
|
||||
}
|
||||
}))
|
||||
const table = new Map<string, unknown>(entries)
|
||||
externalsRequire = (spec: string): unknown => {
|
||||
if (!table.has(spec)) {
|
||||
// Single quotes: the bundle-consistency scan regexes for require("...")
|
||||
// lexical calls — a double-quoted literal here would trip it.
|
||||
throw new Error(`[dsh-better-sidebar] chunk require('${spec}') missed the module table`)
|
||||
}
|
||||
return table.get(spec)
|
||||
}
|
||||
return externalsRequire
|
||||
}
|
||||
|
||||
/** 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
|
||||
* entry so the next call retries (the script re-executes and overwrites its
|
||||
* global registry slot — assignments are idempotent).
|
||||
* @param name - the chunk to load.
|
||||
*/
|
||||
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
|
||||
let task: Promise<ChunkExports>
|
||||
task = (async (): Promise<ChunkExports> => {
|
||||
const test = testLoaders.get(name)
|
||||
if (test !== undefined) return test()
|
||||
const modules = moduleSystem()
|
||||
if (modules === undefined) {
|
||||
throw new Error(`[dsh-better-sidebar] chunk "${name}": client module system unavailable`)
|
||||
}
|
||||
await scriptLoader(CHUNK_URL(name))
|
||||
const factory = chunkRegistry()[name]
|
||||
if (typeof factory !== 'function') {
|
||||
throw new Error(`[dsh-better-sidebar] chunk "${name}" script did not register its factory`)
|
||||
}
|
||||
const require = await buildExternalsRequire(modules)
|
||||
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)
|
||||
loadedChunks.delete(name)
|
||||
chunkEtags.delete(name)
|
||||
})
|
||||
return task
|
||||
}
|
||||
|
||||
/**
|
||||
* 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). 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,9 @@
|
||||
/**
|
||||
* Lazy chunk entry: the code/markdown/html text editor (CodeMirror 6 + the
|
||||
* language packages). Built as `lib/client-editor.js` and registered under
|
||||
* `dsh-better-sidebar/editor` — fetched only when a text file is first
|
||||
* opened (see chunk-loader.ts and docs/plans/2026-08-12-lazy-chunks-design.md).
|
||||
* Never import this module from the core bundle: it pulls CodeMirror into
|
||||
* the startup path.
|
||||
*/
|
||||
export { TextEditor } from '../TextEditor.tsx'
|
||||
@@ -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,9 @@
|
||||
/**
|
||||
* Lazy chunk entry: the interactive terminal (xterm + fit addon). Built as
|
||||
* `lib/client-terminal.js` and registered under
|
||||
* `dsh-better-sidebar/terminal` — fetched only when a terminal tab is first
|
||||
* opened (see chunk-loader.ts and docs/plans/2026-08-12-lazy-chunks-design.md).
|
||||
* Never import this module from the core bundle: it pulls xterm (and its
|
||||
* stylesheet) into the startup path.
|
||||
*/
|
||||
export { TerminalView } from '../TerminalView.tsx'
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Append text to the current session's composer draft through the
|
||||
* conversation service — the shared path behind the explorer's @-reference
|
||||
* button and the viewer selection popup. The service is resolved lazily
|
||||
* through `ctx.get` (the inject-free read the app's own plugins use); a
|
||||
* missing service or scope degrades to a logged no-op, never a crash.
|
||||
*/
|
||||
import type { Context, SidebarConversation } from '../context-types.ts'
|
||||
|
||||
/**
|
||||
* Append `text` to the session's composer draft (space-separated, like the
|
||||
* @-mentions). Returns false — and logs — when the conversation service or
|
||||
* the session scope is unavailable.
|
||||
*/
|
||||
export function appendToDraft(ctx: Context, sessionId: string, text: string): boolean {
|
||||
try {
|
||||
const actx = ctx.sessions.scope(sessionId)
|
||||
if (actx === undefined) return false
|
||||
const conversation = ctx.get('conversation') as SidebarConversation | undefined
|
||||
if (conversation === undefined) return false
|
||||
const input = conversation.input.for(actx)
|
||||
const draft = input.state.getSnapshot().draft
|
||||
input.setDraft(draft.trim() === '' ? text : `${draft} ${text}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('[dsh-better-sidebar] draft insert failed:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Desktop-shell detection for the sidebar. Shells may stamp the render URL
|
||||
* with `dsh-desktop-mode` / `dsh-desktop-platform` (the official Electron
|
||||
* shell does) and expose `window.__DSH_DESKTOP_FILE_PATH__` through a
|
||||
* preload. Parsed once per page and memoized (the URL never changes
|
||||
* mid-session); `resetDesktopEnvForTests` clears the memo for unit tests.
|
||||
*
|
||||
* GEOMETRY POLICY: this module only REPORTS shell facts — it never decides
|
||||
* how to adapt. The strip height comes from standard signals first (the
|
||||
* Window Controls Overlay API, see wco.ts), then the documented contract
|
||||
* parameter `dsh-desktop-titlebar-inset` (a shell may stamp the real pixels
|
||||
* it reserves at the top), then the user's chosen scheme (preset / custom).
|
||||
* The legacy win32-advanced 32px constant is gone from the core: it lives
|
||||
* in the opt-in shell preset (shell-presets.ts) as a fallback for shells
|
||||
* without the WCO API.
|
||||
*/
|
||||
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
|
||||
/**
|
||||
* Contract parameter `dsh-desktop-titlebar-inset`: pixels the shell
|
||||
* reserves at the top of the web content for its own chrome (0–120,
|
||||
* clamped; 0 when absent). Standard WCO geometry takes precedence over
|
||||
* this whenever the API is available.
|
||||
*/
|
||||
readonly titlebarInset: number
|
||||
}
|
||||
|
||||
let cached: DesktopEnv | undefined
|
||||
|
||||
/** Read the shell's desktop stamps (memoized per page; SSR-safe). */
|
||||
export function parseDesktopEnv(): DesktopEnv {
|
||||
if (cached !== undefined) return cached
|
||||
const hasWindow = typeof window !== 'undefined'
|
||||
const hasPreloadMarker = hasWindow
|
||||
&& typeof (window as { __DSH_DESKTOP_FILE_PATH__?: unknown }).__DSH_DESKTOP_FILE_PATH__ !== 'undefined'
|
||||
// location.search includes the leading '?', which URLSearchParams does NOT
|
||||
// strip (it would become part of the first key) — drop it explicitly.
|
||||
// SSR (no window) resolves the empty environment: nothing desktop.
|
||||
const params = hasWindow
|
||||
? new URLSearchParams(window.location.search.replace(/^\?/, ''))
|
||||
: new URLSearchParams()
|
||||
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 || hasPreloadMarker
|
||||
cached = {
|
||||
desktop,
|
||||
mode,
|
||||
platform,
|
||||
titlebarInset: parseTitlebarInset(params.get('dsh-desktop-titlebar-inset')),
|
||||
}
|
||||
return cached
|
||||
}
|
||||
|
||||
/** Clamp the contract inset parameter into 0–120 (invalid/absent → 0). */
|
||||
function parseTitlebarInset(raw: string | null): number {
|
||||
if (raw === null) return 0
|
||||
const parsed = Number(raw)
|
||||
if (!Number.isFinite(parsed)) return 0
|
||||
return Math.min(120, Math.max(0, Math.round(parsed)))
|
||||
}
|
||||
|
||||
/** Test hook: drop the memo so the next parse re-reads the URL/globals. */
|
||||
export function resetDesktopEnvForTests(): void {
|
||||
cached = undefined
|
||||
}
|
||||
+39
-27
@@ -51,6 +51,25 @@ export const IconDiffOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
/**
|
||||
* Stop glyph for the background-job kill button: a filled square in the
|
||||
* app's outline scale (16), the universal "halt this work" mark.
|
||||
*/
|
||||
export const IconStopOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Upload glyph in the app's outline style: an arrow rising into a tray
|
||||
* (the file-manager "upload into the workspace" action). */
|
||||
export const IconUploadOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 10V2.75M4.75 5.5 8 2.25 11.25 5.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M2.75 10.5v2.25A1.25 1.25 0 0 0 4 14h8a1.25 1.25 0 0 0 1.25-1.25V10.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// ── File-viewer inventory glyphs (Side card settings page) ────────────────
|
||||
|
||||
/** Image viewer glyph: a picture frame with a sun and a mountain. */
|
||||
@@ -73,33 +92,6 @@ export const IconPdfOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Word viewer glyph: a document frame with a "W". */
|
||||
export const IconDocxOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.5 1.5h6.5L13.5 5v9.5h-10z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M9.5 1.5V5h4" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M6.2 13.4 7.4 10l1.2 3.4M7.4 10.6l-.35-1.1c-.2-.62.2-1.25.85-1.25h.2c.65 0 1.05.63.85 1.25l-.35 1.1" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M8.75 10.6 9.2 9.4" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Excel viewer glyph: a spreadsheet grid. */
|
||||
export const IconXlsxOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="2" width="13" height="12" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M1.5 6h13M1.5 9.5h13M6 6v8M10.5 6v8" stroke="currentColor" strokeWidth="1.25" />
|
||||
<path d="m3.8 13.2 2-3M5.8 13.2l-2-3" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** PowerPoint viewer glyph: a chart with rising bars. */
|
||||
export const IconPptxOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="2.5" width="13" height="11" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M4 12.5v-3M7 12.5V7M10 12.5V4.5M13 12.5v-1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Markdown viewer glyph: the classic "M with a down arrow" badge. */
|
||||
export const IconMarkdownOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -125,3 +117,23 @@ export const IconGlobeOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<path d="M1.5 8h13M8 1.5c-2.4 1.8-2.4 11.2 0 13M8 1.5c2.4 1.8 2.4 11.2 0 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** History glyph (thread switcher): a clock with a counterclockwise arrow,
|
||||
* in the app's outline style — the "past conversations" mark. */
|
||||
export const IconHistoryOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.4 6.8A5.6 5.6 0 1 1 2.4 9.2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<path d="M2.2 3.4v3.4h3.4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M8 5.4V8l1.9 1.2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Save glyph (save-as-new-session): the classic floppy disk, in the app's
|
||||
* outline style. */
|
||||
export const IconSaveOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.2 14.5h7.6a1.2 1.2 0 0 0 1.2-1.2V4.9L10.6 2.5H4.2A1.2 1.2 0 0 0 3 3.7v9.6a1.2 1.2 0 0 0 1.2 1.2z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M10 2.5v2.6H5.6V2.5" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M5.4 14.5v-4.2h5.2v4.2" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
+213
-65
@@ -8,66 +8,55 @@
|
||||
* bundle itself is a module-table consumer only (react + ui-primitives +
|
||||
* xterm, all provided or inlined).
|
||||
*/
|
||||
import { Component, createElement, type ErrorInfo, type ReactNode } from 'react'
|
||||
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 { 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 { t } from './locales.ts'
|
||||
import { LOCALE_NS, attachLocale, t, zh, en } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
import './layout.css'
|
||||
|
||||
/** Services required before mounting (provided by the client runtime). */
|
||||
export const inject = ['slots', 'sessions', 'connection', 'workspaces']
|
||||
/** Services required before mounting (provided by the client runtime; the
|
||||
* 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: a render error must never blank the
|
||||
* whole panel silently — it shows a dismissible error strip and logs the
|
||||
* stack for diagnosis.
|
||||
* Error boundary over the sidebar tree (root scope): a render error in the
|
||||
* sidebar SHELL itself must never blank the page silently — the shared
|
||||
* RenderBoundary shows a dismissible error strip and logs the stack. The
|
||||
* per-tab scope (Sidebar.tsx) catches viewer/editor crashes first; this root
|
||||
* boundary stays as the last resort for Workbench/shell errors.
|
||||
*/
|
||||
class SidebarBoundary extends Component<{ children: ReactNode }, { error: string | null }> {
|
||||
state = { error: null as string | null }
|
||||
|
||||
static getDerivedStateFromError(error: unknown): { error: string } {
|
||||
return { error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.error('[dsh-better-sidebar] render error:', error, info.componentStack)
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.error !== null) {
|
||||
return (
|
||||
<div className={css.boundaryError}>
|
||||
<span>dsh-better-sidebar: {this.state.error}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.terminalRetry}
|
||||
onClick={() => { this.setState({ error: null }) }}
|
||||
>
|
||||
{t('terminalRetry')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client plugin body.
|
||||
* @param ctx - the client cordis context (slots, sessions).
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
// The sidebar follows the DSH i18n system: attach the locale service so
|
||||
// the module-level t()/isZh() resolve the Host-backed language preference
|
||||
// (and switch live — the Sidebar root subscribes to it), and register the
|
||||
// plugin's dictionaries into the shared locale registry. The disposers
|
||||
// run on fiber disposal, so re-activation (HMR) re-registers cleanly.
|
||||
attachLocale(ctx.locale)
|
||||
ctx.effect(() => {
|
||||
const offZh = ctx.locale.register(LOCALE_NS, 'zh', zh)
|
||||
const offEn = ctx.locale.register(LOCALE_NS, 'en', en)
|
||||
return () => { offZh(); offEn() }
|
||||
}, 'dsh-better-sidebar: dictionaries')
|
||||
// One store instance per activation: production code creates it only here,
|
||||
// then hands it to the mounted panel and closes over it in the slot
|
||||
// registrations (the official createXXXStore() factory rule — no
|
||||
@@ -79,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
|
||||
@@ -103,11 +112,113 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
}
|
||||
try {
|
||||
// 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
|
||||
@@ -119,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(SidebarBoundary, null, 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')
|
||||
|
||||
@@ -163,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,
|
||||
})
|
||||
@@ -186,11 +323,12 @@ export function apply(ctx: Context): void {
|
||||
|
||||
// The IME guard: composition keys (candidate arrows, confirm, cancel)
|
||||
// belong to the input method, never to page JS. Inlined third-party UI
|
||||
// (Univer's office controls) has shipped unguarded keydown handlers that
|
||||
// hijack ArrowUp/ArrowDown and break Chinese input (#562 regression); the
|
||||
// document-capture guard neutralizes the whole class before React or any
|
||||
// native listener sees the event. Registered as early as possible so no
|
||||
// other capture-phase listener can win the ordering race.
|
||||
// (formerly Univer's office controls, #562 regression) has shipped
|
||||
// unguarded keydown handlers that hijack ArrowUp/ArrowDown and break
|
||||
// Chinese input; the document-capture guard neutralizes the whole class
|
||||
// before React or any native listener sees the event. Registered as
|
||||
// early as possible so no other capture-phase listener can win the
|
||||
// ordering race.
|
||||
ctx.effect(
|
||||
() => {
|
||||
try {
|
||||
@@ -203,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
|
||||
|
||||
@@ -57,14 +57,29 @@ export function SidebarProducedFiles(props: {
|
||||
)
|
||||
}
|
||||
|
||||
/** Register the turn-tail interception (returns the disposer). */
|
||||
/**
|
||||
* Register the turn-tail interception (returns the disposer).
|
||||
*
|
||||
* The slot is a CHILD slot the host's ui-conversation declares in its
|
||||
* `conversation.chat.node` children table (kind: chain, scope: session).
|
||||
* Registering it directly races the declaration — the ui-slots core's
|
||||
* load-time validation throws "not declared (a parent entry's children
|
||||
* table must declare it)" when the parent entry is not on the ledger yet.
|
||||
* slots.inject waits for the declaration: the callback runs synchronously
|
||||
* when the slot is already declared, otherwise it runs inside the declaring
|
||||
* register() call once the declaration commits; declaration collapse
|
||||
* disposes the entry and a later declaration re-registers it. This mirrors
|
||||
* @deepseek-ai/dsh-client-ui-deliverables' registration of the same slot.
|
||||
*/
|
||||
export function registerTurnTailInterception(ctx: Context, store: SidebarStore): () => void {
|
||||
return ctx.slots.register({
|
||||
return ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
|
||||
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)
|
||||
},
|
||||
@@ -73,7 +88,7 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
|
||||
inject: (sessionId: string) => ({
|
||||
openInSidebar: (path: string) => { openSidebarFile(ctx, store, sessionId, path) },
|
||||
}),
|
||||
}, SidebarProducedFiles)
|
||||
}, SidebarProducedFiles))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,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(),
|
||||
|
||||
+63
-13
@@ -5,12 +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. The AppFrame's grid items are
|
||||
* (#root > div > div:nth-child(2)): 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
|
||||
@@ -19,26 +29,66 @@
|
||||
*/
|
||||
#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) — 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 > div:nth-child(2) {
|
||||
/* The AppFrame's center column, anchored by data attributes (see above).
|
||||
Composite selector: DSH 0.1.x versions name the center grid item
|
||||
`[data-pane="conversation"]`, while rc.8-era shells put a
|
||||
`[data-slot="conversation"]` child inside it — both selectors resolve to
|
||||
the SAME element on live pages (verified), and keeping both future-proofs
|
||||
the rule against a host rename without touching shell-specific markup. */
|
||||
#root [data-dsh-frame] > [data-pane="conversation"],
|
||||
#root :has(> [data-slot="conversation"]) {
|
||||
margin-bottom: var(--dsh-sidebar-height, 0px);
|
||||
transition: margin-bottom var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
/* When the sidebar is collapsed, the toggle cluster reclaims the top-right
|
||||
corner. Push the DSH session header's right padding out so its right-aligned
|
||||
utilities (the "Session log" download capsule) yield the corner instead of
|
||||
hiding under the cluster. The header default right-pads 28px; the 2-button
|
||||
cluster spans right 10→70px, so 78px clears it with an 8px gap. Anchor on
|
||||
the header's slot host wrapper ([data-slot="conversation.session.header"])
|
||||
rather than a positional path: DSH 0.1.x nests the header several levels
|
||||
under the center column. The Sidebar shell toggles the body attribute with
|
||||
the panel open state. */
|
||||
body[data-dsh-sidebar-collapsed] [data-slot="conversation.session.header"] > header {
|
||||
padding-right: 78px;
|
||||
}
|
||||
|
||||
body[data-dsh-sidebar-dragging] #root,
|
||||
body[data-dsh-sidebar-dragging] #root > div > div:nth-child(2) {
|
||||
body[data-dsh-sidebar-dragging] #root [data-dsh-frame] > [data-pane="conversation"],
|
||||
body[data-dsh-sidebar-dragging] #root :has(> [data-slot="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 > div:nth-child(2) {
|
||||
#root [data-dsh-frame] > [data-pane="conversation"],
|
||||
#root :has(> [data-slot="conversation"]) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Lazy chunk view wrapper: mounts a component that lives in a lazy chunk,
|
||||
* showing a loading placeholder while the chunk script loads and an error +
|
||||
* retry affordance on failure. Used by the built-in tab/viewer descriptors.
|
||||
*
|
||||
* Contract note: {@link lazyChunkComponent} returns a plain render-prop
|
||||
* function — the descriptor contract is `component: (props) => ReactNode`,
|
||||
* and the repo renders descriptors BOTH ways: Sidebar calls
|
||||
* `descriptor.component(props)` directly, EditorHost renders it via
|
||||
* `createElement`. The wrapper function body therefore contains no hooks;
|
||||
* all state lives in the inner {@link LazyChunkView} component.
|
||||
*/
|
||||
import { createElement, useEffect, useState, type ComponentType, type ReactNode } from 'react'
|
||||
import { loadChunk, type ChunkExports, type ChunkName } from './chunk-loader.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
interface LazyChunkViewProps<P> {
|
||||
chunk: ChunkName
|
||||
/** Module-level-stable selector (an inline lambda would re-trigger the effect). */
|
||||
pick: (mod: ChunkExports) => ComponentType<P> | undefined
|
||||
props: P
|
||||
}
|
||||
|
||||
function LazyChunkView<P>({ chunk, pick, props }: LazyChunkViewProps<P>): ReactNode {
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const [state, setState] = useState<
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; message: string }
|
||||
| { status: 'ready'; Comp: ComponentType<any> }
|
||||
>({ status: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setState({ status: 'loading' })
|
||||
loadChunk(chunk).then((mod) => {
|
||||
if (cancelled) return
|
||||
const Comp = pick(mod)
|
||||
if (Comp === undefined) {
|
||||
setState({ status: 'error', message: `[dsh-better-sidebar] chunk "${chunk}" is missing its component` })
|
||||
return
|
||||
}
|
||||
setState({ status: 'ready', Comp })
|
||||
}).catch((error: unknown) => {
|
||||
if (cancelled) return
|
||||
setState({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [chunk, pick, attempt])
|
||||
|
||||
if (state.status === 'loading') {
|
||||
return <div className={css.editorPlaceholder}>{t('loading')}</div>
|
||||
}
|
||||
if (state.status === 'error') {
|
||||
return (
|
||||
<div className={css.editorError}>
|
||||
<span>{state.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.terminalRetry}
|
||||
onClick={() => { setAttempt(current => current + 1) }}
|
||||
>
|
||||
{t('terminalRetry')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return createElement(state.Comp, props)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a descriptor-compatible lazy wrapper for a chunk-resident component.
|
||||
* The returned function is the descriptor `component` itself: it returns an
|
||||
* element and never calls hooks, so both invocation styles (plain function
|
||||
* call and createElement/JSX render) work. `pick` must be a module-level
|
||||
* function (stable identity) — an inline lambda would re-trigger the load
|
||||
* effect on every render.
|
||||
* @param chunk - the chunk name (see chunk-loader.ts).
|
||||
* @param pick - select the component from the chunk's exports.
|
||||
*/
|
||||
export function lazyChunkComponent<P extends object>(
|
||||
chunk: ChunkName,
|
||||
pick: (mod: ChunkExports) => ComponentType<P> | undefined,
|
||||
): (props: P) => ReactNode {
|
||||
return (props: P) => createElement(
|
||||
LazyChunkView as ComponentType<LazyChunkViewProps<P>>,
|
||||
{ chunk, pick, props },
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+336
-43
@@ -1,17 +1,38 @@
|
||||
/**
|
||||
* Minimal zh/en copy for the sidebar. The plugin renders outside the slot
|
||||
* system's locale seat, so it reads the browser language directly (the DSH
|
||||
* locale preference and the browser language agree in practice; both default
|
||||
* from navigator.language anyway).
|
||||
* Minimal zh/en copy for the sidebar. The copy follows the DSH i18n system:
|
||||
* the client apply attaches the locale service (`ctx.locale`, provided by
|
||||
* `@deepseek-ai/dsh-client-locale`) through {@link attachLocale}, and
|
||||
* `t()`/`isZh()` resolve the active locale from it — the Host-backed
|
||||
* `locale.preference` wins over the raw browser language and switches live.
|
||||
* Without an attached service (standalone/test compositions) the browser
|
||||
* language is used, matching the previous behavior. The dictionaries are
|
||||
* also registered into the DSH locale registry under {@link LOCALE_NS}.
|
||||
*/
|
||||
|
||||
const zh = {
|
||||
/** 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)',
|
||||
@@ -23,8 +44,16 @@ 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: '已保存',
|
||||
@@ -96,10 +125,27 @@ const zh = {
|
||||
openEditor: '打开编辑器',
|
||||
gitDetail: '查看变更详情',
|
||||
referenceFile: '@文件',
|
||||
addToConversation: '添加到对话',
|
||||
copyRelative: '复制相对地址',
|
||||
copyAbsolute: '复制绝对地址',
|
||||
download: '下载',
|
||||
uploadFiles: '上传文件',
|
||||
uploadFolder: '上传文件夹',
|
||||
uploadHere: '上传到此处',
|
||||
uploadDropHint: '拖拽文件/文件夹到此处上传',
|
||||
uploadDropChat: '拖放到聊天区:添加图片到对话',
|
||||
uploadTo: '上传到 {dir}',
|
||||
uploadingTo: '正在上传到 {dir}…',
|
||||
uploadProgress: '正在上传 {done}/{total}: {name}',
|
||||
uploadDone: '已上传 {count} 个文件',
|
||||
uploadFailed: '上传失败:{error}',
|
||||
uploadFailedUnknown: '未知错误',
|
||||
uploadTooLarge: '文件过大,超出上传上限',
|
||||
uploadCancelled: '上传已取消',
|
||||
settingsNav: '侧边卡片',
|
||||
settingsIntro: '管理侧边卡片的显示内容与默认行为',
|
||||
settingsPopupDesc: '为「{feature}」配置相关选项',
|
||||
settingsDone: '完成',
|
||||
settingsOpenTitle: '新会话默认打开',
|
||||
settingsOpenDesc: '新建会话时自动展开侧边卡片;已存在的会话保持各自布局',
|
||||
settingsWidthTitle: '默认宽度占比',
|
||||
@@ -107,24 +153,44 @@ const zh = {
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: '聊天区文件在侧边栏打开',
|
||||
settingsOpenPathDesc: '在聊天里点击文件链接(工具行、产物列表、文件提及)时,在侧边栏编辑器中打开,不再调用系统默认应用',
|
||||
settingsTitleBarTitle: '位置兼容模式',
|
||||
settingsTitleBarDesc: '选择顶栏兼容方案:自动检测(默认,保守)/ DSH官方Web / 已知桌面壳 / 自定义方案(下移距离 + 自定义 CSS)',
|
||||
settingsTitleBarStripTitle: '下移距离',
|
||||
settingsTitleBarStripDesc: '标题栏条带高度:侧边栏按钮与内容下移的像素数(0–120,默认 40;自定义方案下生效)',
|
||||
settingsSchemeAutoTitle: '自动检测',
|
||||
settingsSchemeAutoDesc: '保守方案:仅在 Window Controls Overlay 标准 API 可用时按真实标题栏高度让位;网页环境下不做任何修改',
|
||||
settingsSchemeWebTitle: 'DSH官方Web',
|
||||
settingsSchemeWebDesc: '显式声明运行在官方网页版:不做任何适配(连标准 WCO 几何也不适用)',
|
||||
settingsSchemeCustomTitle: '自定义方案',
|
||||
settingsSchemeCustomDesc: '完全由你控制:注入自定义 CSS(可覆盖内置样式),并指定标题栏下移距离',
|
||||
settingsSchemeDetectedSuffix: '已检测',
|
||||
settingsCustomCssTitle: '自定义 CSS',
|
||||
settingsCustomCssDesc: '追加到页面末尾的样式(同优先级下后写胜出;覆盖 JS 内联变量需用 !important)',
|
||||
settingsCustomCssPlaceholder: '/* 例:为自绘标题栏的壳预留 36px */\nhtml[data-dsh-title-bar-height="36"] {\n --dsh-title-bar-strip: 36px !important;\n}',
|
||||
settingsSaveFailed: '保存失败',
|
||||
settingsConflict: '设置已被其他窗口修改,请重试',
|
||||
binaryNoPreview: '此文件类型不支持预览',
|
||||
downloadToView: '下载查看',
|
||||
officeTooLarge: '文件过大,无法预览',
|
||||
officeCorrupt: '文件损坏或格式无效',
|
||||
officeEncrypted: '不支持加密文件',
|
||||
officeLoadFailed: 'Office 预览组件加载失败',
|
||||
previousSlide: '上一页',
|
||||
nextSlide: '下一页',
|
||||
zoom: '缩放',
|
||||
zoomHint: 'Alt + 滚轮',
|
||||
settingsSubagentTitle: '检测到子代理时自动展开子代理页面',
|
||||
settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开子代理页面;关闭后需手动打开',
|
||||
settingsSubagentTitle: '检测到子代理时自动展开任务管理页',
|
||||
settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开任务管理页;关闭后需手动打开',
|
||||
settingsJobsTitle: '有新后台任务时自动展开后台任务页',
|
||||
settingsJobsDesc: '当前会话出现新的后台任务时,自动展开侧边栏并打开后台任务页(每个新任务都会触发);关闭后需手动打开',
|
||||
settingsToolsTitle: '为模型注入终端工具',
|
||||
settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)',
|
||||
settingsBottomTerminalTitle: '底部面板首次展开自动开终端',
|
||||
settingsBottomTerminalDesc: '每次会话中第一次展开底部面板时,尝试在底部面板自动打开一个新终端标签(终端数量上限仍会限制;默认开启)',
|
||||
settingsFontFamilyTitle: '终端字体',
|
||||
settingsFontFamilyDesc: '自定义终端字体族(CSS font-family,如 "JetBrains Mono", monospace;留空跟随主题等宽字体)',
|
||||
settingsFontFamilyPlaceholder: '"JetBrains Mono", monospace',
|
||||
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: '常规',
|
||||
@@ -132,9 +198,6 @@ const zh = {
|
||||
settingsViewerCatchAll: '兜底:任意文件',
|
||||
viewerImage: '图片',
|
||||
viewerPdf: 'PDF',
|
||||
viewerDocx: 'Word 文档',
|
||||
viewerXlsx: 'Excel 表格',
|
||||
viewerPptx: 'PPT 演示',
|
||||
viewerMarkdown: 'Markdown',
|
||||
viewerCode: '代码',
|
||||
viewerBinary: '二进制下载',
|
||||
@@ -160,13 +223,17 @@ 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 禁止在其它页面中显示,无法在侧边栏内加载。可在浏览器中直接打开',
|
||||
browserEmbedAnyway: '仍然加载',
|
||||
subagent: '子代理',
|
||||
openSubagent: '子代理',
|
||||
subagent: '任务管理',
|
||||
openSubagent: '任务管理',
|
||||
subagentMainAgent: '主代理',
|
||||
subagentEmpty: '暂无子代理',
|
||||
subagentEmptyDesc: '当前主代理派生的子代理将显示在这里',
|
||||
@@ -180,15 +247,99 @@ const zh = {
|
||||
subagentDiagUnsupported: '不支持的条目',
|
||||
subagentDiagUnavailable: '不可用',
|
||||
subagentThinking: '思考中…',
|
||||
sideChat: '侧边对话(beta)',
|
||||
sideChatNew: '新建对话',
|
||||
sideChatUntitled: '新对话',
|
||||
sideChatEmpty: '暂无侧边对话',
|
||||
sideChatEmptyDesc: '每个侧边对话是标签栏里的独立 Tab,继承当前会话的上下文运行,不会进入主会话',
|
||||
sideChatCreating: '正在创建侧边对话…',
|
||||
sideChatRetry: '重试',
|
||||
sideChatThreads: '切换线程 / 新建',
|
||||
sideChatSave: '保存为新会话',
|
||||
sideChatSaveTitle: '把该线程提升为顶层会话,出现在主会话列表中',
|
||||
sideChatSaved: '已保存为新会话',
|
||||
sideChatNoTurn: '至少完成一轮对话后才能保存',
|
||||
sideChatPendingDrop: '最后一条未完成的追问不会包含在新会话中',
|
||||
sideChatFirstPlaceholder: '输入第一个问题,已继承当前会话上下文…',
|
||||
sideChatComposerPlaceholder: '追问…',
|
||||
sideChatThinking: '正在深入…',
|
||||
sideChatThink: '思考过程',
|
||||
sideChatInjection: '已注入上下文',
|
||||
sideChatSend: '发送',
|
||||
sideChatCancel: '停止',
|
||||
sideChatCancelTitle: '中止当前回合(保留队列)',
|
||||
sideChatClose: '关闭线程',
|
||||
sideChatCloseTitle: '释放线程的 agent(历史保留)',
|
||||
sideChatError: '侧边对话出错:{message}',
|
||||
jobs: '后台任务',
|
||||
jobsCount: '{count} 个后台任务',
|
||||
jobsCountRunning: '{count} 个后台任务 · {running} 运行中',
|
||||
jobStatusRunning: '运行中',
|
||||
jobStatusStopping: '终止中',
|
||||
jobStatusCompleted: '已完成',
|
||||
jobStatusKilled: '已终止',
|
||||
jobStatusFailed: '失败',
|
||||
jobDurationSeconds: '{seconds} 秒',
|
||||
jobDurationMinutes: '{minutes} 分 {seconds} 秒',
|
||||
jobDurationHours: '{hours} 小时 {minutes} 分',
|
||||
jobViewOutput: '查看输出',
|
||||
jobHideOutput: '收起输出',
|
||||
jobNoOutput: '暂无输出',
|
||||
jobNotReadYet: '等待模型读取该任务的输出(模型执行 job_output 后,输出会显示在这里)',
|
||||
jobOutputTruncated: '输出过长,已截断显示',
|
||||
jobOutputError: '输出读取失败',
|
||||
jobKill: '终止',
|
||||
jobKillConfirm: '再次点击确认终止',
|
||||
jobKillError: '终止失败',
|
||||
addPluginsTabCard: '添加 Tab 插件',
|
||||
addPluginsTabCardDesc: '注册新的侧边栏页面',
|
||||
addPluginsViewerCard: '添加预览插件',
|
||||
addPluginsViewerCardDesc: '注册新的文件类型预览',
|
||||
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 渲染库拆出主包、按需安装',
|
||||
pluginFlowglassDesc: '实时会话流程图:三列泳道展示用户、助手与工具调用,支持并行分组、子代理支线、逐层钻取和实时状态;安装 better-sidebar 后注册原生「流镜」Tab,未安装时保留独立抽屉',
|
||||
pluginGitForgeDesc: 'better-sidebar「Git 凭据」Tab:GitHub/Gitea 等 Forge 账号库 + 按项目授权 + push 策略硬拦;token 仅存本地 secrets,不进模型上下文;提供只读 GitForge 工具与 agent HTTPS credential helper',
|
||||
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分页: 对话划选 → 右侧面板提问 → 同工作区独立追问会话(❓追问·主题):快速无思考模型压缩主对话上下文后与引文一起注入,不打断主对话;追问可嵌套、可继续、可归档',
|
||||
pluginSshTunnelDesc: 'better-sidebar「SSH 隧道」Tab:多机主机清单 + 按项目授权 + 密钥本地保管;模型工具 SSHManager(exec/SFTP/会话策略);中央交互终端与双栏 SFTP',
|
||||
pluginTurnReviewDesc: '对「刚刚这一回合」的 diff 做 Approve / Request changes 的人闸门:只审上一回合,不 fork 会话;文件按主会话/子代理/未归因分组,按文件勾选打回 + 可选评语,点文件先看回合开始快照 vs 现在的 diff。不是 /rewind',
|
||||
pluginVideoPreviewDesc: '在 better-sidebar 编辑器内联预览视频文件(.mp4/.webm/.mov/.mkv/.avi 等),自带支持 HTTP Range(206)的 /video 宿主路由,可拖动进度条、不受 20MB mediaLimit 限制',
|
||||
pluginDocsPanelDesc: 'DSH 侧边栏里的「全局文档」:全局 Markdown 笔记,任何工作区随时可读——列表点选阅读、悬浮大纲跳转、Chrome / VS Code 外部打开、代码复制,目录可配置(默认 ~/.dsh/docs)',
|
||||
}
|
||||
|
||||
const en: Record<keyof typeof zh, string> = {
|
||||
/** 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)',
|
||||
@@ -200,8 +351,16 @@ 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',
|
||||
@@ -273,10 +432,27 @@ const en: Record<keyof typeof zh, string> = {
|
||||
openEditor: 'Open editor',
|
||||
gitDetail: 'View change details',
|
||||
referenceFile: '@file',
|
||||
addToConversation: 'Add to conversation',
|
||||
copyRelative: 'Copy relative path',
|
||||
copyAbsolute: 'Copy absolute path',
|
||||
download: 'Download',
|
||||
uploadFiles: 'Upload files',
|
||||
uploadFolder: 'Upload folder',
|
||||
uploadHere: 'Upload here',
|
||||
uploadDropHint: 'Drop files/folders here to upload',
|
||||
uploadDropChat: 'Drop onto the chat to add images',
|
||||
uploadTo: 'Upload into {dir}',
|
||||
uploadingTo: 'Uploading into {dir}…',
|
||||
uploadProgress: 'Uploading {done}/{total}: {name}',
|
||||
uploadDone: 'Uploaded {count} file(s)',
|
||||
uploadFailed: 'Upload failed: {error}',
|
||||
uploadFailedUnknown: 'Unknown error',
|
||||
uploadTooLarge: 'File too large (over the upload limit)',
|
||||
uploadCancelled: 'Upload cancelled',
|
||||
settingsNav: 'Side card',
|
||||
settingsIntro: 'Manage what the side card shows and how it behaves',
|
||||
settingsPopupDesc: 'Configure related options for {feature}',
|
||||
settingsDone: 'Done',
|
||||
settingsOpenTitle: 'Open by default for new conversations',
|
||||
settingsOpenDesc: 'Expand the side card automatically for brand-new conversations; existing conversations keep their own layouts',
|
||||
settingsWidthTitle: 'Default width share',
|
||||
@@ -284,24 +460,44 @@ 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: 'Pick the title-bar compatibility scheme: auto-detect (default, conservative) / DSH official web / known desktop shells / custom (shift distance + custom CSS)',
|
||||
settingsTitleBarStripTitle: 'Shift distance',
|
||||
settingsTitleBarStripDesc: 'Title-bar strip height: how far the sidebar buttons and content move down in px (0–120, default 40; applies under the custom scheme)',
|
||||
settingsSchemeAutoTitle: 'Auto-detect',
|
||||
settingsSchemeAutoDesc: 'Conservative: only the standard Window Controls Overlay API contributes (real caption-overlay height); plain web environments get no modification',
|
||||
settingsSchemeWebTitle: 'DSH official web',
|
||||
settingsSchemeWebDesc: 'Explicitly declare the official web UI: no adaptation at all (not even standard WCO geometry)',
|
||||
settingsSchemeCustomTitle: 'Custom',
|
||||
settingsSchemeCustomDesc: 'Full control: inject custom CSS (can override built-in styles) and set the title-bar shift distance',
|
||||
settingsSchemeDetectedSuffix: 'detected',
|
||||
settingsCustomCssTitle: 'Custom CSS',
|
||||
settingsCustomCssDesc: 'Styles appended at the end of the page (later in the cascade wins ties; use !important to override JS-written inline variables)',
|
||||
settingsCustomCssPlaceholder: '/* e.g. reserve 36px for a shell with a custom-drawn title bar */\nhtml[data-dsh-title-bar-height="36"] {\n --dsh-title-bar-strip: 36px !important;\n}',
|
||||
settingsSaveFailed: 'Failed to save',
|
||||
settingsConflict: 'The setting changed in another window — please retry',
|
||||
binaryNoPreview: 'This file type cannot be previewed',
|
||||
downloadToView: 'Download to view',
|
||||
officeTooLarge: 'File too large to preview',
|
||||
officeCorrupt: 'File is corrupt or in an invalid format',
|
||||
officeEncrypted: 'Encrypted files are not supported',
|
||||
officeLoadFailed: 'Office preview component failed to load',
|
||||
previousSlide: 'Previous',
|
||||
nextSlide: 'Next',
|
||||
zoom: 'Zoom',
|
||||
zoomHint: 'Alt + wheel',
|
||||
settingsSubagentTitle: 'Auto-open the Subagent page when a subagent appears',
|
||||
settingsSubagentDesc: 'Expand the side card and open the Subagent page when the current conversation spawns a new subagent; turn off to open it manually',
|
||||
settingsSubagentTitle: 'Auto-open the Tasks page when a subagent appears',
|
||||
settingsSubagentDesc: 'Expand the side card and open the Tasks page when the current conversation spawns a new subagent; turn off to open it manually',
|
||||
settingsJobsTitle: 'Auto-open the Jobs page on a new background job',
|
||||
settingsJobsDesc: 'Expand the side card and open the Jobs page whenever a new background job appears for the current conversation (every new job triggers); turn off to open it manually',
|
||||
settingsToolsTitle: 'Inject terminal tools for the model',
|
||||
settingsToolsDesc: 'When enabled, the model can create and drive sidebar terminals through the 8 terminal_* tools (off by default)',
|
||||
settingsBottomTerminalTitle: 'Auto-open a terminal on the bottom panel\'s first expansion',
|
||||
settingsBottomTerminalDesc: 'When the bottom panel is expanded for the first time in a session, try to open a fresh terminal tab there (the terminal quota still applies; on by default)',
|
||||
settingsFontFamilyTitle: 'Terminal font family',
|
||||
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',
|
||||
settingsViewersTitle: 'File viewers',
|
||||
settingsGeneralTitle: 'General',
|
||||
@@ -309,9 +505,6 @@ const en: Record<keyof typeof zh, string> = {
|
||||
settingsViewerCatchAll: 'Catch-all: any file',
|
||||
viewerImage: 'Image',
|
||||
viewerPdf: 'PDF',
|
||||
viewerDocx: 'Word',
|
||||
viewerXlsx: 'Excel',
|
||||
viewerPptx: 'PowerPoint',
|
||||
viewerMarkdown: 'Markdown',
|
||||
viewerCode: 'Code',
|
||||
viewerBinary: 'Binary download',
|
||||
@@ -337,13 +530,17 @@ 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.',
|
||||
browserEmbedAnyway: 'Load anyway',
|
||||
subagent: 'Subagents',
|
||||
openSubagent: 'Subagents',
|
||||
subagent: 'Tasks',
|
||||
openSubagent: 'Tasks',
|
||||
subagentMainAgent: 'Main agent',
|
||||
subagentEmpty: 'No subagents',
|
||||
subagentEmptyDesc: 'Subagents spawned under the main agent will appear here',
|
||||
@@ -357,15 +554,111 @@ const en: Record<keyof typeof zh, string> = {
|
||||
subagentDiagUnsupported: 'Unsupported',
|
||||
subagentDiagUnavailable: 'Unavailable',
|
||||
subagentThinking: 'Thinking…',
|
||||
sideChat: 'Side Chat (beta)',
|
||||
sideChatNew: 'New thread',
|
||||
sideChatUntitled: 'New thread',
|
||||
sideChatEmpty: 'No side conversations',
|
||||
sideChatEmptyDesc: 'Every side conversation is its own tab in the tab strip — it inherits the current session\'s context and never enters the main conversation',
|
||||
sideChatCreating: 'Creating side conversation…',
|
||||
sideChatRetry: 'Retry',
|
||||
sideChatThreads: 'Switch thread / new',
|
||||
sideChatSave: 'Save as new session',
|
||||
sideChatSaveTitle: 'Promote this thread to a top-level session in the main session list',
|
||||
sideChatSaved: 'Saved as a new session',
|
||||
sideChatNoTurn: 'Save is available after the first completed turn',
|
||||
sideChatPendingDrop: 'The last unanswered follow-up will not be included in the saved session',
|
||||
sideChatFirstPlaceholder: 'Ask the first question — context inherited…',
|
||||
sideChatComposerPlaceholder: 'Ask a follow-up…',
|
||||
sideChatThinking: 'Deep diving…',
|
||||
sideChatThink: 'Thinking',
|
||||
sideChatInjection: 'Context injected',
|
||||
sideChatSend: 'Send',
|
||||
sideChatCancel: 'Stop',
|
||||
sideChatCancelTitle: 'Abort the running turn (queued work is kept)',
|
||||
sideChatClose: 'Close thread',
|
||||
sideChatCloseTitle: 'Release the thread\'s agent (history is kept)',
|
||||
sideChatError: 'Side Chat error: {message}',
|
||||
jobs: 'Background jobs',
|
||||
jobsCount: '{count} background jobs',
|
||||
jobsCountRunning: '{count} background jobs · {running} running',
|
||||
jobStatusRunning: 'Running',
|
||||
jobStatusStopping: 'Stopping',
|
||||
jobStatusCompleted: 'Completed',
|
||||
jobStatusKilled: 'Killed',
|
||||
jobStatusFailed: 'Failed',
|
||||
jobDurationSeconds: '{seconds}s',
|
||||
jobDurationMinutes: '{minutes}m {seconds}s',
|
||||
jobDurationHours: '{hours}h {minutes}m',
|
||||
jobViewOutput: 'View output',
|
||||
jobHideOutput: 'Hide output',
|
||||
jobNoOutput: 'No output yet',
|
||||
jobNotReadYet: 'Waiting for the model to read this job; its output appears here once the model runs job_output',
|
||||
jobOutputTruncated: 'Output truncated',
|
||||
jobOutputError: 'Failed to read output',
|
||||
jobKill: 'Kill',
|
||||
jobKillConfirm: 'Click again to confirm kill',
|
||||
jobKillError: 'Kill failed',
|
||||
addPluginsTabCard: 'Add tab plugins',
|
||||
addPluginsTabCardDesc: 'Register a new sidebar page',
|
||||
addPluginsViewerCard: 'Add preview plugins',
|
||||
addPluginsViewerCardDesc: 'Register a file-type preview',
|
||||
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',
|
||||
pluginFlowglassDesc: 'Live session flowgraph with three lanes for user, assistant, and tool calls, plus parallel groups, sub-agent branches, drill-down, and live status; registers a native Flowglass tab when better-sidebar is installed and keeps its standalone drawer as a fallback',
|
||||
pluginGitForgeDesc: 'Git Forge tab: GitHub/Gitea (and other forge) account library + per-project grants + hard push policy; tokens stay in local secrets (never in model context); read-only GitForge tool and agent HTTPS credential helper',
|
||||
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',
|
||||
pluginSshTunnelDesc: 'SSH Tunnel tab: multi-host inventory + per-project grants + local secrets; SSHManager tool (exec/SFTP/session strategies); center interactive terminal and dual-pane SFTP',
|
||||
pluginTurnReviewDesc: 'A human gate on the just-finished turn: Approve / Request changes per path with an optional comment; paths grouped by main session / subagent / unattributed; inline snapshot-vs-now diff before you decide. No fork, no /rewind',
|
||||
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',
|
||||
pluginDocsPanelDesc: 'Global docs in the DSH sidebar: read your own Markdown notes from any workspace — a file list, an outline, open in Chrome / VS Code, and copy buttons; the docs directory is configurable (default ~/.dsh/docs)',
|
||||
}
|
||||
|
||||
/** Translate a copy key in the browser's language (zh → zh, else en). */
|
||||
/**
|
||||
* The dictionary namespace this plugin owns in the DSH locale registry
|
||||
* (`'sidebar'` is taken by DSH's own ui-sidebar, hence this distinct name).
|
||||
*/
|
||||
export const LOCALE_NS = 'betterSidebar'
|
||||
|
||||
/** The DSH locale service attached by the client apply (absent → browser detection). */
|
||||
let localeService: { getSnapshot(): { active: string } } | undefined
|
||||
|
||||
/**
|
||||
* Attach (or detach, with undefined) the DSH locale service. The sidebar
|
||||
* mounts its own React root outside the slot system's locale seat, so the
|
||||
* service rides this module-level holder: components keep calling the plain
|
||||
* `t()` function, and the Sidebar root's locale subscription re-renders the
|
||||
* whole tree on switches.
|
||||
*/
|
||||
export function attachLocale(service: { getSnapshot(): { active: string } } | undefined): void {
|
||||
localeService = service
|
||||
}
|
||||
|
||||
/**
|
||||
* The active locale id ('zh' | 'en'): the DSH locale service's snapshot when
|
||||
* attached, else the browser language.
|
||||
*/
|
||||
function activeLocale(): string {
|
||||
return localeService?.getSnapshot().active
|
||||
?? (typeof navigator !== 'undefined' ? navigator.language : '')
|
||||
?? 'en'
|
||||
}
|
||||
|
||||
/** Translate a copy key in the active locale (zh → zh, else en). */
|
||||
export type CopyKey = keyof typeof zh
|
||||
|
||||
/** Translate a copy key; `{name}` placeholders interpolate from `params`. */
|
||||
export function t(key: CopyKey, params?: Record<string, string | number>): string {
|
||||
const lang = typeof navigator !== 'undefined' ? navigator.language : 'en'
|
||||
const dict = lang.toLowerCase().startsWith('zh') ? zh : en
|
||||
const dict = activeLocale().toLowerCase().startsWith('zh') ? zh : en
|
||||
let text = dict[key]
|
||||
if (params !== undefined) {
|
||||
for (const [name, value] of Object.entries(params)) {
|
||||
@@ -375,9 +668,9 @@ export function t(key: CopyKey, params?: Record<string, string | number>): strin
|
||||
return text
|
||||
}
|
||||
|
||||
/** Whether the browser language is Chinese (used for selectors). */
|
||||
/** Whether the active locale is Chinese (used for selectors). */
|
||||
export function isZh(): boolean {
|
||||
return typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('zh')
|
||||
return activeLocale().toLowerCase().startsWith('zh')
|
||||
}
|
||||
|
||||
/** Format an ISO 8601 author date relative to now (刚刚 / N 分钟前 / N 小时前 / 昨天 / date). */
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* Office file type dispatch — pure functions mapping file extensions to
|
||||
* preview kinds. Kept separate from the editor host so the mapping is
|
||||
* unit-testable without mounting React or pulling in docx-preview / Univer.
|
||||
*
|
||||
* The host side never inspects Office extensions: the binary NUL probe in
|
||||
* {@link ../index.ts} (readText) tags every Office file as binary, and the
|
||||
* /sidebar/file media route serves raw bytes for any extension. This module
|
||||
* is the client's sole source of truth for "is this previewable here".
|
||||
*/
|
||||
|
||||
/** Extensions rendered with docx-preview (preserved styles/images/tables). */
|
||||
export const OFFICE_DOCX_EXT = '.docx' as const
|
||||
|
||||
/** Extensions rendered with Univer (sheets: data + formulas + formatting). */
|
||||
export const OFFICE_XLSX_EXT = '.xlsx' as const
|
||||
|
||||
/** Extensions rendered with the browser-native PPTX viewer. */
|
||||
export const OFFICE_PPTX_EXT = '.pptx' as const
|
||||
|
||||
/** Extensions that get a download-only affordance (no client-side renderer). */
|
||||
export const OFFICE_DOWNLOAD_ONLY_EXT = ['.doc', '.xls', '.ppt'] as readonly string[]
|
||||
|
||||
/**
|
||||
* The preview strategy for one path's extension.
|
||||
* - `'docx'` — render via docx-preview.
|
||||
* - `'xlsx'` — render via Univer.
|
||||
* - `'pptx'` — render via pptx-renderer.
|
||||
* - `'download-only'` — show the binary download button (no preview renderer
|
||||
* covers legacy OLE .doc/.xls/.ppt on the client).
|
||||
* - `null` — not an Office file; the caller's existing image / md / text / binary
|
||||
* pipeline handles it.
|
||||
*/
|
||||
export type OfficeKind = 'docx' | 'xlsx' | 'pptx' | 'download-only' | null
|
||||
|
||||
/**
|
||||
* Map a lowercased extension (with leading dot) to its preview kind.
|
||||
*
|
||||
* @param ext - the path extension (with leading dot) (`.docx`,
|
||||
* `.xlsx`, `.pptx`, `.doc`, `.xls`, `.ppt`, …); `''` when the path has none.
|
||||
*/
|
||||
export function officeKindForExt(ext: string): OfficeKind {
|
||||
if (ext === OFFICE_DOCX_EXT) return 'docx'
|
||||
if (ext === OFFICE_XLSX_EXT) return 'xlsx'
|
||||
if (ext === OFFICE_PPTX_EXT) return 'pptx'
|
||||
if (OFFICE_DOWNLOAD_ONLY_EXT.includes(ext)) return 'download-only'
|
||||
return null
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
/**
|
||||
* Office preview components: {@link DocxView} renders .docx via docx-preview
|
||||
* (preserved styles/images/tables), {@link XlsxView} renders .xlsx via the
|
||||
* Univer sheets preset (data + formulas + formatting). Both load the file
|
||||
* bytes through the existing /sidebar/file media route and own their library
|
||||
* lifecycle (dispose on unmount so canvases/workers don't leak — mirrors the
|
||||
* TerminalView dispose discipline).
|
||||
*
|
||||
* Errors degrade to the same download-button affordance the binary
|
||||
* placeholder uses, so a corrupted / encrypted / oversized file always
|
||||
* leaves the user with a way to get the file.
|
||||
*
|
||||
* Both libraries pull in several MB; tsdown's `codeSplitting: false` inlines
|
||||
* them into the single client bundle (the CJS __ModuleLoader__ wrapper's
|
||||
* `require` cannot load relative chunk URLs in the browser, so dynamic
|
||||
* import() collapses to a synchronous inline — see tsdown.config.ts).
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { downloadUrl, mediaUrl, type SessionScope } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { xlsxWorkbookToUniver } from './xlsx-to-univer.ts'
|
||||
import css from './sidebar.module.css'
|
||||
// Univer's stylesheets ride the same dsh-css-inline pipeline as xterm's CSS
|
||||
// (one <style data-plugin-css> tag, idempotent). Static import so the styles
|
||||
// are present before the first .xlsx opens.
|
||||
import '@univerjs/preset-sheets-core/lib/index.css'
|
||||
|
||||
/** Loading / ready / error state shared by both views. */
|
||||
type LoadState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'ready' }
|
||||
| { status: 'error'; message: string }
|
||||
|
||||
/** Shared props. */
|
||||
interface OfficeViewProps {
|
||||
scope: SessionScope
|
||||
path: string
|
||||
title: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a .docx file via docx-preview. The library renders into a container
|
||||
* div (no canvas); images and styles are inlined. Unmounting clears the
|
||||
* container's innerHTML — docx-preview has no dispose API, but tearing down
|
||||
* the DOM is enough.
|
||||
*/
|
||||
export function DocxView(props: OfficeViewProps): JSX.Element {
|
||||
const { scope, path, title } = props
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
|
||||
const [zoom, setZoom] = useState(100)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const container = viewportRef.current
|
||||
const wrap = wrapRef.current
|
||||
if (container === null || wrap === null) return
|
||||
setZoom(100)
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(mediaUrl(scope, path))
|
||||
if (cancelled) return
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const buf = await response.arrayBuffer()
|
||||
if (cancelled) return
|
||||
// docx-preview ships its own CSS through the className option; the
|
||||
// wrapper div scopes its render output.
|
||||
const { renderAsync } = await import('docx-preview')
|
||||
await renderAsync(buf, wrap, undefined, {
|
||||
className: 'docx',
|
||||
inWrapper: true,
|
||||
ignoreWidth: false,
|
||||
ignoreHeight: false,
|
||||
breakPages: true,
|
||||
experimental: false,
|
||||
})
|
||||
if (!cancelled) setLoad({ status: 'ready' })
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
// Tear down the rendered DOM so a reopen starts clean.
|
||||
if (wrap !== null) wrap.innerHTML = ''
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current
|
||||
if (viewport === null) return
|
||||
const onWheel = (event: WheelEvent): void => {
|
||||
if (!event.altKey) return
|
||||
event.preventDefault()
|
||||
const delta = event.deltaY < 0 ? 10 : -10
|
||||
setZoom(current => Math.max(50, Math.min(200, current + delta)))
|
||||
}
|
||||
viewport.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => { viewport.removeEventListener('wheel', onWheel) }
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={css.editorDocx}>
|
||||
<div className={css.editorDocxViewport} ref={viewportRef}>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <BinaryFallback scope={scope} path={path} message={load.message} />}
|
||||
{load.status !== 'error' && (
|
||||
<div
|
||||
className={css.editorDocxWrap}
|
||||
ref={wrapRef}
|
||||
aria-label={title}
|
||||
style={{ zoom: zoom / 100 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={css.editorDocxZoom}>
|
||||
<span className={css.editorDocxZoomHint}>{t('zoomHint')}</span>
|
||||
<input
|
||||
className={css.editorDocxZoomRange}
|
||||
type="range"
|
||||
min={50}
|
||||
max={200}
|
||||
step={10}
|
||||
value={zoom}
|
||||
aria-label={t('zoom')}
|
||||
onChange={(event) => { setZoom(Number(event.currentTarget.value)) }}
|
||||
/>
|
||||
<span className={css.editorDocxZoomValue}>{zoom}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a .xlsx file via Univer. The sheets preset creates a canvas-based
|
||||
* spreadsheet (formula bar, sheet tabs, formula engine) sized to its
|
||||
* container, so the host fills the pane. Unmounting calls `univer.dispose()`
|
||||
* — without it the canvas, workers, and DOM listeners leak (mirrors the
|
||||
* xterm dispose discipline in TerminalView).
|
||||
*/
|
||||
export function XlsxView(props: OfficeViewProps): JSX.Element {
|
||||
const { scope, path, title } = props
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const univerRef = useRef<{ dispose: () => void } | null>(null)
|
||||
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const host = hostRef.current
|
||||
if (host === null) return
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(mediaUrl(scope, path))
|
||||
if (cancelled) return
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const buf = await response.arrayBuffer()
|
||||
if (cancelled) return
|
||||
|
||||
// Dynamic imports — collapsed into the bundle by codeSplitting:false.
|
||||
// The dynamic form keeps the source readable: each lib is only pulled
|
||||
// in when an .xlsx is actually opened (semantically; the bundle still
|
||||
// contains all of them).
|
||||
const XLSX = await import('xlsx')
|
||||
const { createUniver, LocaleType, mergeLocales } = await import('@univerjs/presets')
|
||||
const { UniverSheetsCorePreset } = await import('@univerjs/preset-sheets-core')
|
||||
// Locales pick the browser language; falls back to en-US.
|
||||
const isZh = typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('zh')
|
||||
const localePack = await (isZh
|
||||
? import('@univerjs/preset-sheets-core/locales/zh-CN').then(m => m.default).catch(() => null)
|
||||
: import('@univerjs/preset-sheets-core/locales/en-US').then(m => m.default).catch(() => null))
|
||||
|
||||
const wb = XLSX.read(buf, { type: 'array' })
|
||||
const locale = isZh ? LocaleType.ZH_CN : LocaleType.EN_US
|
||||
const workbookData = xlsxWorkbookToUniver(wb, '0.25.1', locale)
|
||||
|
||||
if (cancelled) return
|
||||
const { univer, univerAPI } = createUniver({
|
||||
locale,
|
||||
locales: localePack !== null ? { [locale]: mergeLocales(localePack) } : {},
|
||||
presets: [UniverSheetsCorePreset({ container: host })],
|
||||
})
|
||||
univerRef.current = univer
|
||||
univerAPI.createWorkbook(workbookData)
|
||||
if (!cancelled) setLoad({ status: 'ready' })
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
try {
|
||||
univerRef.current?.dispose()
|
||||
} catch {
|
||||
// Partially initialized instance — ignore disposal errors.
|
||||
}
|
||||
univerRef.current = null
|
||||
host.innerHTML = ''
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
// Critical: dispose the Univer instance (canvas + workers + listeners).
|
||||
try {
|
||||
univerRef.current?.dispose()
|
||||
} catch {
|
||||
// Already torn down — ignore.
|
||||
}
|
||||
univerRef.current = null
|
||||
// Clear the host in case dispose left DOM behind.
|
||||
if (host !== null) host.innerHTML = ''
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
return (
|
||||
<div className={css.editorXlsx} aria-label={title}>
|
||||
{/* Univer exclusively owns this element's descendants. React only owns
|
||||
the sibling overlay, so Univer cannot remove a React child. */}
|
||||
<div className={css.editorUniverHost} ref={hostRef} />
|
||||
{load.status !== 'ready' && (
|
||||
<div className={css.editorOfficeOverlay}>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <BinaryFallback scope={scope} path={path} message={load.message} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared error / fallback affordance: the failure reason plus a download
|
||||
* link, so the user always has a path to the file. Used by both DocxView and
|
||||
* XlsxView, and matches the binary placeholder download link.
|
||||
*/
|
||||
function BinaryFallback(props: { scope: SessionScope; path: string; message: string }): JSX.Element {
|
||||
const { scope, path, message } = props
|
||||
return (
|
||||
<div className={css.editorBinary}>
|
||||
<span className={css.editorBinaryNotice}>{message}</span>
|
||||
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||
{t('downloadToView')}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Deferred one-shot open for hosts that may not have a real size yet.
|
||||
*
|
||||
* xterm's `Terminal.open()` must not run in a zero-size container: the
|
||||
* renderer creation fails there (the DomRenderer is built from the host's
|
||||
* dimensions), leaving the render service's renderer `undefined`, and the
|
||||
* next Viewport refresh crashes reading `.dimensions` off it. WebKit-based
|
||||
* hosts (WKWebView) reliably report zero while the bottom panel's expand
|
||||
* slide is in flight; any `display:none`-hidden ancestor does the same.
|
||||
*
|
||||
* The caller's `open` callback (open + fit + resize) is invoked exactly
|
||||
* once, on the first frame where the host reports a real size. While the
|
||||
* host stays zero-sized the polling continues every frame; it stops when
|
||||
* the host leaves the document (`isConnected`), so a pending open never
|
||||
* fires after unmount. The returned cancel function drops a pending frame
|
||||
* immediately (idempotent).
|
||||
*
|
||||
* `raf`/`caf` are injectable so tests can drive the polling deterministically.
|
||||
*/
|
||||
export function openWhenSized(
|
||||
host: HTMLElement,
|
||||
open: () => void,
|
||||
raf: (cb: FrameRequestCallback) => number = requestAnimationFrame,
|
||||
caf: (id: number) => void = cancelAnimationFrame,
|
||||
): () => void {
|
||||
let frame: number | null = null
|
||||
const step = (): void => {
|
||||
frame = null
|
||||
if (!host.isConnected) return
|
||||
if (host.clientWidth > 0 && host.clientHeight > 0) {
|
||||
open()
|
||||
return
|
||||
}
|
||||
frame = raf(step)
|
||||
}
|
||||
frame = raf(step)
|
||||
return () => {
|
||||
if (frame !== null) {
|
||||
caf(frame)
|
||||
frame = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Shared vocabulary of the recommended plugin catalogs: the entry shape and
|
||||
* the GitHub topic URL. The two catalogs live in sibling modules —
|
||||
* `plugins-tabs.ts` (tab registrations) and `plugins-viewers.ts` (file
|
||||
* previewer registrations) — and are shown in the two "add plugin" modals
|
||||
* (Side card settings → the dashed cards at the end of the 侧边栏内容 /
|
||||
* 文件预览 grids).
|
||||
*/
|
||||
|
||||
/** The GitHub topic page listing every repo tagged `dsh-better-sidebar`. */
|
||||
export const PLUGIN_TOPIC_URL = 'https://github.com/topics/dsh-better-sidebar'
|
||||
|
||||
/** One curated plugin entry (name / url / description / install script). */
|
||||
export interface PluginEntry {
|
||||
/** Unique id (the npm package name). */
|
||||
id: string
|
||||
/** Short display name. */
|
||||
name: string
|
||||
/** GitHub repository URL. */
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* The built-in catalog of TAB-registration plugins (sidebar pages),
|
||||
* shown in the "add tab plugin" modal (Side card settings → 侧边栏内容 grid
|
||||
* → the dashed card). Adding an entry: append one object here (unique
|
||||
* `id` = npm package name, `url` = GitHub repo, `description` =
|
||||
* i18n-friendly (add a `pluginXxxDesc` key in locales.ts), `install` = the
|
||||
* full one-line install script — it starts with `cd ~/.dsh` so the install
|
||||
* runs with the DSH home as the working directory). Data integrity is
|
||||
* guarded by `tests/plugin-list.spec.ts`.
|
||||
*/
|
||||
import { t } from './locales.ts'
|
||||
import type { PluginEntry } from './plugins-shared.ts'
|
||||
|
||||
/** Tab-registration plugins (alphabetical order). */
|
||||
export const builtinTabPlugins: readonly PluginEntry[] = [
|
||||
{
|
||||
id: '@dsh-external/dsh-sentinel',
|
||||
name: 'dsh-sentinel 唤醒系统',
|
||||
url: 'https://github.com/fuhefei/dsh-sentinel',
|
||||
description: () => t('pluginSentinelDesc'),
|
||||
// The official one-line bundle-channel install (git source, build
|
||||
// artifacts committed — no build step needed). The `github:…` form is
|
||||
// the upstream's documented command, `cd ~/.dsh` keeps the profile
|
||||
// context consistent with the other entries.
|
||||
install: 'cd ~/.dsh && dsh plugin --profile web add "github:fuhefei/dsh-sentinel#v0.7.0"',
|
||||
},
|
||||
{
|
||||
id: 'dsh-docs-panel',
|
||||
name: 'dsh-docs-panel 全局文档',
|
||||
url: 'https://github.com/mlosun/dsh-docs-panel',
|
||||
description: () => t('pluginDocsPanelDesc'),
|
||||
// dsh-docs-panel 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 dsh-docs-panel',
|
||||
},
|
||||
{
|
||||
id: 'dsh-flowglass',
|
||||
name: 'dsh-flowglass 流镜',
|
||||
url: 'https://github.com/Iwctwbh/dsh-flowglass',
|
||||
description: () => t('pluginFlowglassDesc'),
|
||||
// Flowglass keeps its standalone drawer as a fallback and registers the
|
||||
// native tab automatically when better-sidebar is present.
|
||||
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-better-sidebar && dsh plugin --profile web add dsh-flowglass',
|
||||
},
|
||||
{
|
||||
id: 'dsh-git-forge',
|
||||
name: 'dsh-git-forge Git 凭据',
|
||||
url: 'https://github.com/thirsty5034/dsh-git-forge',
|
||||
description: () => t('pluginGitForgeDesc'),
|
||||
// Peer-depends on dsh-better-sidebar (Git Forge tab). Install the
|
||||
// prerequisite first; package is GitHub-sourced until npm publish.
|
||||
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-better-sidebar && dsh plugin --profile web add "dsh-git-forge@github:thirsty5034/dsh-git-forge"',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
id: 'dsh-ssh-tunnel',
|
||||
name: 'dsh-ssh-tunnel SSH 隧道',
|
||||
url: 'https://github.com/thirsty5034/dsh-ssh-tunnel',
|
||||
description: () => t('pluginSshTunnelDesc'),
|
||||
// Peer-depends on dsh-better-sidebar (SSH Tunnel tab + center terminal/SFTP).
|
||||
// Install the prerequisite first; package is GitHub-sourced until npm publish.
|
||||
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-better-sidebar && dsh plugin --profile web add "dsh-ssh-tunnel@github:thirsty5034/dsh-ssh-tunnel"',
|
||||
},
|
||||
{
|
||||
id: 'dsh-turn-review',
|
||||
name: 'dsh-turn-review 本轮审查',
|
||||
url: 'https://github.com/yq04/dsh-turn-review',
|
||||
description: () => t('pluginTurnReviewDesc'),
|
||||
// Needs dsh-better-sidebar (optional peer) for the tab; no model tools.
|
||||
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-better-sidebar && dsh plugin --profile web add git+https://github.com/yq04/dsh-turn-review.git',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* The built-in catalog of FILE-PREVIEWER plugins (file-type previewers),
|
||||
* shown in the "add preview plugin" modal (Side card settings → 文件预览
|
||||
* grid → the dashed card). Adding an entry: append one object here (unique
|
||||
* `id` = npm package name, `url` = GitHub repo, `description` =
|
||||
* i18n-friendly, `install` = the full shell command pre-filled into the
|
||||
* install terminal — it starts with `cd ~/.dsh` so the install runs with
|
||||
* the DSH home as the working directory). Data integrity is guarded by
|
||||
* `tests/plugin-list.spec.ts`.
|
||||
*/
|
||||
import { t } from './locales.ts'
|
||||
import type { PluginEntry } from './plugins-shared.ts'
|
||||
|
||||
/** File-previewer plugins (alphabetical order). */
|
||||
export const builtinViewerPlugins: readonly PluginEntry[] = [
|
||||
{
|
||||
id: '@huanlin/dsh-plugin-better-sidebar-plugin-office',
|
||||
name: 'Office 预览插件',
|
||||
url: 'https://github.com/HuanLinOTO/dsh-plugin-better-sidebar-plugin-office',
|
||||
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',
|
||||
},
|
||||
]
|
||||
+112
-2
@@ -10,13 +10,25 @@
|
||||
*/
|
||||
import type { api } from './api.ts'
|
||||
import {
|
||||
clampTerminalFontSize,
|
||||
clampTitleBarStrip,
|
||||
clampWidthPercent,
|
||||
SIDEBAR_PREFS_DEFAULTS,
|
||||
TITLE_BAR_SCHEMES,
|
||||
TITLE_BAR_STRIP_DEFAULT,
|
||||
type SidebarPrefs,
|
||||
type TitleBarScheme,
|
||||
} from '../prefs-shared.ts'
|
||||
|
||||
export { SIDEBAR_PREFS_DEFAULTS, clampWidthPercent }
|
||||
export type { SidebarPrefs }
|
||||
export {
|
||||
SIDEBAR_PREFS_DEFAULTS,
|
||||
TITLE_BAR_SCHEMES,
|
||||
TITLE_BAR_STRIP_DEFAULT,
|
||||
clampTerminalFontSize,
|
||||
clampTitleBarStrip,
|
||||
clampWidthPercent,
|
||||
}
|
||||
export type { SidebarPrefs, TitleBarScheme }
|
||||
|
||||
/** The settings wire face the preferences need (a subset of the plugin api). */
|
||||
export type SidebarSettingsClient = Pick<typeof api, 'settingsGet' | 'settingsUpdate'>
|
||||
@@ -40,15 +52,55 @@ export function parsePrefs(value: unknown): SidebarPrefs {
|
||||
autoOpenSubagent: typeof record.autoOpenSubagent === 'boolean'
|
||||
? record.autoOpenSubagent
|
||||
: SIDEBAR_PREFS_DEFAULTS.autoOpenSubagent,
|
||||
autoOpenJobs: typeof record.autoOpenJobs === 'boolean'
|
||||
? record.autoOpenJobs
|
||||
: SIDEBAR_PREFS_DEFAULTS.autoOpenJobs,
|
||||
agentTerminalTools: typeof record.agentTerminalTools === 'boolean'
|
||||
? record.agentTerminalTools
|
||||
: SIDEBAR_PREFS_DEFAULTS.agentTerminalTools,
|
||||
bottomPanelAutoTerminal: typeof record.bottomPanelAutoTerminal === 'boolean'
|
||||
? record.bottomPanelAutoTerminal
|
||||
: SIDEBAR_PREFS_DEFAULTS.bottomPanelAutoTerminal,
|
||||
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,
|
||||
// The title-bar scheme (auto | web | preset | custom). The schema
|
||||
// declares the field WITHOUT a default, so documents written by older
|
||||
// plugin versions resolve without it — migrate from the legacy fields:
|
||||
// a document that ALREADY HAS VALUES (the manual compat flag on, or a
|
||||
// non-default strip px — both only reachable through the old gear
|
||||
// popup) maps to the `custom` scheme so the user's numbers keep
|
||||
// working; a pristine document keeps the conservative `auto` scheme.
|
||||
titleBarScheme: isTitleBarScheme(record.titleBarScheme)
|
||||
? record.titleBarScheme
|
||||
: (record.titleBarCompat === true || hasLegacyStripValue(record.titleBarStripPx) ? 'custom' : 'auto'),
|
||||
titleBarPresetId: typeof record.titleBarPresetId === 'string'
|
||||
? record.titleBarPresetId
|
||||
: SIDEBAR_PREFS_DEFAULTS.titleBarPresetId,
|
||||
customCss: typeof record.customCss === 'string'
|
||||
? record.customCss
|
||||
: SIDEBAR_PREFS_DEFAULTS.customCss,
|
||||
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,
|
||||
@@ -61,11 +113,35 @@ 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),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the plugin-owned settings map (v0.12.0+): `{ descriptorId: { key:
|
||||
* value } }`, nested open maps. Any non-object value (or a malformed whole)
|
||||
* falls back to the empty map — the schema defaults already guard the wire
|
||||
* shape, this is the client's second line.
|
||||
*/
|
||||
function pluginSettingsMapOf(value: unknown): Record<string, Record<string, unknown>> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}
|
||||
const out: Record<string, Record<string, unknown>> = {}
|
||||
for (const [id, blob] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (blob !== null && typeof blob === 'object' && !Array.isArray(blob)) {
|
||||
out[id] = blob as Record<string, unknown>
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one enable-switch map (per-tab / per-viewer). Only boolean values
|
||||
* survive; a non-object or a non-boolean entry falls back to the empty map /
|
||||
@@ -80,6 +156,21 @@ function booleanMapOf(value: unknown): Record<string, boolean> {
|
||||
return out
|
||||
}
|
||||
|
||||
/** Type guard for the title-bar scheme union (anything else falls back). */
|
||||
function isTitleBarScheme(value: unknown): value is TitleBarScheme {
|
||||
return typeof value === 'string' && (TITLE_BAR_SCHEMES as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the legacy document carries an explicit strip value (only
|
||||
* reachable through the old gear popup): a stored number different from the
|
||||
* default counts as "the user already configured something" and migrates to
|
||||
* the `custom` scheme.
|
||||
*/
|
||||
function hasLegacyStripValue(value: unknown): boolean {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value !== TITLE_BAR_STRIP_DEFAULT
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the resolved side card preferences through the plugin's settings route.
|
||||
* @param settings - the settings wire face (the plugin api by default).
|
||||
@@ -95,3 +186,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('\\') ? '\\' : '/'
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Pure payload builders for the "add selection to conversation" popup in the
|
||||
* text viewers (markdown preview + the catch-all code viewer). Everything
|
||||
* here is string math — no React, no ctx — so the unit tests cover it
|
||||
* directly.
|
||||
*
|
||||
* Insert shape (agreed with the product owner):
|
||||
* - Selection ≤ SELECTION_LIMIT characters: a fenced code block whose info
|
||||
* line is `相对路径:起止行` and whose body is the selected text.
|
||||
* - Selection over the limit: a single plain-text line `相对路径:起止行`
|
||||
* (no fence, no content).
|
||||
* - The path is relative to the session cwd (the same projection the
|
||||
* explorer's @ button uses); an unknown cwd falls back to the absolute
|
||||
* path.
|
||||
* - Line numbers: single-line selections write `path:12`, multi-line write
|
||||
* `path:12-15`. The markdown preview cannot map rendered DOM back to
|
||||
* source lines directly, so it reverse-searches the selected text in the
|
||||
* source and only reports lines on an unambiguous hit (see
|
||||
* {@link linesOfSelection}).
|
||||
*/
|
||||
import { relativeTo } from './paths.ts'
|
||||
|
||||
/** Max inserted selection length (UTF-16 code units, i.e. JS `.length`). */
|
||||
export const SELECTION_LIMIT = 500
|
||||
|
||||
/** The source line span a selection maps to (1-based, inclusive). */
|
||||
export interface SelectionLines {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The fence info line: `rel[:start[-end]]` — lines are omitted entirely
|
||||
* when unknown (the preview reverse-search missed).
|
||||
*/
|
||||
export function headerOf(path: string, cwd: string | undefined, lines?: SelectionLines): string {
|
||||
const rel = cwd !== undefined ? relativeTo(cwd, path) : path
|
||||
if (lines === undefined) return rel
|
||||
if (lines.end > lines.start) return `${rel}:${lines.start}-${lines.end}`
|
||||
return `${rel}:${lines.start}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The full text appended to the composer draft for one selection.
|
||||
* Over the limit the content is dropped: the plain path line is the whole
|
||||
* payload (an empty fenced block would just occupy the draft).
|
||||
*/
|
||||
export function buildSelectionInsert(
|
||||
path: string,
|
||||
cwd: string | undefined,
|
||||
lines: SelectionLines | undefined,
|
||||
selected: string,
|
||||
): string {
|
||||
const header = headerOf(path, cwd, lines)
|
||||
if (selected.length > SELECTION_LIMIT) return header
|
||||
return `\`\`\`${header}\n${selected}\n\`\`\``
|
||||
}
|
||||
|
||||
/** 1-based line number of a character index in a text. */
|
||||
function lineAt(source: string, index: number): number {
|
||||
let line = 1
|
||||
for (let i = 0; i < index && i < source.length; i++) {
|
||||
if (source[i] === '\n') line++
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse-map a rendered-DOM selection back to source line numbers. The
|
||||
* preview selection is plain text (block boundaries come out as `\n`), so
|
||||
* this is a best-effort substring search: a single trailing newline is
|
||||
* stripped first (DOM block selections tend to carry one), and only an
|
||||
* EXACTLY-ONE occurrence yields lines — an ambiguous or missing match
|
||||
* returns null (the header then carries the path without line numbers).
|
||||
*/
|
||||
export function linesOfSelection(source: string, selected: string): SelectionLines | null {
|
||||
const text = selected.endsWith('\n') ? selected.slice(0, -1) : selected
|
||||
if (text === '') return null
|
||||
const at = source.indexOf(text)
|
||||
if (at === -1) return null
|
||||
if (source.indexOf(text, at + 1) !== -1) return null
|
||||
return {
|
||||
start: lineAt(source, at),
|
||||
end: lineAt(source, at + Math.max(text.length - 1, 0)),
|
||||
}
|
||||
}
|
||||
+467
-27
@@ -22,14 +22,58 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import {
|
||||
activateTab, allLeaves, closeTab as closeTabReducer, openTabInActivePane, patchTab,
|
||||
type SidebarState, type SidebarStore, type SidebarTab,
|
||||
activateTab as activateTabReducer, allLeaves, closeTab as closeTabReducer, leafWithTab,
|
||||
openTabInActivePane, patchTab, tabOpenIn, togglePanel, treeOf,
|
||||
type SidebarSnapshot, type SidebarState, type SidebarStore, type SidebarTab,
|
||||
} from './state.ts'
|
||||
import { isNarrowWidth } from './breakpoints.ts'
|
||||
import type { SessionScope } from './api.ts'
|
||||
import type { SidebarPrefs } from '../prefs-shared.ts'
|
||||
|
||||
/** One declarative boolean setting of a tab/viewer, rendered as a nested
|
||||
* switch row in the Side card settings page (e.g. the Subagent page's
|
||||
* "auto-open when a subagent appears"). */
|
||||
/**
|
||||
* Public state vocabulary re-exported for consumers (type-only; the values
|
||||
* stay internal). External plugins name these types in their descriptors —
|
||||
* e.g. `dedupeKey: (tab: SidebarTab) => tab.id`, `createTab: (state: SidebarState) => …`,
|
||||
* or `badge: (…, state: SidebarState) => …`.
|
||||
*/
|
||||
export type {
|
||||
SidebarTab,
|
||||
SidebarState,
|
||||
SidebarStore,
|
||||
SidebarSnapshot,
|
||||
SidebarDiffRef,
|
||||
TabType,
|
||||
} from './state.ts'
|
||||
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' | '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`, '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
|
||||
@@ -37,17 +81,61 @@ export interface SidebarSettingToggle {
|
||||
title: string | (() => string)
|
||||
/** Row description (i18n friendly). */
|
||||
desc?: string | (() => string)
|
||||
/** Row control type; defaults to 'switch' (backward compatible). */
|
||||
type?: SidebarSettingToggleType
|
||||
/** Lower bound for `type: 'number'` rows (clamped on commit). */
|
||||
min?: number
|
||||
/** Upper bound for `type: 'number'` rows (clamped on commit). */
|
||||
max?: number
|
||||
/** Input placeholder for `type: 'text'` rows. */
|
||||
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`). */
|
||||
export interface SidebarSettingsRenderProps {
|
||||
store: SidebarStore
|
||||
service: BetterSidebarService
|
||||
prefs: SidebarPrefs
|
||||
/** This descriptor's own persisted settings blob (from `pluginSettings[id]`). */
|
||||
pluginSettings: Record<string, unknown>
|
||||
/** Persist one plugin-owned setting of this descriptor. */
|
||||
updatePluginSetting(key: string, value: unknown): void
|
||||
/** Close the settings popup. */
|
||||
close(): void
|
||||
}
|
||||
|
||||
/** Declarative settings of one registered tab or file viewer. */
|
||||
export interface SidebarSettingsDeclaration {
|
||||
/**
|
||||
* Extra boolean toggles rendered under the feature's own row in the
|
||||
* Extra settings rows rendered under the feature's own row in the
|
||||
* settings page (only while the feature is enabled). Keys must be fields
|
||||
* of the host's PrefsSchema (built-ins: 'autoOpenSubagent',
|
||||
* 'agentTerminalTools'); unknown keys are dropped by the settings seam.
|
||||
* 'agentTerminalTools', 'terminalFontFamily'); unknown keys are dropped
|
||||
* by the settings seam.
|
||||
*/
|
||||
toggles?: readonly SidebarSettingToggle[]
|
||||
/**
|
||||
* Plugin-owned settings rows (v0.12.0+): same row controls as `toggles`
|
||||
* (switch/text/number), but the keys are plugin-local and persisted in
|
||||
* the sidebar's own prefs document under `pluginSettings[<descriptor id>]`
|
||||
* — no host PrefsSchema field needed. Values must be JSON-serializable
|
||||
* (the row controls produce strings / numbers / booleans).
|
||||
*/
|
||||
pluginToggles?: readonly SidebarSettingToggle[]
|
||||
/**
|
||||
* Custom settings panel (v0.12.0+): when given, the gear popup renders
|
||||
* this instead of the row lists (`toggles` / `pluginToggles`). Receives
|
||||
* the shared store/service, the live prefs, the descriptor's own
|
||||
* `pluginSettings` blob, and a persistence helper.
|
||||
*/
|
||||
render?: (props: SidebarSettingsRenderProps) => ReactNode
|
||||
}
|
||||
|
||||
/** Props every tab component receives (builtins and external alike). */
|
||||
@@ -104,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
|
||||
@@ -111,6 +215,27 @@ export interface TabDescriptor {
|
||||
* (e.g. the subagent tab's 'autoOpenSubagent').
|
||||
*/
|
||||
settings?: SidebarSettingsDeclaration
|
||||
/**
|
||||
* Tab-strip badge (v0.12.0+): a small pill rendered on the tab next to
|
||||
* the icon — a number renders as a count (99+ capped), a string renders
|
||||
* as-is, null/undefined hides the badge. Called on every tab-bar render,
|
||||
* so keep it cheap; a throw is swallowed (no badge shown).
|
||||
*/
|
||||
badge?: (ctx: Context, scope: SessionScope, state: SidebarState) => string | number | null | undefined
|
||||
/**
|
||||
* Lifecycle callbacks (v0.12.0+). Fired by the SERVICE paths only:
|
||||
* `onOpen` when an open actually creates a tab (a dedupe/id-safety-net
|
||||
* focus is NOT an open — it fires `onActivate` instead), `onActivate`
|
||||
* when a tab is focused (dedupe focus, id-safety-net focus, or the
|
||||
* tab-bar activation), `onClose` when a tab is closed through
|
||||
* `closeTab`. Builtin-only flows that mutate state directly (the diff
|
||||
* split placement, agent-terminal reconcile) never touch external tabs
|
||||
* and fire no callbacks. A throwing callback is logged and never breaks
|
||||
* the open/close/activate flow.
|
||||
*/
|
||||
onOpen?: (tab: SidebarTab, scope: SessionScope) => void
|
||||
onActivate?: (tab: SidebarTab, scope: SessionScope) => void
|
||||
onClose?: (tab: SidebarTab, scope: SessionScope) => void
|
||||
component: (props: TabComponentProps) => ReactNode
|
||||
}
|
||||
|
||||
@@ -138,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). */
|
||||
@@ -158,8 +310,9 @@ export interface FileViewerDescriptor {
|
||||
* is consulted before its `exts` (per-descriptor, in priority order).
|
||||
*/
|
||||
detect?: (path: string, head: Uint8Array) => boolean
|
||||
/** fetchStrategy='custom' loader. */
|
||||
load?: (path: string, scope: SessionScope) => Promise<unknown>
|
||||
/** fetchStrategy='custom' loader. `signal` (v0.12.0+) aborts on viewer
|
||||
* teardown / re-match; loaders that ignore it keep working. */
|
||||
load?: (path: string, scope: SessionScope, signal?: AbortSignal) => Promise<unknown>
|
||||
/**
|
||||
* Declarative settings shown in the Side card settings page: every
|
||||
* registered viewer gets an enable/disable switch (icon + title + exts).
|
||||
@@ -168,7 +321,26 @@ export interface FileViewerDescriptor {
|
||||
component: (props: FileViewerProps) => ReactNode
|
||||
}
|
||||
|
||||
/** The registry service published as `ctx.betterSidebar`. */
|
||||
/** One `openTab` request. */
|
||||
export interface OpenTabSeed {
|
||||
type: string
|
||||
/** Overrides the descriptor's title when given (the editor tab shows the file name). */
|
||||
title?: string
|
||||
/** A file path (the editor tab's content seed). */
|
||||
path?: string
|
||||
/** A diff reference (the diff tab's content seed). */
|
||||
diff?: SidebarTab['diff']
|
||||
/** Explicit tab id (defaults to the type). */
|
||||
id?: string
|
||||
/** A URL the tab navigates to on mount (the browser tab's seed). */
|
||||
url?: string
|
||||
/** JSON-serializable custom state carried on the minted tab (persisted across reloads; v0.12.0+). */
|
||||
meta?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry service published as `ctx.betterSidebar`.
|
||||
*/
|
||||
export interface BetterSidebarService {
|
||||
registerTab(descriptor: TabDescriptor): () => void
|
||||
registerFileViewer(descriptor: FileViewerDescriptor): () => void
|
||||
@@ -198,12 +370,59 @@ export interface BetterSidebarService {
|
||||
* pre-set to the URL (the browser tab's navigation seed; the caller
|
||||
* usually pairs it with a hostname `title`). A disabled tab type is a
|
||||
* no-op.
|
||||
*
|
||||
* `scope` (v0.12.0+) targets a specific session: when given, the open
|
||||
* lands in THAT session's sidebar state (loading it if it has none yet)
|
||||
* without switching the UI's active session; when absent the open lands
|
||||
* in the currently active session (the pre-0.12 behavior).
|
||||
*
|
||||
* A CONTENT open (a `path` or `url` seed) must land in sight: when the
|
||||
* panel hosting the landing pane is collapsed, it is expanded
|
||||
* automatically (the right panel by default, the bottom panel when the
|
||||
* active pane lives there; on narrow viewports the merged drawer opens).
|
||||
* Type-only opens (the + menu, agent-terminal auto-tabs) never expand —
|
||||
* the panel behavior is their caller's business.
|
||||
*
|
||||
* Note: `available` gates the + menu's disabled state only — it does NOT
|
||||
* refuse `openTab` (only the settings disable switch does).
|
||||
*/
|
||||
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void
|
||||
/** Close a tab by id. */
|
||||
closeTab(tabId: string): void
|
||||
openTab(seed: OpenTabSeed, scope?: SessionScope): void
|
||||
/**
|
||||
* Close a tab by id (fires descriptor.onClose). An unknown tab id is a
|
||||
* strict no-op (no state churn, no callbacks). `scope` (v0.12.0+) rides
|
||||
* to the callback (its optional cwd included); absent, the callback gets
|
||||
* `{ sessionId }` of the active session.
|
||||
*/
|
||||
closeTab(tabId: string, scope?: SessionScope): void
|
||||
/** Subscribe to registry changes (register/dispose). */
|
||||
subscribe(listener: () => void): () => void
|
||||
/** The plugin version this service instance was built from ('0.12.0'). */
|
||||
readonly version: string
|
||||
/**
|
||||
* Monotonic capability list (v0.12.0+): 'badge' | 'tabLifecycle' |
|
||||
* 'updateTab' | 'openFile' | 'targetedOpen' | 'stateSubscription' |
|
||||
* 'tabMeta' | 'pluginSettings'. Features are never removed — consumers
|
||||
* gate new API usage on membership.
|
||||
*/
|
||||
readonly features: readonly string[]
|
||||
/**
|
||||
* The current sidebar snapshot: the active session id, its state (panel
|
||||
* geometry, open tabs, expansions), and the side card prefs (v0.12.0+).
|
||||
* `state`/`sessionId` are undefined until a session becomes active.
|
||||
*/
|
||||
getSnapshot(): SidebarSnapshot
|
||||
/** Subscribe to snapshot changes (session switch, state changes, prefs changes). Returns the disposer. */
|
||||
subscribeState(listener: () => void): () => void
|
||||
/** Update an open tab's display fields (title / path / meta); a missing tab id is a no-op. */
|
||||
updateTab(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void
|
||||
/**
|
||||
* Activate an open tab (the tab-bar activation path; fires
|
||||
* descriptor.onActivate). An unknown tab id is a strict no-op. `scope`
|
||||
* (v0.12.0+) rides to the callback like `closeTab`'s.
|
||||
*/
|
||||
activateTab(tabId: string, scope?: SessionScope): void
|
||||
/** Open a file in the sidebar editor of `scope`'s session (title defaults to the file name). */
|
||||
openFile(scope: SessionScope, path: string, title?: string): void
|
||||
}
|
||||
|
||||
/** Extract the lowercase extension without leading dot from a path. */
|
||||
@@ -214,6 +433,81 @@ function extOfPath(path: string): string {
|
||||
return base.includes('/') || base.includes('\\') ? '' : base
|
||||
}
|
||||
|
||||
/** The file name of a path (both separators). */
|
||||
function baseNameOf(path: string): string {
|
||||
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
||||
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.15.1'
|
||||
|
||||
/**
|
||||
* Monotonic capability list consumers use to gate new API usage (features
|
||||
* are never removed). Each string names a v0.12.0+ capability:
|
||||
* - 'badge': TabDescriptor.badge
|
||||
* - 'tabLifecycle': TabDescriptor.onOpen/onActivate/onClose
|
||||
* - 'updateTab': BetterSidebarService.updateTab
|
||||
* - 'openFile': BetterSidebarService.openFile
|
||||
* - 'targetedOpen': BetterSidebarService.openTab(seed, scope?)
|
||||
* - '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',
|
||||
'tabLifecycle',
|
||||
'updateTab',
|
||||
'openFile',
|
||||
'targetedOpen',
|
||||
'stateSubscription',
|
||||
'tabMeta',
|
||||
'pluginSettings',
|
||||
'urlTarget',
|
||||
'settingSelect',
|
||||
] as const
|
||||
|
||||
/** Run one plugin callback; a throw is logged and never breaks the caller. */
|
||||
function safeCall(fn: () => void): void {
|
||||
try {
|
||||
fn()
|
||||
} catch (error) {
|
||||
console.error('[dsh-better-sidebar] plugin callback error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one BetterSidebar service bound to a store. The service owns the
|
||||
* tab/viewer registries (Map + listener set) and proxies openTab/closeTab
|
||||
@@ -300,7 +594,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
return undefined
|
||||
}
|
||||
|
||||
const openTab = (seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void => {
|
||||
const openTab = (seed: OpenTabSeed, scope?: SessionScope): void => {
|
||||
// A type the user disabled in settings never opens — neither from the
|
||||
// + menu nor from derived flows (file opens, subagent auto-open,
|
||||
// external plugins). Already-open tabs keep rendering.
|
||||
@@ -308,9 +602,23 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
console.warn(`[dsh-better-sidebar] tab type "${seed.type}" is disabled in the side card settings`)
|
||||
return
|
||||
}
|
||||
store.reduce((state) => {
|
||||
const descriptor = tabs.get(seed.type)
|
||||
if (descriptor === undefined) return state
|
||||
const descriptor = tabs.get(seed.type)
|
||||
if (descriptor === undefined) return
|
||||
// A scope targets another session: the open lands in THAT session's
|
||||
// state (loaded on demand) without switching the UI's active session.
|
||||
const targetSessionId = scope?.sessionId ?? store.getSnapshot().sessionId
|
||||
if (targetSessionId === undefined) return
|
||||
const callbackScope: SessionScope = scope ?? { sessionId: targetSessionId }
|
||||
// Whether this open targets a session that is NOT the one on screen: a
|
||||
// targeted open must not auto-expand panels the user cannot see (the
|
||||
// expansion is about landing "in sight" for the CURRENT viewer).
|
||||
const activeSessionId = store.getSnapshot().sessionId
|
||||
const targetsInactiveSession = scope !== undefined && scope.sessionId !== activeSessionId
|
||||
// Lifecycle capture: `created` when the open minted a NEW tab (a
|
||||
// dedupe/id-safety-net focus is an ACTIVATION, not an open).
|
||||
let created: SidebarTab | undefined
|
||||
let activated: SidebarTab | undefined
|
||||
const reducer = (state: SidebarState): SidebarState => {
|
||||
// Let the descriptor mint the tab (terminal's nextTerminal bump, etc.).
|
||||
let tab: SidebarTab
|
||||
let next: SidebarState
|
||||
@@ -329,28 +637,153 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
title: seed.title ?? (typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title),
|
||||
...(seed.path !== undefined ? { path: seed.path } : {}),
|
||||
...(seed.diff !== undefined ? { diff: seed.diff } : {}),
|
||||
...(seed.meta !== undefined ? { meta: seed.meta } : {}),
|
||||
}
|
||||
next = applyDedupe(state, tab, descriptor)
|
||||
}
|
||||
// A URL seed pre-fills the tab's path (the browser tab navigates to it
|
||||
// on mount). An explicit seed.title also wins over a createTab-minted
|
||||
// default title (e.g. the sidebar-browser's hostname title).
|
||||
if (seed.url !== undefined) {
|
||||
return patchTab(next, tab.id, {
|
||||
// Classify the landing against the INPUT state FIRST: a FOCUS fires
|
||||
// onActivate with the tab that is active NOW; a real creation fires
|
||||
// onOpen with the minted tab. Both the dedupeKey match AND the id
|
||||
// match count as a focus — a descriptor deduping by key (e.g. editor
|
||||
// by path) can focus an existing tab for a NEW requested id, and
|
||||
// classifying that as a creation would fire onOpen with a phantom
|
||||
// tab that never closes.
|
||||
const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined)
|
||||
const key = dedupeKey?.(tab)
|
||||
const inputTabs = allLeaves(state.splits).concat(allLeaves(state.bottomSplits)).flatMap(leaf => leaf.tabs)
|
||||
const existedByKey = key !== undefined
|
||||
&& inputTabs.some(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key)
|
||||
const existedById = tabOpenIn(state, tab.id)
|
||||
const isCreation = !existedByKey && !existedById
|
||||
// A URL seed pre-fills a NEWLY CREATED tab's path (the browser tab
|
||||
// navigates to it on mount); a FOCUS must never have its path
|
||||
// overwritten. An explicit seed.title still wins over a createTab-
|
||||
// minted default title (e.g. the sidebar-browser's hostname title).
|
||||
let landed: SidebarState = next
|
||||
if (seed.url !== undefined && isCreation) {
|
||||
landed = patchTab(next, tab.id, {
|
||||
path: seed.url,
|
||||
...(seed.title !== undefined ? { title: seed.title } : {}),
|
||||
})
|
||||
}
|
||||
return next
|
||||
})
|
||||
// Lifecycle capture (before the auto-expand block, which early-returns).
|
||||
if (isCreation) {
|
||||
// Resolve the ACTUAL landed tab — the url patch mints a new object,
|
||||
// so the callback must see the tab that was really inserted.
|
||||
const landedTabs = allLeaves(landed.splits).concat(allLeaves(landed.bottomSplits)).flatMap(leaf => leaf.tabs)
|
||||
created = landedTabs.find(candidate => candidate.id === tab.id) ?? tab
|
||||
} else {
|
||||
// A focus happened: resolve the tab that is actually active now and
|
||||
// report THAT to onActivate (never the caller's un-inserted seed).
|
||||
const candidates = allLeaves(landed.splits).concat(allLeaves(landed.bottomSplits)).flatMap(leaf => leaf.tabs)
|
||||
activated = key !== undefined
|
||||
? candidates.find(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key)
|
||||
: candidates.find(candidate => candidate.id === tab.id)
|
||||
activated ??= tab
|
||||
}
|
||||
// A CONTENT open (file / browser) must land in sight: when the panel
|
||||
// hosting the landing pane is collapsed, expand it. On narrow
|
||||
// viewports the two workbenches merge into one drawer, so the drawer
|
||||
// (panelOpen) is the only lever; on wide viewports the landing pane's
|
||||
// own panel opens — the bottom panel when the active pane lives in the
|
||||
// bottom tree, else the right panel. Type-only opens (+ menu,
|
||||
// agent-terminal auto-tabs) never expand (the panel behavior is their
|
||||
// caller's business). The check runs on the post-dedupe state, so a
|
||||
// content open that merely FOCUSES an existing tab expands the panel
|
||||
// too — the open must never land out of sight. Opens targeted at an
|
||||
// INACTIVE session never expand (nothing is in sight for the user).
|
||||
if (
|
||||
!targetsInactiveSession
|
||||
&& typeof window !== 'undefined'
|
||||
&& (seed.path !== undefined || seed.url !== undefined)
|
||||
) {
|
||||
if (isNarrowWidth(window.innerWidth)) {
|
||||
if (!landed.panelOpen) return togglePanel(landed)
|
||||
} else {
|
||||
const hostKey = treeOf(landed, landed.activePane ?? '')
|
||||
if (hostKey === 'bottomSplits') {
|
||||
if (!landed.bottomOpen) return { ...landed, bottomOpen: true }
|
||||
} else if (!landed.panelOpen) {
|
||||
return togglePanel(landed)
|
||||
}
|
||||
}
|
||||
}
|
||||
return landed
|
||||
}
|
||||
// A scope targeting ANOTHER session lands the open there without
|
||||
// switching the UI; a scope naming the active session (or no scope)
|
||||
// takes the regular reduce path so the UI notifies and re-renders.
|
||||
if (targetsInactiveSession) {
|
||||
store.reduceFor(scope.sessionId, reducer)
|
||||
} else {
|
||||
store.reduce(reducer)
|
||||
}
|
||||
if (created !== undefined) safeCall(() => descriptor.onOpen?.(created!, callbackScope))
|
||||
else if (activated !== undefined) safeCall(() => descriptor.onActivate?.(activated!, callbackScope))
|
||||
}
|
||||
|
||||
const closeTab = (tabId: string): void => {
|
||||
const closeTab = (tabId: string, scope?: SessionScope): void => {
|
||||
let closed: SidebarTab | undefined
|
||||
store.reduce((state) => {
|
||||
// Unknown tab ids are a strict no-op: no state churn, no notify, no
|
||||
// pointless localStorage rewrite (mirrors updateTab's short-circuit).
|
||||
if (!tabOpenIn(state, tabId)) return state
|
||||
const paneId = findPaneIdOf(state, tabId)
|
||||
if (paneId === '') return state
|
||||
const leaf = leafWithTab(state[treeOf(state, paneId)], tabId)
|
||||
closed = leaf?.tabs.find(tab => tab.id === tabId)
|
||||
return closeTabReducer(state, paneId, tabId)
|
||||
})
|
||||
if (closed !== undefined) {
|
||||
const sessionId = scope?.sessionId ?? store.getSnapshot().sessionId
|
||||
if (sessionId !== undefined) {
|
||||
const descriptor = tabs.get(closed.type)
|
||||
// An explicit scope (with its optional cwd) rides to the callback.
|
||||
safeCall(() => descriptor?.onClose?.(closed!, scope ?? { sessionId }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The snapshot the store publishes (state/prefs carry the active session). */
|
||||
const getSnapshot = (): SidebarSnapshot => store.getSnapshot()
|
||||
|
||||
/** Store changes: session switch, state mutations, prefs writes. */
|
||||
const subscribeState = (listener: () => void): (() => void) => store.subscribe(listener)
|
||||
|
||||
/** Patch an open tab's display fields (a missing tab id is a no-op). */
|
||||
const updateTab = (tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void => {
|
||||
store.reduce((state) => patchTab(state, tabId, {
|
||||
...(patch.title !== undefined ? { title: patch.title } : {}),
|
||||
...(patch.path !== undefined ? { path: patch.path } : {}),
|
||||
...(patch.meta !== undefined ? { meta: patch.meta } : {}),
|
||||
}))
|
||||
}
|
||||
|
||||
/** Activate an open tab (the tab-bar activation path; fires onActivate). */
|
||||
const activateTab = (tabId: string, scope?: SessionScope): void => {
|
||||
let activated: SidebarTab | undefined
|
||||
store.reduce((state) => {
|
||||
// Unknown tab ids are a strict no-op (no state churn / notify).
|
||||
if (!tabOpenIn(state, tabId)) return state
|
||||
const paneId = findPaneIdOf(state, tabId)
|
||||
const leaf = leafWithTab(state[treeOf(state, paneId)], tabId)
|
||||
activated = leaf?.tabs.find(tab => tab.id === tabId)
|
||||
return activateTabReducer(state, paneId, tabId)
|
||||
})
|
||||
if (activated !== undefined) {
|
||||
const sessionId = scope?.sessionId ?? store.getSnapshot().sessionId
|
||||
if (sessionId !== undefined) {
|
||||
const descriptor = tabs.get(activated.type)
|
||||
// An explicit scope (with its optional cwd) rides to the callback.
|
||||
safeCall(() => descriptor?.onActivate?.(activated!, scope ?? { sessionId }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Open a file in the sidebar editor of `scope`'s session (title defaults
|
||||
* to the file name; the tab id is path-derived, like the internal
|
||||
* open-path interception, so distinct files open side by side). */
|
||||
const openFile = (scope: SessionScope, path: string, title?: string): void => {
|
||||
openTab({ type: 'editor', title: title ?? baseNameOf(path), path, id: `editor:${path}` }, scope)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -365,6 +798,13 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
openTab,
|
||||
closeTab,
|
||||
subscribe,
|
||||
version: SIDEBAR_SERVICE_VERSION,
|
||||
features: SIDEBAR_FEATURES,
|
||||
getSnapshot,
|
||||
subscribeState,
|
||||
updateTab,
|
||||
activateTab,
|
||||
openFile,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,7 +823,7 @@ function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescri
|
||||
// bottom panel focuses an existing instance wherever it lives.
|
||||
for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) {
|
||||
const existing = leaf.tabs.find(t => t.type === tab.type && dedupeKey!(t) === key)
|
||||
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
|
||||
if (existing !== undefined) return activateTabReducer(state, leaf.id, existing.id)
|
||||
}
|
||||
}
|
||||
return openTabInActivePane(state, tab)
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Built-in desktop-shell presets for the "位置兼容模式" secondary setting
|
||||
* (scheme `preset`). DATA-DRIVEN by design: each shell's adaptation lives
|
||||
* in one entry (strip values / optional CSS), the core applies it through
|
||||
* the SAME generic strip variable and CSS-injection mechanism — adding a
|
||||
* shell is adding data, never a code path.
|
||||
*
|
||||
* Inclusion rule (maintained in AGENTS.md §8): only shells that (a) appear
|
||||
* in this repo's issues/PRs (a user actually hit a problem) and (b) have
|
||||
* 100+ GitHub stars (a real user base). The mechanism is opt-in: auto
|
||||
* detection never applies a preset — the settings badge only SUGGESTS it.
|
||||
*/
|
||||
import type { DesktopEnv } from './desktop-env.ts'
|
||||
|
||||
export interface ShellPreset {
|
||||
/** Stable preset id (persisted in `titleBarPresetId`). */
|
||||
readonly id: string
|
||||
/** User-facing name of the shell. */
|
||||
readonly title: string
|
||||
/** One-line description shown in the settings popup. */
|
||||
readonly desc: string
|
||||
/**
|
||||
* The top strip (px) this shell reserves over web content, per
|
||||
* environment — the fallback used when neither the standard WCO API nor
|
||||
* the `dsh-desktop-titlebar-inset` contract parameter is available.
|
||||
* Return undefined when the shell needs no strip in that environment.
|
||||
* MUST be pure (called during render).
|
||||
*/
|
||||
readonly stripFor?: (env: DesktopEnv) => number | undefined
|
||||
/**
|
||||
* Extra CSS applied while the preset is enabled (injected last, after the
|
||||
* plugin's own styles). Targets the plugin's stable data attributes and
|
||||
* shell-declared body/URL markers only — never other shells' class names.
|
||||
* Empty for presets fully covered by the strip variable.
|
||||
*/
|
||||
readonly css?: string
|
||||
/**
|
||||
* Whether this shell's marker is currently visible (URL stamps / preload
|
||||
* markers — see desktop-env.ts). Used ONLY for the settings "已检测"
|
||||
* badge; never auto-applies anything. MUST be pure.
|
||||
*/
|
||||
readonly detect?: (env: DesktopEnv) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* DeepSeek Harness Desktop (anywhere-labs, Electron, 16k+ stars — the most
|
||||
* reported shell in this repo's issues/PRs). Advanced mode: macOS reserves
|
||||
* a 20px caption row (traffic lights top-left), win32 draws the native
|
||||
* window controls in a ~32px overlay (WCO reports the real height when
|
||||
* available, which the auto scheme already consumes; the 32 is the
|
||||
* no-WCO fallback). Compatibility mode keeps the native frame — nothing.
|
||||
*/
|
||||
const DSH_DESKTOP: ShellPreset = {
|
||||
id: 'dsh-desktop',
|
||||
title: 'DeepSeek Harness Desktop',
|
||||
desc: 'Electron 高级模式(无边框):macOS 顶栏 20px、Windows 无 WCO 时 32px 标题栏让位',
|
||||
stripFor: (env) => {
|
||||
if (env.mode !== 'advanced') return undefined
|
||||
if (env.platform === 'darwin') return 20
|
||||
if (env.platform === 'win32') return 32
|
||||
return undefined
|
||||
},
|
||||
detect: (env) => env.mode === 'advanced',
|
||||
}
|
||||
|
||||
const PRESETS: readonly ShellPreset[] = [
|
||||
DSH_DESKTOP,
|
||||
]
|
||||
|
||||
/** All built-in shell presets (registration order = settings list order). */
|
||||
export function getShellPresets(): readonly ShellPreset[] {
|
||||
return PRESETS
|
||||
}
|
||||
|
||||
/** One preset by id, or undefined for an unknown/empty id. */
|
||||
export function getShellPreset(id: string): ShellPreset | undefined {
|
||||
return PRESETS.find(preset => preset.id === id)
|
||||
}
|
||||
|
||||
/** The strip the active preset contributes for the given environment. */
|
||||
export function presetStripFor(preset: ShellPreset | undefined, env: DesktopEnv): number | undefined {
|
||||
return preset?.stripFor?.(env)
|
||||
}
|
||||
+780
-148
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Side Chat transcript mapping (browser half): turns a thread child's
|
||||
* history rows (`session.history` — the generic RPC, which reads the durable
|
||||
* log without activating the child) into compact display rows.
|
||||
*
|
||||
* A thread child's log starts with the ENTIRE inherited parent log as its
|
||||
* fork seed. The mapping therefore cuts everything up to the LAST
|
||||
* `session/end-seed` marker and maps context injections (the "Side
|
||||
* conversation boundary" prompt, plugin-sourced context) onto a collapsible
|
||||
* injection row, so the view shows only the thread's own conversation.
|
||||
*
|
||||
* Live streaming: `assistant/message` events only land when a step
|
||||
* completes, but `assistant/chunk` events stream token-level text and
|
||||
* reasoning deltas. The mapping accumulates both per block and supersedes
|
||||
* them with the assembled message once it lands (settled rows).
|
||||
*/
|
||||
import type { SidebarHistoryEntry } from '../context-types.ts'
|
||||
import { isContextInjectionMessage, SIDE_BOUNDARY_PROMPT } from '../sidechat-core.ts'
|
||||
|
||||
/** One compact transcript row rendered in the thread view. `seq` is the
|
||||
* source event's log sequence — stable row identity for React keys across
|
||||
* polls (streaming caches ride the key, so window slides must not re-key
|
||||
* rows). */
|
||||
export type SidechatTranscriptRow =
|
||||
| { kind: 'user'; seq: number; text: string }
|
||||
/** A context injection (the side boundary prompt + the parked in-progress
|
||||
* snapshot, or any plugin-sourced context): rendered as one collapsible
|
||||
* row, never as a user bubble. */
|
||||
| { kind: 'injection'; seq: number; text: string }
|
||||
/** `settled` distinguishes an assembled message from a still-streaming
|
||||
* chunk accumulation (streaming rows are superseded by the settle). */
|
||||
| { kind: 'assistant'; seq: number; text: string; settled: boolean }
|
||||
| { kind: 'reasoning'; seq: number; text: string; settled: boolean }
|
||||
| {
|
||||
kind: 'tool'
|
||||
seq: number
|
||||
name: string
|
||||
failed: boolean
|
||||
/** Raw arguments JSON as the model produced it. */
|
||||
args?: string
|
||||
/** Plain text of the paired result. */
|
||||
resultText?: string
|
||||
/** True while the call's result has not landed yet. */
|
||||
executing?: boolean
|
||||
}
|
||||
|
||||
/** Extract the visible text of a content-block list (`text` blocks verbatim,
|
||||
* joined by blank lines); empty reads `…` so rows never render blank. */
|
||||
export function blockText(content: readonly unknown[]): string {
|
||||
const parts: string[] = []
|
||||
for (const block of content) {
|
||||
if (block === null || typeof block !== 'object') continue
|
||||
const candidate = block as { type?: unknown; text?: unknown }
|
||||
if (candidate.type === 'text' && typeof candidate.text === 'string') {
|
||||
parts.push(candidate.text)
|
||||
}
|
||||
}
|
||||
const text = parts.join('\n\n')
|
||||
return text === '' ? '…' : text
|
||||
}
|
||||
|
||||
/** Cap for a tool row's one-line argument summary (display only). */
|
||||
const ARGS_SUMMARY_MAX = 80
|
||||
|
||||
/** The most identifying argument keys, in priority order (bash's command,
|
||||
* fs tools' paths, search's pattern, …). */
|
||||
const ARGS_SUMMARY_KEYS = ['command', 'file_path', 'path', 'pattern', 'query', 'url', 'prompt'] as const
|
||||
|
||||
function flatTruncate(text: string): string {
|
||||
const flat = text.replace(/\s+/g, ' ').trim()
|
||||
return flat.length > ARGS_SUMMARY_MAX ? `${flat.slice(0, ARGS_SUMMARY_MAX - 1)}…` : flat
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line summary of a tool call's raw arguments JSON for the collapsed
|
||||
* row: the first identifying string field when the JSON parses, else the
|
||||
* flattened raw text; empty when there is nothing worth showing.
|
||||
*/
|
||||
export function toolArgsSummary(args: string | undefined): string {
|
||||
if (args === undefined) return ''
|
||||
try {
|
||||
const parsed = JSON.parse(args) as Record<string, unknown> | null
|
||||
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
for (const key of ARGS_SUMMARY_KEYS) {
|
||||
const value = parsed[key]
|
||||
if (typeof value === 'string' && value.trim() !== '') return flatTruncate(value)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Raw text fallthrough.
|
||||
}
|
||||
return flatTruncate(args)
|
||||
}
|
||||
|
||||
/** The plain text of a tool/result message (text blocks inside its
|
||||
* `tool-result` content block). */
|
||||
function resultTextOf(data: Record<string, unknown>): string {
|
||||
const message = data.message as { content?: unknown } | undefined
|
||||
const content = message?.content
|
||||
if (!Array.isArray(content)) return ''
|
||||
const parts: string[] = []
|
||||
for (const block of content) {
|
||||
if (block === null || typeof block !== 'object') continue
|
||||
const candidate = block as { type?: unknown; content?: unknown }
|
||||
if (candidate.type !== 'tool-result') continue
|
||||
const inner = candidate.content
|
||||
if (!Array.isArray(inner)) continue
|
||||
for (const item of inner) {
|
||||
if (item === null || typeof item !== 'object') continue
|
||||
const textItem = item as { type?: unknown; text?: unknown }
|
||||
if (textItem.type === 'text' && typeof textItem.text === 'string') {
|
||||
parts.push(textItem.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
/** Index of the last `session/end-seed` event (fork seed marker), or -1. */
|
||||
function lastSeedEnd(events: readonly { type: string }[]): number {
|
||||
for (let index = events.length - 1; index >= 0; index--) {
|
||||
if (events[index]?.type === 'session/end-seed') return index
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the thread's OWN events on first attach: walk backward from the
|
||||
* log tail (oldest-first accumulation) until the `session/end-seed` marker
|
||||
* surfaces, then keep everything after it.
|
||||
*
|
||||
* Page size matters: cold reads re-expand persisted chunk-rows into one
|
||||
* `assistant/chunk` event per delta, so a single streamed answer can be
|
||||
* HUNDREDS of events. A small walk window (the old 8×32 = 256 events) let
|
||||
* earlier `tool/call` events fall out of the loaded window — the tool rows
|
||||
* vanished on re-entry while the settled text survived. The walk therefore
|
||||
* pages big; tail polls stay small.
|
||||
*
|
||||
* Exhaustion (log start reached without a marker — a thread created before
|
||||
* seeding existed, or a pathological log) returns `seedBoundary: 0` so the
|
||||
* caller stops re-walking and renders the window as-is.
|
||||
*
|
||||
* @param fetchPage - one history page (newest-first window ending at
|
||||
* `beforeSeq`, exclusive; omit for the tail page).
|
||||
* @param pageCap - safety bound on backward pages.
|
||||
*/
|
||||
export async function collectOwnEvents(
|
||||
fetchPage: (beforeSeq?: number) => Promise<readonly SidebarHistoryEntry[]>,
|
||||
pageCap = 40,
|
||||
): Promise<{ seedBoundary: number; entries: SidebarHistoryEntry[] }> {
|
||||
const collected: SidebarHistoryEntry[] = []
|
||||
let beforeSeq: number | undefined
|
||||
for (let page = 0; page < pageCap; page++) {
|
||||
const events = await fetchPage(beforeSeq)
|
||||
if (events.length === 0) {
|
||||
// Log start reached without a marker: the window IS the whole log.
|
||||
return { seedBoundary: 0, entries: collected }
|
||||
}
|
||||
const olderThan = collected.length > 0 ? collected[0]!.event.seq : undefined
|
||||
const fresh = olderThan === undefined
|
||||
? [...events]
|
||||
: events.filter(entry => entry.event.seq < olderThan)
|
||||
const seedEnd = fresh.findLastIndex(entry => entry.event.type === 'session/end-seed')
|
||||
if (seedEnd >= 0) {
|
||||
collected.unshift(...fresh.slice(seedEnd + 1))
|
||||
return { seedBoundary: fresh[seedEnd]!.event.seq, entries: collected }
|
||||
}
|
||||
collected.unshift(...fresh)
|
||||
if (fresh.length === 0) {
|
||||
// The page overlaps entirely with what we have: nothing older exists.
|
||||
return { seedBoundary: 0, entries: collected }
|
||||
}
|
||||
beforeSeq = fresh[0]!.event.seq
|
||||
}
|
||||
// Cap hit: accept the window (it is overwhelmingly the thread's own tail)
|
||||
// rather than re-walking on every poll.
|
||||
return { seedBoundary: 0, entries: collected }
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a thread child's history rows onto compact transcript rows: the
|
||||
* inherited fork seed is cut at the last `session/end-seed`, context
|
||||
* injections map onto a collapsible injection row, `assistant/chunk`
|
||||
* deltas accumulate into streaming rows per (turn, step, block) and are
|
||||
* superseded by the assembled `assistant/message`, and tool invocations
|
||||
* render one expandable line each (arguments, paired result text, failure
|
||||
* marker; a still-executing call is marked until its result lands).
|
||||
* @param entries - history rows (event + host-computed view) in seq order.
|
||||
* @returns display rows in log order.
|
||||
*/
|
||||
export function transcriptRows(entries: readonly SidebarHistoryEntry[]): SidechatTranscriptRow[] {
|
||||
const events = entries.map(entry => entry.event)
|
||||
const seedEnd = lastSeedEnd(events)
|
||||
const rows: SidechatTranscriptRow[] = []
|
||||
/** (turn, step, index, kind) key → index of its accumulating stream row. */
|
||||
const streamRows = new Map<string, number>()
|
||||
/** tool callId → index of its tool row in `rows` (result pairing). */
|
||||
const callRows = new Map<string, number>()
|
||||
for (let index = 0; index < events.length; index++) {
|
||||
if (index <= seedEnd) continue
|
||||
const event = events[index]
|
||||
if (event === undefined) continue
|
||||
const data = event.data as Record<string, unknown>
|
||||
switch (event.type) {
|
||||
case 'user/message': {
|
||||
const text = blockText(Array.isArray(data.content) ? data.content : [])
|
||||
// Context injections (the boundary prompt + snapshot, plugin-sourced
|
||||
// context) collapse into an injection row; genuine user messages —
|
||||
// including the FIRST one, which the host now delivers as its own
|
||||
// event — render as user rows.
|
||||
if (isContextInjectionMessage(data)) {
|
||||
const source = data.source as { kind?: unknown } | undefined
|
||||
// Threads logged BEFORE the host split carry boundary(+snapshot)+
|
||||
// question in ONE 'user' message. The boundary prompt is a known
|
||||
// constant, so the message splits THERE: the injection row keeps
|
||||
// the prompt, the remainder (snapshot + question if any — pure
|
||||
// question in the common case) renders as the user's real message.
|
||||
if (source?.kind === 'user' && text.startsWith(`${SIDE_BOUNDARY_PROMPT}\n\n`)) {
|
||||
rows.push({ kind: 'injection', seq: event.seq, text: SIDE_BOUNDARY_PROMPT })
|
||||
const body = text.slice(SIDE_BOUNDARY_PROMPT.length + 2)
|
||||
if (body !== '') rows.push({ kind: 'user', seq: event.seq, text: body })
|
||||
break
|
||||
}
|
||||
rows.push({ kind: 'injection', seq: event.seq, text })
|
||||
break
|
||||
}
|
||||
rows.push({ kind: 'user', seq: event.seq, text })
|
||||
break
|
||||
}
|
||||
case 'assistant/chunk': {
|
||||
const chunk = data.chunk as { type?: unknown; text?: unknown } | undefined
|
||||
if (chunk === null || typeof chunk !== 'object') break
|
||||
const kind = chunk.type === 'text-delta' ? 'assistant' : chunk.type === 'reasoning-delta' ? 'reasoning' : null
|
||||
if (kind === null || typeof chunk.text !== 'string' || chunk.text === '') break
|
||||
const turn = data.turn
|
||||
const step = data.step
|
||||
const blockIndex = (chunk as { index?: unknown }).index
|
||||
const key = `${String(turn)}:${String(step)}:${String(blockIndex)}:${kind}`
|
||||
const existing = streamRows.get(key)
|
||||
if (existing !== undefined) {
|
||||
const row = rows[existing]
|
||||
if (row !== undefined && row.kind === kind && !row.settled) {
|
||||
rows[existing] = { ...row, text: row.text + chunk.text }
|
||||
}
|
||||
} else {
|
||||
streamRows.set(key, rows.length)
|
||||
rows.push({ kind, seq: event.seq, text: chunk.text, settled: false })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'assistant/message': {
|
||||
const prefix = `${String(data.turn)}:${String(data.step)}:`
|
||||
const streamed = [...streamRows.entries()]
|
||||
.filter(([key]) => key.startsWith(prefix))
|
||||
.map(([, rowIndex]) => rowIndex)
|
||||
for (const key of [...streamRows.keys()]) {
|
||||
if (key.startsWith(prefix)) streamRows.delete(key)
|
||||
}
|
||||
const content = Array.isArray((data.message as { content?: unknown } | undefined)?.content)
|
||||
? (data.message as { content: readonly unknown[] }).content
|
||||
: []
|
||||
const settled: SidechatTranscriptRow[] = content.flatMap((block): SidechatTranscriptRow[] => {
|
||||
if (block === null || typeof block !== 'object') return []
|
||||
const candidate = block as { type?: unknown; text?: unknown }
|
||||
if (candidate.type === 'reasoning' && typeof candidate.text === 'string' && candidate.text !== '') {
|
||||
return [{ kind: 'reasoning', seq: event.seq, text: candidate.text, settled: true }]
|
||||
}
|
||||
if (candidate.type === 'text' && typeof candidate.text === 'string' && candidate.text !== '') {
|
||||
return [{ kind: 'assistant', seq: event.seq, text: candidate.text, settled: true }]
|
||||
}
|
||||
return []
|
||||
})
|
||||
if (streamed.length === 0) rows.push(...settled)
|
||||
else rows.splice(Math.min(...streamed), streamed.length, ...settled)
|
||||
break
|
||||
}
|
||||
case 'tool/call': {
|
||||
const callId = data.callId
|
||||
const name = typeof data.name === 'string' ? data.name : 'tool'
|
||||
const args = typeof data.arguments === 'string' ? data.arguments : undefined
|
||||
const rowIndex = rows.length
|
||||
if (typeof callId === 'string') callRows.set(callId, rowIndex)
|
||||
rows.push({ kind: 'tool', seq: event.seq, name, failed: false, args, executing: true })
|
||||
break
|
||||
}
|
||||
case 'tool/result': {
|
||||
const source = data.message as { source?: { callId?: unknown } } | undefined
|
||||
const callId = typeof source?.source?.callId === 'string' ? source.source.callId : undefined
|
||||
const rowIndex = callId === undefined ? undefined : callRows.get(callId)
|
||||
const failed = data.error !== undefined
|
||||
const resultText = resultTextOf(data)
|
||||
if (rowIndex !== undefined) {
|
||||
const row = rows[rowIndex]
|
||||
if (row !== undefined && row.kind === 'tool') {
|
||||
rows[rowIndex] = {
|
||||
...row,
|
||||
failed: row.failed || failed,
|
||||
resultText: resultText === '' ? row.resultText : resultText,
|
||||
executing: false,
|
||||
}
|
||||
}
|
||||
} else if (failed || resultText !== '') {
|
||||
// Orphan result (no call row in the window): surface it so the row
|
||||
// stays informative and expandable.
|
||||
rows.push({
|
||||
kind: 'tool',
|
||||
seq: event.seq,
|
||||
name: callId === undefined ? 'tool' : `tool:${callId.slice(0, 8)}`,
|
||||
failed,
|
||||
resultText: resultText === '' ? undefined : resultText,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
default: {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -123,8 +123,9 @@ function LeafView(props: {
|
||||
onNewTab: (optionId: string) => void
|
||||
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
|
||||
getTabIcon?: (tab: SidebarTab) => ReactNode
|
||||
getTabBadge?: (tab: SidebarTab) => ReactNode
|
||||
}) {
|
||||
const { leaf, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
|
||||
const { leaf, newTabOptions, actions, onNewTab, renderTab, getTabIcon, getTabBadge } = props
|
||||
const [dropZone, setDropZone] = useState<DropZone | null>(null)
|
||||
const activeTab = leaf.tabs.find(tab => tab.id === leaf.active) ?? leaf.tabs[leaf.tabs.length - 1]
|
||||
|
||||
@@ -178,6 +179,7 @@ function LeafView(props: {
|
||||
onNewTab={onNewTab}
|
||||
newTabOptions={newTabOptions}
|
||||
getTabIcon={getTabIcon}
|
||||
getTabBadge={getTabBadge}
|
||||
onDropTab={(payload, before) => {
|
||||
if (before === null) actions.moveTabToEdge(payload, leaf.id, 'center')
|
||||
else actions.moveTabBefore(payload, leaf.id, before)
|
||||
@@ -217,8 +219,9 @@ function NodeView(props: {
|
||||
onNewTab: (optionId: string) => void
|
||||
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
|
||||
getTabIcon?: (tab: SidebarTab) => ReactNode
|
||||
getTabBadge?: (tab: SidebarTab) => ReactNode
|
||||
}) {
|
||||
const { node, state, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
|
||||
const { node, state, newTabOptions, actions, onNewTab, renderTab, getTabIcon, getTabBadge } = props
|
||||
if (node.kind === 'leaf') {
|
||||
return (
|
||||
<LeafView
|
||||
@@ -228,6 +231,7 @@ function NodeView(props: {
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
getTabIcon={getTabIcon}
|
||||
getTabBadge={getTabBadge}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -254,6 +258,7 @@ function NodeView(props: {
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
getTabIcon={getTabIcon}
|
||||
getTabBadge={getTabBadge}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
@@ -274,8 +279,9 @@ export function Workbench(props: {
|
||||
onNewTab: (optionId: string) => void
|
||||
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
|
||||
getTabIcon?: (tab: SidebarTab) => ReactNode
|
||||
getTabBadge?: (tab: SidebarTab) => ReactNode
|
||||
}) {
|
||||
const { state, tree, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
|
||||
const { state, tree, newTabOptions, actions, onNewTab, renderTab, getTabIcon, getTabBadge } = props
|
||||
return (
|
||||
<div className={css.workbench}>
|
||||
<NodeView
|
||||
@@ -286,6 +292,7 @@ export function Workbench(props: {
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
getTabIcon={getTabIcon}
|
||||
getTabBadge={getTabBadge}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
+172
-19
@@ -10,10 +10,11 @@
|
||||
* operations are pure functions over the node, unit-tested in tests/state.spec.ts.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
@@ -24,14 +25,18 @@ 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);
|
||||
* `diff` carries the change a diff tab shows. */
|
||||
/** 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 {
|
||||
id: string
|
||||
type: TabType
|
||||
title: string
|
||||
path?: string
|
||||
diff?: SidebarDiffRef
|
||||
/** Plugin-owned state (v0.12.0+): MUST be JSON-serializable — it is
|
||||
* persisted with the layout and restored verbatim on reload. */
|
||||
meta?: unknown
|
||||
}
|
||||
|
||||
/** A tab group. */
|
||||
@@ -100,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
|
||||
@@ -134,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
|
||||
@@ -205,6 +224,44 @@ export function firstLeaf(node: SplitNode): SidebarLeaf {
|
||||
return firstLeaf(node.children[0]!)
|
||||
}
|
||||
|
||||
/** Empty every leaf of a tree (the bottom tree after its tabs migrate out). */
|
||||
function clearAllTabs(node: SplitNode): SplitNode {
|
||||
if (node.kind === 'leaf') return { ...node, tabs: [], active: null }
|
||||
return { ...node, children: node.children.map(clearAllTabs) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow-viewport migration: the bottom panel's tabs are thrown INTO the
|
||||
* right sidebar — the "merged display" on mobile is the right panel alone,
|
||||
* whose tab strips now carry the bottom tree's tabs (depth-first order,
|
||||
* appended to the right tree's FIRST leaf). The bottom tree is emptied (its
|
||||
* structure stays — the desktop bottom panel re-renders its welcome cards)
|
||||
* and the panel closes. The active pane moves to the right tree's first
|
||||
* leaf so every new tab lands in the visible panel.
|
||||
*
|
||||
* Idempotent: a bottom tree with no tabs and a closed panel returns the
|
||||
* same reference. Runs when the viewport enters narrow (see the Sidebar
|
||||
* shell); migrating is permanent for the session — the tabs now live in the
|
||||
* right tree, exactly like the user "threw them in".
|
||||
*/
|
||||
export function migrateBottomTabs(state: SidebarState): SidebarState {
|
||||
const bottomTabs = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs)
|
||||
const activeInBottom = state.activePane !== null && treeHasId(state.bottomSplits, state.activePane)
|
||||
if (bottomTabs.length === 0 && !state.bottomOpen && !activeInBottom) return state
|
||||
const target = firstLeaf(state.splits)
|
||||
return {
|
||||
...state,
|
||||
activePane: target.id,
|
||||
bottomOpen: false,
|
||||
splits: bottomTabs.length > 0
|
||||
? mapLeaf(state.splits, target.id, leaf => {
|
||||
leaf.tabs = [...leaf.tabs, ...bottomTabs]
|
||||
})
|
||||
: state.splits,
|
||||
bottomSplits: bottomTabs.length > 0 ? clearAllTabs(state.bottomSplits) : state.bottomSplits,
|
||||
}
|
||||
}
|
||||
|
||||
/** Find the leaf containing a tab id, if any. */
|
||||
export function leafWithTab(node: SplitNode, tabId: string): SidebarLeaf | undefined {
|
||||
if (node.kind === 'leaf') {
|
||||
@@ -393,14 +450,14 @@ export function activateTab(state: SidebarState, paneId: string, tabId: string):
|
||||
}
|
||||
}
|
||||
|
||||
/** Update the display fields of one open tab (title / path) without
|
||||
/** Update the display fields of one open tab (title / path / meta) without
|
||||
* re-opening it. The browser tab persists its current URL and hostname
|
||||
* title through this reducer so a reload restores the visited page. A
|
||||
* missing tab id is a no-op. The tab may live in either tree. */
|
||||
export function patchTab(
|
||||
state: SidebarState,
|
||||
tabId: string,
|
||||
patch: { title?: string; path?: string },
|
||||
patch: { title?: string; path?: string; meta?: unknown },
|
||||
): SidebarState {
|
||||
let changed = false
|
||||
const walk = (node: SplitNode): SplitNode => {
|
||||
@@ -412,6 +469,7 @@ export function patchTab(
|
||||
...tab,
|
||||
...(patch.title !== undefined ? { title: patch.title } : {}),
|
||||
...(patch.path !== undefined ? { path: patch.path } : {}),
|
||||
...(patch.meta !== undefined ? { meta: patch.meta } : {}),
|
||||
}
|
||||
})
|
||||
return tabs === node.tabs ? node : { ...node, tabs }
|
||||
@@ -720,13 +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.
|
||||
// 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)
|
||||
return makeDefaultState(width, prefs.openByDefault, prefs.tabsEnabled['explorer'] !== false)
|
||||
const openByDefault = prefs.openByDefault && (viewport === undefined || !isNarrowWidth(viewport))
|
||||
const seed: DefaultSeed = prefs.tabsEnabled['editor'] === false ? 'none' : 'editor-home'
|
||||
return makeDefaultState(width, openByDefault, seed)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -841,11 +907,30 @@ 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).
|
||||
tabs.push({
|
||||
id: candidate.id,
|
||||
type: candidate.type,
|
||||
title: candidate.title,
|
||||
...(typeof candidate.path === 'string' ? { path: candidate.path } : {}),
|
||||
...(candidate.meta !== undefined ? { meta: candidate.meta } : {}),
|
||||
})
|
||||
}
|
||||
const active = typeof record.active === 'string' ? record.active : null
|
||||
@@ -884,9 +969,31 @@ export class SidebarStore {
|
||||
prefs: { ...SIDEBAR_PREFS_DEFAULTS },
|
||||
}
|
||||
private readonly listeners = new Set<() => void>()
|
||||
private persistTimer: number | undefined
|
||||
/** Per-session persist debounce timers (v0.12.0+: one per session, so a
|
||||
* targeted open never cancels another session's pending write). */
|
||||
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
|
||||
@@ -969,21 +1076,67 @@ export class SidebarStore {
|
||||
const state = this.snapshot.state
|
||||
if (sessionId === undefined || state === undefined) return
|
||||
const next = reducer(state)
|
||||
// A reducer returning the SAME reference means "no change": skip the
|
||||
// persist + notify entirely — strict no-op paths (unknown tab ids,
|
||||
// patchTab on a missing tab) must not churn the state or rewrite
|
||||
// localStorage.
|
||||
if (next === state) return
|
||||
this.bySession.set(sessionId, next)
|
||||
this.snapshot = { sessionId, state: next, prefs: this.prefs }
|
||||
this.schedulePersist(sessionId, next)
|
||||
this.notify()
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a pure reducer to a TARGET session's state (not the active one),
|
||||
* loading it on demand and persisting the result — WITHOUT switching the
|
||||
* active snapshot or notifying (the UI must not follow along). Used by the
|
||||
* service's targeted `openTab(seed, scope)`: the open lands in the target
|
||||
* session's layout and is visible whenever the user switches to it.
|
||||
*/
|
||||
reduceFor(sessionId: string, reducer: (state: SidebarState) => SidebarState): void {
|
||||
// The uid counter is SHARED across sessions, and the ACTIVE session's
|
||||
// safety requires it to never drop below the ids IT minted. Seeding it
|
||||
// from the target's max may LOWER it (a cached target older than the
|
||||
// active session): restoring the pre-call level afterwards keeps the
|
||||
// active session's next mint collision-free — ids minted for the target
|
||||
// only need to exceed the target's own max, which the seed guaranteed.
|
||||
const counterBefore = nextIdCounter
|
||||
let state = this.bySession.get(sessionId)
|
||||
if (state === undefined) {
|
||||
state = loadState(sessionId, this.prefs)
|
||||
this.bySession.set(sessionId, state)
|
||||
} else {
|
||||
// Re-seed the uid counter past THIS session's persisted ids, exactly
|
||||
// like setSession's cache-hit path.
|
||||
nextIdCounter = maxCounterId(state)
|
||||
}
|
||||
const next = reducer(state)
|
||||
// Same-reference result = no change: keep the counter restore (it may
|
||||
// have been seeded down) but skip the write.
|
||||
nextIdCounter = Math.max(nextIdCounter, counterBefore)
|
||||
if (next === state) return
|
||||
this.bySession.set(sessionId, next)
|
||||
this.schedulePersist(sessionId, next)
|
||||
}
|
||||
|
||||
private schedulePersist(sessionId: string, state: SidebarState): void {
|
||||
window.clearTimeout(this.persistTimer)
|
||||
this.persistTimer = window.setTimeout(() => {
|
||||
// Per-session debounce timers: one session's pending write must never
|
||||
// cancel another's (targeted opens schedule writes for INACTIVE
|
||||
// sessions while the active session may already have one pending —
|
||||
// a shared timer would drop the earlier write and the reload would
|
||||
// lose that session's layout).
|
||||
const existing = this.persistTimers.get(sessionId)
|
||||
if (existing !== undefined) window.clearTimeout(existing)
|
||||
const timer = window.setTimeout(() => {
|
||||
this.persistTimers.delete(sessionId)
|
||||
try {
|
||||
localStorage.setItem(`${STORAGE_PREFIX}:${sessionId}`, JSON.stringify(state))
|
||||
} catch {
|
||||
// Storage full or unavailable: layout memory is best-effort.
|
||||
}
|
||||
}, 200)
|
||||
this.persistTimers.set(sessionId, timer)
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* Pure derivation of the compact LIVE line shown on a running subagent card:
|
||||
* the last text output and the last tool call of the child's history tail
|
||||
* (`subagent.history` events, oldest → newest). Renders nothing itself — the
|
||||
* SubagentLiveLine component turns this into the card's status lines.
|
||||
* Kept framework-free so the parser is unit-testable in the node environment.
|
||||
*/
|
||||
import type { SidebarHistoryEntry } from '../context-types.ts'
|
||||
|
||||
/**
|
||||
* Extract the concatenated plain text of a content-block list (the durable
|
||||
* `ContentBlock[]` shape, structurally: blocks with `type: 'text'` carry
|
||||
* `text`; anything else — tool_use, image, … — contributes nothing).
|
||||
* @param content - the raw `content` field of a message event.
|
||||
* @returns the joined text, or undefined when the message carries no text.
|
||||
*/
|
||||
export function contentText(content: unknown): string | undefined {
|
||||
if (!Array.isArray(content)) return undefined
|
||||
const parts: string[] = []
|
||||
for (const block of content) {
|
||||
if (block === null || typeof block !== 'object') continue
|
||||
const candidate = block as { type?: unknown; text?: unknown }
|
||||
if (candidate.type === 'text' && typeof candidate.text === 'string') {
|
||||
parts.push(candidate.text)
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? parts.join('\n') : undefined
|
||||
}
|
||||
|
||||
/** The live status of one subagent card (both fields optional). */
|
||||
export interface LastActivity {
|
||||
/** The latest assembled assistant text output in the tail. */
|
||||
text?: string
|
||||
/** The latest tool call in the tail. */
|
||||
tool?: { name: string; args: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a history tail into the last text output + last tool call (each is
|
||||
* the LAST occurrence in event order). Lifecycle events and raw
|
||||
* `assistant/chunk` rows are ignored — the card shows what the subagent is
|
||||
* doing right now, not its plumbing.
|
||||
* @param entries - the tail page from `subagent.history` (oldest → newest).
|
||||
* @returns the last text and/or tool call; an empty object when the tail has neither.
|
||||
*/
|
||||
export function lastActivity(entries: SidebarHistoryEntry[]): LastActivity {
|
||||
let text: string | undefined
|
||||
let tool: { name: string; args: string } | undefined
|
||||
for (const entry of entries) {
|
||||
const { type, data } = entry.event
|
||||
if (type === 'assistant/message') {
|
||||
const message = data.message as { content?: unknown } | undefined
|
||||
const extracted = contentText(message?.content)
|
||||
if (extracted !== undefined) text = extracted
|
||||
} else if (type === 'tool/call') {
|
||||
tool = {
|
||||
name: typeof data.name === 'string' ? data.name : 'tool',
|
||||
args: typeof data.arguments === 'string' ? data.arguments : '',
|
||||
}
|
||||
}
|
||||
}
|
||||
if (text === undefined && tool === undefined) return {}
|
||||
return {
|
||||
...(text === undefined ? {} : { text }),
|
||||
...(tool === undefined ? {} : { tool }),
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user