Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b1898f9cb | ||
|
|
c894163b17 | ||
|
|
6cfe7a736f | ||
|
|
0314fd9b93 | ||
|
|
d71261ace3 | ||
|
|
20da6479f1 | ||
|
|
df90e9d132 | ||
|
|
02125deee8 | ||
|
|
31c675a00e | ||
|
|
e7c4578791 | ||
|
|
acf639471d | ||
|
|
531bb53129 | ||
|
|
c6a3e24599 | ||
|
|
5d019d78ac | ||
|
|
68f771e65f | ||
|
|
d052bd7fb2 | ||
|
|
f9153dfc1c | ||
|
|
bba7bb6073 | ||
|
|
eed40bb11a | ||
|
|
2cbcba8d11 | ||
|
|
6f6568d29f | ||
|
|
9c4a8270a7 | ||
|
|
e5f106b576 | ||
|
|
e18528b716 | ||
|
|
256256e543 | ||
|
|
78f22f2425 | ||
|
|
1a18f75641 | ||
|
|
f5ba7e082b | ||
|
|
699ce448a1 | ||
|
|
9048dbe42d | ||
|
|
6001f0167c | ||
|
|
f4e71c1257 | ||
|
|
15e90d936e | ||
|
|
4631a02db0 | ||
|
|
2b757cfe24 | ||
|
|
8e0ef7a175 | ||
|
|
4c0da8119b | ||
|
|
7f634866bf | ||
|
|
2357fc08a5 | ||
|
|
504579e144 | ||
|
|
df489ccb45 | ||
|
|
b5eb71a136 | ||
|
|
867de61aca | ||
|
|
1eb8e760f9 | ||
|
|
64338f4244 | ||
|
|
abe9c4fc46 | ||
|
|
53f3a2c246 | ||
|
|
e135332433 | ||
|
|
4feb5ef8de | ||
|
|
8e04ee6f74 | ||
|
|
ddd3bed7cf | ||
|
|
9340dc9702 | ||
|
|
68640101e1 | ||
|
|
36a4119253 | ||
|
|
d15645bf23 | ||
|
|
197b10a3fe | ||
|
|
b7229aec67 | ||
|
|
f648b0c006 | ||
|
|
e20b95024b | ||
|
|
f17a1b30ac | ||
|
|
751fb954a2 | ||
|
|
9410e5bb2a | ||
|
|
0a21629fa3 | ||
|
|
e07d4ba5dd | ||
|
|
72796ee46a | ||
|
|
5951bae1ad | ||
|
|
b4118a4c77 | ||
|
|
af8ec58f4c | ||
|
|
a860462254 | ||
|
|
d63de6f815 | ||
|
|
37a84815d1 | ||
|
|
8f4d58dcc7 | ||
|
|
90584535d4 | ||
|
|
e9a488024c | ||
|
|
ed3083d133 | ||
|
|
3445331494 | ||
|
|
60b9c867d8 | ||
|
|
c88eccfadb | ||
|
|
5c88279d7a | ||
|
|
23a72a5f64 | ||
|
|
ba83dd33f7 | ||
|
|
d6d8db0416 | ||
|
|
4af8d3121a | ||
|
|
f58e361cbe | ||
|
|
7f8f95a59b | ||
|
|
dab6e795a8 | ||
|
|
4882a5c90f | ||
|
|
e9ce58afea | ||
|
|
b87f263c09 | ||
|
|
d0f194b67a | ||
|
|
57a4ad82e1 | ||
|
|
16a809594c | ||
|
|
a75928daf1 | ||
|
|
38dc807d15 | ||
|
|
c381557a1e | ||
|
|
7ceee777b4 | ||
|
|
ddb66c9c08 | ||
|
|
d9b8f15d9e | ||
|
|
22a5d372be | ||
|
|
f36afc0180 | ||
|
|
38a3d94410 | ||
|
|
71c5413e5c | ||
|
|
e867e27106 | ||
|
|
656f9fba3c | ||
|
|
4148130aac | ||
|
|
924f377fa1 | ||
|
|
d3c0f22345 | ||
|
|
e24d9bf879 | ||
|
|
f5fd6fc01a | ||
|
|
78de79c9d6 | ||
|
|
67271e54c8 | ||
|
|
9b4240449c | ||
|
|
ef8d3a9050 | ||
|
|
9a40164d1a | ||
|
|
29b8615bff | ||
|
|
546afefb9b | ||
|
|
b7aab48deb | ||
|
|
7dbd9b75e2 | ||
|
|
cdda072973 | ||
|
|
646c91cf70 | ||
|
|
55ad7eec8d | ||
|
|
d38a411f6d | ||
|
|
940d0a8928 | ||
|
|
f52cb8779e | ||
|
|
27cd211bb3 | ||
|
|
1c330ba44f | ||
|
|
04686dcc7d | ||
|
|
c426431e8b | ||
|
|
965ec49c16 | ||
|
|
8ceaefcfc7 | ||
|
|
f0965e1d61 | ||
|
|
f268d35f8b | ||
|
|
ea70dc97eb | ||
|
|
b8e0840f3f | ||
|
|
3004604a2d | ||
|
|
2d2b42722a | ||
|
|
c62ba4a565 | ||
|
|
0fdf63b081 | ||
|
|
6cea4bdd6f | ||
|
|
a9d3451415 | ||
|
|
0da2812dd3 | ||
|
|
7f4f84c4cf | ||
|
|
bc6f1e129d | ||
|
|
50a888845f | ||
|
|
e2052a5e81 | ||
|
|
04374b23e4 | ||
|
|
6d6d582996 | ||
|
|
4e52923970 | ||
|
|
ffcd6953eb | ||
|
|
bb2456bea9 | ||
|
|
5cc1a4b52e | ||
|
|
e8d04980b4 | ||
|
|
5be195df18 | ||
|
|
6c891514b5 | ||
|
|
23c315ed82 | ||
|
|
14ef8e4df8 | ||
|
|
27ea096f82 | ||
|
|
659bfadb47 | ||
|
|
c0e41f4b0d | ||
|
|
4c875ceff7 | ||
|
|
4a407d78b8 | ||
|
|
8181e13b52 | ||
|
|
bcd60234ac | ||
|
|
76aacddbb9 | ||
|
|
0bcafd476d | ||
|
|
e8757026a1 | ||
|
|
b5e8665499 | ||
|
|
c973d6ec1b | ||
|
|
39666832a2 | ||
|
|
498528d2eb | ||
|
|
23132560fb | ||
|
|
dd5a3dda07 | ||
|
|
4d0f99ced6 | ||
|
|
5e416eccef | ||
|
|
9bea6c5539 | ||
|
|
af637b4f1b | ||
|
|
9ad0a49b8a | ||
|
|
7cc3c5fe4a | ||
|
|
bb8df17ecb | ||
|
|
6ee1ba11d0 | ||
|
|
c923fc57b1 | ||
|
|
f39156607e | ||
|
|
ab785d71fb | ||
|
|
333961be71 | ||
|
|
618858b5b1 | ||
|
|
565e66d30e | ||
|
|
de5234a3d7 | ||
|
|
a673f2399f | ||
|
|
6f52aa79d2 | ||
|
|
8d9ab1cd2f | ||
|
|
a0918496f3 | ||
|
|
aea9fcd3a0 | ||
|
|
be95787c00 | ||
|
|
9652bbda59 | ||
|
|
0052061a72 | ||
|
|
db189e3852 | ||
|
|
bb3b40ff15 | ||
|
|
5c138ca851 | ||
|
|
fc4031e65f | ||
|
|
165ab03ea6 | ||
|
|
3c196fffc3 | ||
|
|
fd9544f16b | ||
|
|
835d1538e0 | ||
|
|
9b50f5fb64 | ||
|
|
d33d6764fc | ||
|
|
515ed0abd0 | ||
|
|
d7dc609553 | ||
|
|
c9372de69b | ||
|
|
ecebc97800 | ||
|
|
b6ef4d544a | ||
|
|
1ca4d4ef4d | ||
|
|
5d2d6e5801 | ||
|
|
30d0b103f6 | ||
|
|
8ad1766390 | ||
|
|
648ff27b5f | ||
|
|
1e7c72ccbe | ||
|
|
5c27882995 | ||
|
|
ad787ab22d | ||
|
|
ae71aecb83 | ||
|
|
1a0d530711 | ||
|
|
29f162dac0 | ||
|
|
5bd961f7f1 | ||
|
|
f842fa0029 | ||
|
|
0b5405117d | ||
|
|
2bace68af8 | ||
|
|
f903ac77d6 | ||
|
|
8014255541 | ||
|
|
33ee02e747 | ||
|
|
7ee0b457a3 | ||
|
|
d478780e43 | ||
|
|
76fa7df264 | ||
|
|
acd4a8313c | ||
|
|
cb6f2cdc8f | ||
|
|
6f092a6b1a | ||
|
|
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 | ||
|
|
cd737a7f3f | ||
|
|
28d20a46c0 | ||
|
|
10fdeae409 | ||
|
|
562bc96f7e | ||
|
|
9c8bdc4aa5 | ||
|
|
bd49fcc3cd | ||
|
|
c8fc6c7cbf | ||
|
|
6b4fed1010 | ||
|
|
05f67cd937 | ||
|
|
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 |
@@ -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.2 + Chrome 151(macOS)。
|
||||
Example: DSH 0.1.1-rc.2 + Chrome 151 (macOS).
|
||||
placeholder: "DSH 版本 / version + 浏览器 / browser(如 0.1.1-rc.2 + 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,118 @@
|
||||
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.
|
||||
# NOTE: DSH 0.1.2-alpha.1 (Remote gateway slash endpoints + one-time-
|
||||
# token browser auth) is NOT on npm yet — the lanes speak BOTH dialects
|
||||
# (tests/e2e/host-protocol.ts, dot-first with slash fallback), so bump
|
||||
# this pin once @deepseek-ai/dsh@0.1.2-alpha.1 publishes.
|
||||
- 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/
|
||||
|
||||
|
||||
@@ -9,8 +9,38 @@ 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 的 `tasks.output` 事件回放:读会话事件日志而非动注册表);如果确实做不到,先向用户说明取舍,而不是直接改 DSH。
|
||||
- **DSH 市场受管安装兼容约束**:发布清单的 `dependencies` / `peerDependencies` / `optionalDependencies` 三字段**一律不得出现 `cordis`**(市场预览按名硬拒,optional 无效),且 `scripts` 不得含 `preinstall` / `install` / `postinstall` / `prepare`。回归由 `tests/market-manifest.spec.ts` 守护——违反即市场目录拿不到 `repository_backlink` 验证目标。
|
||||
- 需要 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` 会整体替换默认值)。
|
||||
|
||||
**DSH 0.1.2-alpha.1 适配(双协议 e2e)**:0.1.2-alpha.1(GitHub tag `dsh-v0.1.2-alpha.1`,**尚未发布 npm**,故 CI 钉版暂不动,发布后再平移)有两项破坏性变更,e2e lane 已经双版本兼容:
|
||||
|
||||
1. **一次性 token 鉴权**:`dsh web` 就绪行变为鉴权 URL `dsh web: http://127.0.0.1:<port>/?token=<43字符>`(页面导航它换取签名 cookie,干净 URL 一律 401)。`scripts/e2e-mount.sh` 的 URL grep 必须延伸到空白(`[^ ]*`——在 `/` 截断会丢 token);lane 统一经 `tests/e2e/host.ts`:`PAGE_URL`/`pageUrl({...})` 做 goto(query 与 token 合并,绝不裸拼 `?`),`createHostApi()` 先用 token URL 换 `Set-Cookie`(`redirect: 'manual'`)再建带 cookie 的 APIRequestContext。插件自有 `/sidebar/*` 路由**不受**影响(webserver carrier 无鉴权层,仅 `/api`、index HTML 与 remote.mux 升级在 browser auth 后面),浏览器内同源 fetch 照旧。
|
||||
2. **Remote gateway 取代 ApiProxy**:点分端点 `POST /api/workspace.create`(payload=裸参数)被斜杠端点 `POST /api/workspace/create` 取代——payload 必须恰为 `{args: {...}}`,**args 按控制器的 TS 参数名包装**(`workspace/create`、`session/create` 的参数名是 `request` → `{args:{request:{...}}}`;`session/list` 的参数名是 `_request` 且**不可省略**——`{}`/`{request:{}}` 都被 typert 网关以 `args fields do not match the descriptor` 拒绝),envelope `method` 与路径一致,点分路径不再被认领(404)。`hostRpc(api, 'workspace.create', args)` 点分优先(已发布宿主零探测开销)、404 回退斜杠并缓存;参数名映射表 `SLASH_ARGS_KEY` 与全部契约由 `tests/e2e-host-protocol.spec.ts` 单测锁定(形状经真机 0.1.2-alpha.1 验证)。
|
||||
|
||||
另:`@deepseek-ai/dsh-client-runtime` 包在 0.1.2-alpha.1 被移除(继任 seed 是裸名 `@deepseek-ai/dsh-client-store`,无 `/client` 子路径)。插件源码对它零引用(chunk externals 白名单里的 `dsh-client-runtime/client` 解析失败会被 `buildExternalsRequire` 宽容跳过,见 `src/client/chunk-loader.ts` 头注释),已从 peerDependencies 移除;`dsh.client.inject` 里保留该条目无害(inject 目标不在 boot graph 时两版宿主都静默跳过)。
|
||||
|
||||
**运行时组件契约(真机验证过的一处破坏)**:`MarkdownText` 的 chrome labels prop 在 0.1.2-alpha.1 从扁平可选的 `codeLabels` 改名为**必填嵌套**的 `labels: { code: { copyLabel, copiedLabel }, footnotes }`——只传旧 prop 时 fence 渲染抛 `Cannot read properties of undefined (reading 'code')`。插件四个渲染点(mermaid.tsx / MarkdownHtml.tsx / TextEditor.tsx / SideChatView.tsx)统一改走 `src/client/markdown-labels.tsx` 的 `markdownTextProps()`(双形状对象 + 双 prop 名,两版通吃)。另注意 token 换 cookie 的 303 会**丢弃同 URL 的其它 query 参数**——e2e 的带 stamps 导航统一走 `host.ts` 的 `gotoPage()`(先给浏览器上下文 addCookies 再直达带参 URL,见 host.ts 注释)。
|
||||
|
||||
### 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` 只打包验证、不发版。
|
||||
|
||||
---
|
||||
|
||||
@@ -19,7 +49,7 @@ better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context
|
||||
- **服务名**:`betterSidebar`(即 `ctx.betterSidebar`)
|
||||
- **发布侧**:better-sidebar 的 client half(`src/client/index.tsx`,通过 `ctx.provide('betterSidebar', service)` 发布)
|
||||
- **消费侧**:你的插件的 client half(`inject = ['betterSidebar', ...]`,然后 `ctx.betterSidebar.registerTab(...)`)
|
||||
- **类型合并**:`declare module 'cordis' { interface Context { betterSidebar: BetterSidebarService } }` 由 `dsh-better-sidebar` 包导出;消费插件 `import type {} from 'dsh-better-sidebar'` 即触发类型合并
|
||||
- **类型合并**:`declare module '@deepseek-ai/cordis' { interface Context { betterSidebar: BetterSidebarService } }` 由 `dsh-better-sidebar` 包导出;消费插件 `import type {} from 'dsh-better-sidebar'` 即触发类型合并
|
||||
|
||||
> ⚠️ **host 半不发布此服务**:`ctx.betterSidebar` 只在 client 侧存在。如果你的插件 host 半需要读 better-sidebar 状态,走 better-sidebar 自己的 HTTP/WS 路由(`/sidebar/api/*`),不走服务。
|
||||
|
||||
@@ -33,7 +63,7 @@ better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context
|
||||
{
|
||||
"name": "my-plugin",
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/cordis": "^4.0.1",
|
||||
"dsh-better-sidebar": "workspace:*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -86,7 +116,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 '@deepseek-ai/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` 也能编译。
|
||||
|
||||
---
|
||||
|
||||
@@ -102,7 +141,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
|
||||
@@ -116,28 +155,118 @@ 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' / 'agentOpenTools' / '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+):给出时**追加**渲染在行列表(toggles /
|
||||
* pluginToggles)之后,与行列表共存;未声明行列表时单独渲染。
|
||||
* 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
|
||||
}
|
||||
@@ -153,7 +282,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
|
||||
@@ -202,22 +331,41 @@ 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 | 是 | 否 | 子代理拓扑 |
|
||||
| `terminal` | 40 | 否 | 否 | 终端(nextTerminal 自增) |
|
||||
| `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 自增)。v0.17.0+ 支持右键「固定到工作区 / 固定到全局」:固定后切换会话不消失,在 TabBar 内联呈现为虚拟 Tab(跨会话注入第一个 leaf 的 tabs 尾部,virtual id = `pinned:<homeSessionId>:<tabId>`,meta 携带 home scope);点击就地激活(TerminalView 按 home sessionId+tab 连接宿主 PTY,不跳转会话);global 任意会话可见;workspace 仅同 cwd 会话可见;Agent 终端(`agent:<uuid>`)被 reconcile 移除时豁免;pin 元数据 `tab.pin = { scope, homeCwd? }` 随宿主会话 state 持久化,渲染期跨会话解析(`collectPinnedTabs` → `createPinnedVirtualTab` → `injectPinnedIntoTree`),不写跨会话投影 |
|
||||
| `browser` | 50 | 否(createTab 铸造 browser:`<n>`,nextBrowser 自增) | 否 | 内嵌网页浏览器(沙箱 iframe;可设置关闭沙箱) |
|
||||
| `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(由 GitView 触发) |
|
||||
|
||||
@@ -245,8 +393,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) }[] }
|
||||
/** 渲染函数 */
|
||||
@@ -268,6 +417,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
|
||||
}
|
||||
```
|
||||
|
||||
@@ -277,7 +431,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` | 不预览,显示下载按钮 | (无) | 无客户端渲染器的二进制格式 |
|
||||
|
||||
@@ -292,7 +446,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,**README 级内嵌 HTML 支持**——块级 HTML 段按文档顺序 DOMPurify 白名单消毒内联渲染、`<details>` 跨段嵌套内部 markdown、markdown 表格单元格等处的内联标签消毒还原为元素、本地媒体 src 重写走 `/sidebar/file`;预览顶部浮动目录大纲按钮(≥3 标题出现,点击条目平滑滚动并自动展开折叠 `<details>`;纯 markdown 文档走原单次解析路径零变化。实现 `src/client/markdown-html.ts` / `MarkdownHtml.tsx` / `md-toc.tsx`,设计文档 `docs/plans/2026-08-24-markdown-html-toc-design.md`) / 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 注册示例
|
||||
@@ -369,22 +523,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' | 'floatWindows'——消费插件用 `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` / `agentOpenTools` / `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 起也有设置条)。
|
||||
|
||||
---
|
||||
|
||||
@@ -402,16 +598,60 @@ interface BetterSidebarService {
|
||||
| 陷阱 | 说明 |
|
||||
|---|---|
|
||||
| **构建纯度门** | client bundle 禁止 value-import `@dsh-external/*` 或非白名单的 `@deepseek-ai/*`;类型 `import type {}` 会被擦除,不触发门禁 |
|
||||
| **双 cordis 实例** | 外部插件解析不到 DSH monorepo 的 cordis augmentation;better-sidebar 自己重述了 `interface Context { betterSidebar: ... }`,你 `import type {}` 即拿到类型 |
|
||||
| **统一 cordis 分支(v0.15.2+)** | 类型基底与 augmentation 全部落在 DSH 运行时正主 `@deepseek-ai/cordis`(`src/context-types.ts`:真实 cordis `Context` 与结构化服务面做**交集**,不再重述 `declare module 'cordis'`——DSH host/client 包对同一成员声明不同类型(如 `sessions`),合并会 TS2717 冲突)。消费者 `import type {} from 'dsh-better-sidebar'` 即拿到 `ctx.betterSidebar` augmentation,或直接 `import type { Context } from 'dsh-better-sidebar'`。**公开版 `cordis` 不再被依赖**(市场规则拒绝依赖字段出现 `cordis`,见 §0 硬约束) |
|
||||
| **ModuleLoader 不跨插件** | 运行时 `require()` 虽支持跨 bundle,但被构建门挡;所有交互走 `ctx.betterSidebar` 方法调用 |
|
||||
| **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`) |
|
||||
| **懒加载 chunk** | 内置重依赖(Univer/xterm/CodeMirror/pptx/docx)在独立 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` 纯渲染函数)不变 |
|
||||
| **家族右面板互斥(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` 时**必须**经 `src/client/markdown-labels.tsx` 的 `markdownTextProps(text, { copyLabel: t('copy'), copiedLabel: t('copied') })`——该组件 cordis-free,漏传则代码块复制按钮回退硬编码中文;且该 helper 同时满足两代 prop 契约(0.1.1-rc.x 的扁平可选 `codeLabels` 与 0.1.2-alpha.1+ 改名后**必填嵌套**的 `labels: { code: {copyLabel, copiedLabel}, footnotes }`——只传旧 prop 在 alpha.1+ 上崩溃 `reading 'code'`,双形状对象 + 双 prop 名两版通吃) |
|
||||
| **第三语言覆盖(ja 等)** | 可选 peer `@huanlin/dsh-plugin-better-locale`(`peerDependenciesMeta.optional: true`)提供 ja/ko/... 等第三语言覆盖。覆盖**借用 DSH 的英文槽位**:`store.getOverride(dshActive, ns, key)` 只在 `dshActive === 'en'` 时返回覆盖文本,DSH 在 zh 下覆盖完全惰性(保持原生 zh)。安装后 `apply()` 调 `ctx.get('betterLocale')` 取得 override store 并经 `attachBetterLocale()` 注入 `t()`:`t()` 先查 `store.getOverride(active, 'betterSidebar', key)`,命中则返回覆盖文本(如 ja),否则走原 zh/en 链;`isZh()` 改用 `store.isOverrideActive(active)` 判断(仅在 DSH=en 且有 override 时返回 false)。Sidebar root 另订阅 store 的 `subscribe()`(uSES on `store.active`),覆盖切换时全树重渲染(DSH locale 的 `active` 字段不变,所以既有 `localeRevision` uSES 不会触发)。本插件同时把 ja 词典 `register('betterSidebar', { ja })` 进 better-locale 的 store,让外部 `ctx.locale.lookup('betterSidebar', key)` 调用者也能拿到 ja 文本。better-locale 的 LanguageSwitcher 在用户选了非 native 且 DSH 非 en 时显示「请将 DSH 切换到英文以查看 [语言名]」提示。未安装 better-locale 时 `ctx.get` 返回 undefined,整段为 no-op,zh/en 行为不变。新增 zh key 时**必须**同步加 ja 翻译(`src/client/locales-ja.ts`),否则该 key 在 ja 覆盖下回退到 en |
|
||||
| **懒加载 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. 完整最小示例
|
||||
## 7.5 自由窗口(v0.16.0)
|
||||
|
||||
任意 tab(内置或插件注册的)可拖出侧边栏,成为悬浮在主会话区域上的**自由窗口**(free window):
|
||||
|
||||
- **拖出**:把标签栏的 tab 拖到**主会话区域**(conversation 列)上——虚线提示浮层标记落区,松开即浮动(`floatTab` reducer:tab 从 pane **移动**进 `SidebarState.floats`,窗口以默认尺寸 390×780(手机竖屏比例,创建时先按视口减 24px 钳高)居中于松点、钳入视口;清空的 pane 折叠)。检测在 `src/client/Sidebar.tsx`:document 捕获 `dragover`/`drop`,以 `body[data-dsh-tab-dragging]`(TabBar 维护)门控,目标矩形取 `#root [data-slot="conversation"]` 的父列;窄视口(合并抽屉)禁用手势。tab 右键菜单的**「移动到自由窗口」**(两面板共用 TabBar,一处生效)始终可用,窗口落在会话列中心。
|
||||
- **窗口操作**(`src/client/FreeWindow.tsx`,渲染在 `[data-dsh-panel-host]` 内、z-index 42):头部 pointer 拖动移动(rAF 直写 DOM + 松手提交 store 的面板拖拽模式);拖动中指针落在 pane(`[data-dsh-pane]`)上时该 pane 高亮(`data-dsh-float-dock-over`),松开即**停靠**(center 合并进该 pane,`dockFloat`);右下角缩放(`FLOAT_MIN_W/H` = 320/200,SE 角锚定);点击任意处置顶(层叠顺序 = `floats` 数组序,`raiseFloat`);头部右键菜单「回到侧边栏 / 关闭」;X = `closeTab`(关闭 tab 与窗口,正常触发 `onClose` 生命周期、释放终端)。
|
||||
- **持久化**:`floats` 随会话进 localStorage(`dsh-sidebar:v1:<sessionId>`),刷新原样恢复;`sanitizeState` 对每条 float **宽容校验**(非法条目单独丢弃不动整体布局,几何钳入当前视口,diff/ephemeral tab 不持久化,旧文档缺字段 = `[]`);`maxCounterId` 的 uid 播种覆盖 `float:N` 前缀。
|
||||
- **服务语义**(消费插件可见,`features` 含 `'floatWindows'`):`openTab` 的 dedupeKey/id 聚焦命中**浮动中的 tab = 置顶其窗口**(不重复开、不展开面板);`closeTab`/`activateTab` 对浮动 tab 分别关窗/置顶并照常触发 `onClose`/`onActivate`;agent 终端 reconcile 会随宿主列表移除已消失的浮动终端窗口。tab 内容复用常规 `renderTab`(`TabContent`),浮窗对 `visible` 恒为 true,所有 tab 类型无差别浮动。
|
||||
- **稳定寻址面**(皮肤/CSS 可用):`[data-dsh-float-window]`(窗口本体)、`[data-dsh-float-id]`(窗口 id)、`[data-dsh-pane]`(每个 pane)、`[data-dsh-float-dock-over]`(停靠高亮)、`[class*='floatDropHint']`(拖出提示)。视觉全令牌驱动(`--dsw-alias-bg-layer-1` 表面、`--dsw-shadow-lv3` 阴影、无硬编码色),头部带 `-webkit-app-region: no-drag`(§8 拖拽区退出规则)。
|
||||
- **i18n**:`moveToFreeWindow` / `floatDropHint` / `dockToSidebar`(19 语言词典已全量同步)。
|
||||
- **⚠️ portal 事件劫持陷阱**:浮窗头部/缩放把手是 pointer 拖拽表面,但其 React 子树含 portal 覆盖层(头部 `Menu` portal 到 `document.body`)——portal 后代的合成事件**沿 React 树冒泡**回拖拽表面的 `onPointerDown`,会被误判为拖拽开始(preventDefault 吞点击 + setPointerCapture 抢走 pointer,菜单项/X 按钮在真实浏览器中失灵;jsdom 合成事件不走此路径,只有 e2e 能抓)。因此 `FreeWindow.tsx` 的拖拽起点必须带**同源守卫**:`event.currentTarget.contains(event.target)` 为假或目标在 `button` 内时直接返回。任何「拖拽表面内嵌 portal 覆盖层」的新组件都要同样设防(回归测试:`tests/free-window.spec.tsx` 的 portaled-menu 用例)。
|
||||
|
||||
---
|
||||
|
||||
## 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:25; 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**:面板宿主层 25、折叠按钮簇 45(host 内部层叠;角手柄在面板内层叠,z-index 2 仅面板内有效)——全部低于 DSH 的 ui-cordis 动态插件面板(fixed,30,其清单/审批面不得被工作台遮挡)与 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` 文件预览器。
|
||||
|
||||
@@ -426,7 +666,7 @@ interface BetterSidebarService {
|
||||
"./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }
|
||||
},
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/cordis": "^4.0.1",
|
||||
"dsh-better-sidebar": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"react": "^18.2.0"
|
||||
@@ -441,7 +681,7 @@ interface BetterSidebarService {
|
||||
```tsx
|
||||
import { createElement } from 'react'
|
||||
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
|
||||
export const inject = ['betterSidebar']
|
||||
|
||||
@@ -483,19 +723,23 @@ 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` / `node_modules` / 构建缓存 / symlink 目录;测试 `tests/fs-search.spec.ts`、组件测试 `tests/editor-host.spec.tsx`)
|
||||
- **`src/client/markdown-html.ts`** / **`src/client/MarkdownHtml.tsx`** / **`src/client/md-toc.tsx`**:markdown 预览的内嵌 HTML 渲染(fence 感知切分器 + DOMPurify 消毒管线 + 跨段 `<details>` 嵌套状态机 + 内联标签 commit 后还原 + MutationObserver 迟到内容)与目录大纲(DOM 收集 + sticky 浮动按钮 + 跳转展开折叠块;注意 `md-toc.tsx` 头注释记录的「子组件读父 ref 为 null」React 时序陷阱)。测试 `tests/markdown-html.spec.ts` / `tests/markdown-html-render.spec.tsx` / `tests/md-toc.spec.tsx`,设计文档 `docs/plans/2026-08-24-markdown-html-toc-design.md`
|
||||
- **`src/agent-opens.ts`** / **`/sidebar/ws/agent-opens`**:模型主动打开(`sidebar_open` 工具 + `agentOpenTools` 设置,默认关闭)——会话级队列推送注册表 + 工具注册;客户端在 Sidebar.tsx 订阅推送并路由到 editor(文件/文件夹窗口)/browser(HTTP(S) 网页)tab,文件夹窗口是 `meta.dir: true` 的 editor tab(全窗树以目录为根);设计文档 `docs/plans/2026-08-23-agent-open-tools-design.md`
|
||||
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`** / **`docs/plans/2026-08-14-add-plugins-modal-design.md`**:设计文档(含实施偏差记录)
|
||||
|
||||
调试时直接读这些文件即可看到所有 API 的真实用法。
|
||||
|
||||
@@ -1,79 +1,147 @@
|
||||
# dsh-better-sidebar
|
||||
|
||||
> 一个插件,一套完整工作台:
|
||||
>
|
||||
> 文件管理、编辑预览、内嵌浏览器、真实终端、Git 面板、任务管理页——**右侧栏 + 底部面板双工作台**,一个插件全部搞定。
|
||||
>
|
||||
> 支持Tab窗口随意拖拽,支持三方拓展注册新Tab页面和文件预览
|
||||
<!-- 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 · 0.1.2-alpha.1" 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_%C2%B7_0.1.2--alpha.1-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>
|
||||
|
||||
https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86
|
||||
<div align="center">
|
||||
🌏 <a href="./README.md"><b>中文</b></a> · <a href="./README_EN.md">English</a>
|
||||
</div>
|
||||
|
||||
<img width="2630" height="1794" alt="6c4293e1bec2e935031bf0e986d6ec65" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
|
||||
<div align="center">
|
||||
<img alt="dsh-better-sidebar 工作台截图" src="https://github.com/user-attachments/assets/991c4b70-d45a-461f-a8c8-0a28b4218e60" />
|
||||
<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 不丢草稿
|
||||
- **⚡ 客户端懒加载**:Office / 终端 / 代码编辑器等重依赖按需分块加载——启动只拉 ~325KB 核心,打开 .xlsx 才拉 Univer(~20MB)、打开 .docx 只拉 docx 预览器、打开终端才拉 xterm;首次打开短暂 loading 后即用(详见 `docs/plans/2026-08-12-lazy-chunks-design.md`)
|
||||
- **🌐 浏览器**:内嵌网页浏览 tab(多开),后退/前进/刷新 +「在浏览器中打开」;页面在**沙箱 iframe** 中运行(不透明源:无法访问界面数据与本地文件,拒绝 localhost 等本机地址),界面实时显示沙箱状态、可临时解锁(关闭时红色警示);被站点拒绝嵌入(X-Frame-Options)时显示原因面板;聊天/界面里的 http(s) 外链默认在侧边栏打开(侧边栏折叠时自动展开面板)
|
||||
- **💻 终端**:xterm.js + node-pty 真实 shell(每会话 3 个 UI 上限)、Tab 保活重连回放;可选为模型注入 8 个 `terminal_*` 工具
|
||||
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、懒加载历史、右键暂存/放弃/提交/还原/捡取
|
||||
- **🧩 任务管理页**:主会话完整 agent 拓扑、点击直达执行记录、实时工具调用轮询、新子代理自动展开;**同页显示后台命令**(当前树全部任务,bash/pwsh 类型徽标 + 退出码,点击查看实时输出——自动跟随底部、非消费 peek,不干扰模型的 `task_output`;两击确认可强制终止)
|
||||
- **🪟 底部面板**:独立的第二个工作台(与右侧栏同类的标签页),只挤占中间 Agent 输出区、不覆盖左右侧边栏;**首次展开自动开一个新终端**(终端卡片二级设置可关);右上角 x 一键折叠
|
||||
- **📱 移动端**:视口 < 768px(真正的移动端宽度,不对齐宿主 1024 断点)时只显示右侧栏——进入窄屏时底部面板的标签页**直接并入右侧栏标签条**,右上角只剩一枚开关,面板为全宽抽屉;新会话默认收起,聊天里点文件/外链自动展开,不挤压对话区
|
||||
- **🔧 分栏工作台**:拖 Tab 拆分/合并分栏(可**跨面板拖 Tab**)、分隔线调比例;右上角持久按钮簇(底栏 + 侧拉 glyph)折叠/展开两个面板;两面板共享拐角双向拖动调节尺寸,拖动 rAF 直写 DOM 保持流畅
|
||||
- **🔁 会话隔离**:布局/分栏/Tab/两面板状态按会话持久化(localStorage),陈旧状态自动净化;聊天「产出文件」改在侧边栏打开(面板折叠时自动展开)
|
||||
- **⚙️ 声明式设置**:设置页「侧边卡片」分区按注册表渲染功能清单(小卡片网格,高亮 = 启用),每项可独立开/关;二级设置(子代理自动展开、终端工具、底部面板首展自动开终端、沙箱开关等)经齿轮按钮在原生弹窗中编辑
|
||||
- **🔌 服务化**:暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏 tab 与文件预览器(内置 7 tab + 9 viewer 也走同一服务,详见 [AGENTS.md](./AGENTS.md))
|
||||
- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大;README 级内嵌 HTML——徽章墙 / `<details>` 折叠 / 表格内联标签经 DOMPurify 消毒真实渲染;浮动目录大纲一键跳转)/ HTML / PDF
|
||||
- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整)
|
||||
- **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具
|
||||
- **📂 模型侧边栏打开(可选)**:全局设置开启后注入 `sidebar_open` 工具——模型可主动在侧边栏打开文件 / 文件夹(树以该目录为根)/ HTTP(S) 网页
|
||||
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原;工作区容器下自动发现子仓库并显示**仓库选择器**,支持 linked worktree 变更发现
|
||||
- **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止)
|
||||
- **💬 侧边对话(beta)**:Codex 风格的侧边线程——继承主会话完整上下文(含进行中的回合与工具调用)独立运行,不进入主会话;线程内可持续追问,一键「保存为新会话」提升为顶层会话
|
||||
- **🪟 双工作台**:右侧栏 + 底部面板;拖 Tab 拆分 / 合并分栏(可跨面板),移动端自动合并全宽抽屉
|
||||
- **🪟 自由窗口**:把标签栏的任一 tab 拖到主会话区域——成为可移动 / 缩放 / 置顶的悬浮窗口(默认 390×780),拖回侧边栏 pane 即停靠,随会话持久化;`features` 含 `'floatWindows'`,插件 tab 无差别支持
|
||||
- **📌 固定终端**:右键终端 Tab 可「固定到工作区 / 固定到全局」——固定后切换会话不消失,在 TabBar 内联呈现(跨会话虚拟 Tab,点击就地激活,PTY 按 home 会话 id+tab 直连宿主 PTY,无需切回宿主会话);Agent 终端被 reconcile 移除时豁免保留
|
||||
- **🔁 会话隔离**:布局 / Tab / 面板按会话持久化,陈旧状态自动净化
|
||||
- **⚙️ 声明式设置**:设置页「侧边卡片」逐项独立开关,二级设置经齿轮弹窗
|
||||
- **⚡ 按需加载**:启动只拉 ~325KB 核心,终端 / 编辑器 / Mermaid 图表等重依赖用到才按需拉取([设计文档](docs/plans/2026-08-12-lazy-chunks-design.md))
|
||||
- **🌏 多语言**:界面文案跟随 DSH 语言(zh / en)实时切换;安装 `@huanlin/dsh-plugin-better-locale` 后支持日语(ja)等第三语言覆盖(见下方「🌏 第三语言覆盖」)
|
||||
|
||||
## ⌨️ 快捷键
|
||||
|
||||
| 操作 | 按键 |
|
||||
|---|---|
|
||||
| 保存编辑 | `Ctrl/Cmd + S` |
|
||||
| Git 提交 | `Ctrl + Enter` |
|
||||
| 关闭 Tab | 鼠标中键 |
|
||||
| 拆分/合并分栏 | 拖 Tab 到分栏边缘 / 中间 |
|
||||
| 引用文件到输入框 | 悬浮行尾 `@文件` 按钮 |
|
||||
| 复制文件路径 | 右键行 → 复制相对/绝对地址 |
|
||||
> 🔌 **核心理念**:服务优先——内置的 7 tab + 6 viewer 与第三方插件通过同一套 `ctx.betterSidebar` API 注册,能力完全对等;官方不再内置、可由生态提供的功能,交由生态插件实现(已有 **28+ 生态插件**,见下方「🌐 插件生态」)。接入文档见「🔌 服务化扩展」与 [外部插件接入指南](./docs/external-plugin-guide.md)。
|
||||
|
||||
## 🚀 安装
|
||||
|
||||
前置:已安装 DSH(`dsh web` 可运行),Node.js ≥ 20、pnpm ≥ 10。把下面提示词**整段**发给 DSH 即可自动完成克隆、构建、注册与安装:
|
||||
**前置**:已装好 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 · 0.1.2-alpha.1" 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_%C2%B7_0.1.2--alpha.1-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 插件安装到我的 web profile(插件 = VSCode 风格右侧侧边栏,仓库 https://github.com/dsh-external/DSH-better-sidebar):
|
||||
帮我安装 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 的常见问题表。
|
||||
```
|
||||
|
||||
1. 克隆并构建:
|
||||
git clone https://github.com/dsh-external/DSH-better-sidebar.git ~/Code/DSH-better-sidebar
|
||||
<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
|
||||
(@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)验证
|
||||
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 半改动才需重启)
|
||||
```
|
||||
|
||||
> 安装 = 依赖登记(等价 `dsh plugin --profile web add link:<路径>`)+ 一行挂载行。**DSH 以 npm 包启动(如 `npx -p @deepseek-ai/dsh@0.0.1-rc.1 dsh web`)同样可用**(v0.4.3 起实测验证)。
|
||||
更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.16.1"` 再 `pnpm install`。
|
||||
|
||||
### 更新
|
||||
</details>
|
||||
|
||||
```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 安装(可选,与上述二选一)
|
||||
<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/dsh-external/DSH-better-sidebar.git && cd DSH-better-sidebar
|
||||
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 # 安装(默认禁用)
|
||||
@@ -82,9 +150,24 @@ 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 与文件预览器
|
||||
</details>
|
||||
|
||||
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 7 tab + 9 viewer 也走同一服务,吃自己的狗粮):
|
||||
## 🖼️ 特性巡礼
|
||||
|
||||
> 以下均为真实界面实拍(每行两张,点击可放大)。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **🗂️ 文件工作台:资源管理器**<br/><sub>支持两种格式的资源管理器:内嵌在文件预览中 / 独立显示文件树。懒加载目录树、软链接按目标类型展示(目录软链接可展开、失效链接标红)、全局文件名搜索、上传文件/文件夹与拖放上传、右键菜单(在新 Tab 打开 / 在侧边打开 / 复制路径)、悬浮 `@文件` 一键引用进输入框。</sub><br/><div align="center"><img width="420" alt="文件资源管理器" src="https://github.com/user-attachments/assets/a410bfd2-a8ba-43e6-873e-22417756e94d" /></div> | **📝 Markdown · 图片 · PDF 内联预览**<br/><sub>Markdown 预览支持 **Mermaid 图表**(`securityLevel: 'strict'` 安全渲染 + 二次清洗;点击图表弹窗放大、滚轮缩放、拖拽平移)、**README 级内嵌 HTML**(徽章墙 `<div align=center>`、`<details>` 折叠块内嵌 markdown、表格单元格内联标签——DOMPurify 白名单消毒真实渲染,`<script>` 等活性内容剥除,本地图片经会话媒体路由重写)与**浮动目录大纲**(≥3 标题出现,点击平滑跳转、自动展开折叠块);图片 / PDF 走媒体路由内联展示;Office 三件套由生态插件补齐。</sub><br/><div align="center"><img width="420" alt="Markdown + Mermaid 预览" src="https://github.com/user-attachments/assets/fe0e5182-55bb-45cc-b98b-a2877c2bdd38" /></div> |
|
||||
| **🖥️ CodeMirror 代码编辑器**<br/><div align="center"><img width="420" alt="CodeMirror 代码编辑器" src="https://github.com/user-attachments/assets/b44b488e-568c-4ee0-b96c-e9c906598a77" /></div> | **🖼️ 图片内联预览**<br/><div align="center"><img width="420" alt="图片内联预览" src="https://github.com/user-attachments/assets/f9a58c30-5b7a-48b5-9e22-37d7e071f593" /></div> |
|
||||
| **💻 真实终端**<br/><sub>xterm.js + node-pty 真实 shell(不是模拟器):断线重连 transcript 回放、shell / shellArgs 可配置(设置页或 `cordis.patch.yml`)、可选为模型注入 `terminal_*` 工具(agent 可直接开终端跑命令)。</sub><br/><div align="center"><img width="420" alt="真实终端" src="https://github.com/user-attachments/assets/0dad6ad3-ff3f-4b5a-86d2-f832ce65323e" /></div> | **🌿 Git 面板**<br/><sub>暂存 / 取消暂存 / 提交(`Ctrl+Enter`)/ 还原,历史列表;点击改动文件打开 **VSCode 式 diff tab**(红绿行级对比)。</sub><br/><div align="center"><img width="420" alt="Git 面板" src="https://github.com/user-attachments/assets/e7fc1220-305f-4bca-8583-e77ab4f4fa78" /></div> |
|
||||
| **🌐 内嵌浏览器**<br/><sub>多开网页 tab:后退 / 前进 / 刷新 / 地址栏;内容运行在**不透明源沙箱 iframe**(界面实时显示沙箱状态,可按页面临时解锁);聊天里的外链点击可被接管到侧边栏打开(按协议分流,可配)。</sub><br/><div align="center"><img width="420" alt="内嵌浏览器" src="https://github.com/user-attachments/assets/9bc6b65a-64fc-4942-a685-76e391e55606" /></div> | **🧩 任务页:子代理拓扑 + 后台任务**<br/><sub>子代理树实时拓扑(运行状态、批量实时预览)+ 后台任务清单(退出码 / 实时输出 / 强制终止);新子代理 / 新任务可自动展开侧边栏(可关)。</sub><br/><div align="center"><img width="420" alt="任务页:子代理拓扑" src="https://github.com/user-attachments/assets/dcd8ed2f-59fa-405b-937b-2d250f5034dd" /></div> |
|
||||
| **💬 侧边对话(beta)**<br/><sub>Codex 风格侧边线程:**每个对话一个独立 Tab**;线程继承主会话完整上下文(含进行中回合,以 interrupted 诚实冻结)独立运行,不污染主会话;可持续追问、重启冷恢复;一键「保存为新会话」提升为顶层会话。</sub><br/><div align="center"><img width="420" alt="侧边对话(beta)" src="https://github.com/user-attachments/assets/3a338c36-f5de-4000-95f3-4b1cd04f60fc" /></div> | **🪟 双工作台:右侧栏 + 底部面板 + 分栏**<br/><sub>右侧栏与底部面板可同时展开;拖 Tab 到分栏边缘**拆分**、拖到中间**合并**(可跨面板);面板宽高左缘/上缘拖拽调节;移动端自动合并为全宽抽屉;把 tab 拖到主会话区域可变为**自由窗口**(悬浮 / 缩放 / 置顶,拖回 pane 停靠)。</sub><br/><div align="center"><img width="420" alt="双工作台(右侧栏 + 底部面板)" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" /></div> |
|
||||
| **⚙️ 声明式设置**<br/><sub>设置页「侧边卡片」分区:每个 tab / 预览器一张小卡片,独立开关(高亮启用态 + 品牌开关滑块);二级设置经卡片底部「功能设置」条弹窗(开关 / 文本 / 数字 / 下拉);插件自有设置持久化在 `pluginSettings`。</sub><br/><div align="center"><img width="420" alt="声明式设置:侧边卡片" src="https://github.com/user-attachments/assets/0800ca64-621e-48da-b7df-aecfddc3ec29" /></div> | **📱 移动端**<br/><sub>窄屏(<768px)自动切换为全宽抽屉:底栏 tab 一次性并入右侧栏,触屏拖拽可调。</sub><br/><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 类型合并
|
||||
@@ -93,17 +176,293 @@ 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} />,
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
完整接入文档(`TabDescriptor` / `FileViewerDescriptor` 全字段、匹配算法、HMR 陷阱、声明式设置):见 [`AGENTS.md`](./AGENTS.md)。
|
||||
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>24 个插件(点击展开)</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 + 侧栏分支 + 全局仪表盘 |
|
||||
| [Fisfzy/ego-browser](https://github.com/Fisfzy/ego-browser) | <img alt="stars" src="https://img.shields.io/github/stars/Fisfzy/ego-browser?style=flat&color=4d6bfe" /> | Agent 浏览器:i18n 感知的本机浏览器 Tab(`@dsh-external/ego-browser`,装了 better-sidebar 自动注册侧边栏页,未装回退浮动浮窗观察) |
|
||||
| [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` 守护)。
|
||||
|
||||
## 🆕 最近更新
|
||||
|
||||
**支持的 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 · 0.1.2-alpha.1" 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_%C2%B7_0.1.2--alpha.1-4d6bfe" /></a> · 完整发布历史见 [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)
|
||||
|
||||
### v0.17.1
|
||||
|
||||
- 🔗 **DSH 0.1.2-alpha.1 适配(双版本兼容)**:DSH 0.1.2-alpha.1 的 Remote gateway、一次性 token 浏览器鉴权与 `MarkdownText` labels 契约变更已全量适配,插件在 0.1.0-rc.8 ~ 0.1.1-rc.2 与 0.1.2-alpha.1 上一致工作(后者经 GitHub tag 源码构建的真机挂载冒烟 14/14 验证;该版本 npm 尚未发布,CI 钉版暂保持 0.1.1-rc.2,发布后平移)。要点:`MarkdownText` 四个渲染点统一改走双形状 labels helper([markdown-labels.tsx](./src/client/markdown-labels.tsx)),修复 alpha.1 上 markdown/mermaid 预览的 `reading 'code'` 崩溃;e2e 冒烟双协议化(token URL 换 cookie、`/api` 斜杠端点 + 按参数名包装 args,[tests/e2e/host-protocol.ts](./tests/e2e/host-protocol.ts));移除已在 0.1.2-alpha.1 消亡的 `@deepseek-ai/dsh-client-runtime` peer
|
||||
|
||||
### v0.16.1
|
||||
|
||||
自 v0.16.0 以来的全部更改:
|
||||
|
||||
**🐛 修复**
|
||||
|
||||
- 🧊 **Git 面板卡死 + 重启死循环**([#376](https://github.com/omdsh-dev/DSH-better-sidebar/pull/376),修复 [#369](https://github.com/omdsh-dev/DSH-better-sidebar/issues/369)):开启「源代码管理」面板可能整页冻结、重启后自动恢复冻结状态且无法退出——三层无上限操作叠加所致,现已全部设界:**① status 截断**——`git status --untracked-files=all` 响应上限 2000 条(超限置 `truncated`,面板显示截断提示,对齐 `fs.read` 截断语义;worktree 变更计数同步有界),海量未跟踪文件不再冻结浏览器主线程;**② 仓库发现限界**——cwd 非 Git 仓库(如家目录)时不再对每个可见子目录串行无界探测:探测超时 30s→5s、子目录探测上限 200 个、并发请求共享同一次扫描并按 60s TTL 缓存,家目录不再引发 `git rev-parse` 进程风暴;**③ 重置逃生通道**——带 `?dsh-sidebar-reset` 打开页面即丢弃持久化布局(含共享宽度)从默认布局启动,即使原页面已卡死也能自救,移除参数后恢复持久化;`statusTruncated` 文案同步全部 19 个词典
|
||||
|
||||
### v0.16.0
|
||||
|
||||
自 v0.15.2 以来的全部更改:
|
||||
|
||||
**✨ 新功能**
|
||||
|
||||
- 🪟 **自由窗口**([#354](https://github.com/omdsh-dev/DSH-better-sidebar/pull/354)):把标签栏的任意 tab(内置或插件注册)**拖到主会话区域**——会话列出现虚线提示浮层,松开即成为悬浮窗口(默认 390×780,手机竖屏比例,创建时按视口钳制后居中于松点);窗口支持头部拖动移动、右下角 SE 缩放(≥320×200)、点击任意处置顶、头部右键「回到侧边栏 / 关闭」、X 走 `closeTab` 正常关闭生命周期(释放终端等);拖到侧边栏 pane 上时该 pane 高亮、松开即**停靠**合并回该 pane;`floats` 随会话持久化(刷新原样恢复,宽容 sanitize + 几何钳入视口);服务语义:`features` 新增 `'floatWindows'`——`openTab` 的 dedupe/id 聚焦命中浮动 tab = **置顶窗口**(不重复开、不展开面板),`closeTab` / `activateTab` 对浮窗正常关窗 / 置顶并照常触发回调,浮窗内 tab `visible` 恒 true,agent 终端 reconcile 覆盖浮窗;tab 内容复用常规渲染、插件 tab 与 pane 完全同契约;附带文件二级页面 8px 网格间距规整([设计文档](docs/plans/2026-08-23-free-window-design.md))
|
||||
- 📂 **模型主动打开(`sidebar_open` 工具)**([#353](https://github.com/omdsh-dev/DSH-better-sidebar/pull/353)):侧边栏新增全局设置 `agentOpenTools`(**默认关闭**),开启后向模型注入**一个**工具——模型可在调用方会话的侧边栏打开本地**文件**(editor tab,按 path 去重)、**文件夹**(全窗树窗口,以该目录为根,`meta.dir`)与 **HTTP(S) 网页**(browser tab,URL 预填);关闭设置即注销工具并清空未投递队列,已打开 tab 保留;非激活会话的打开排队、下次可见时重放(`/sidebar/ws/agent-opens` 推送,同一 trust fence);无新增公共 API、不改变 `BetterSidebarService`([设计文档](docs/plans/2026-08-23-agent-open-tools-design.md))
|
||||
- 📝 **Markdown README 级内嵌 HTML + 目录大纲(TOC)**([#360](https://github.com/omdsh-dev/DSH-better-sidebar/pull/360)):Markdown 预览现在真实渲染**块级内嵌 HTML**——徽章墙 `<div align=center>`、`<details>` 折叠块内嵌 markdown、表格单元格 `<br/>`/`<sub>`/`<img>`、`<video>`/`<picture>` 全部经 DOMPurify 白名单消毒(`<script>` 等活性内容剥除、`<a>` 强制 `_blank rel=noopener`),本地媒体 src 重写为会话媒体路由;≥3 标题出现浮动**目录大纲**按钮,点击平滑滚动并自动展开折叠 `<details>`,HTML 段内标题同样收录;渲染器仍是宿主 `MarkdownText`(shiki / KaTeX / GFM 保留),纯 markdown(零 HTML)文档走原路径零回归([设计文档](docs/plans/2026-08-24-markdown-html-toc-design.md))
|
||||
- 🌏 **第三语言覆盖(19 语言)**([#339](https://github.com/omdsh-dev/DSH-better-sidebar/pull/339)):接入可选 peer `@huanlin/dsh-plugin-better-locale`——ja / de / fr / pt / ko / ar / hi / id / tr / vi / th / ru / it / nl / sv / pl / zh-HK / zh-TW / zh-MO 全量词典(每种约 340 keys);覆盖**借用 DSH 英文槽位**(DSH active=en 时生效,zh 下完全惰性、界面不混语言);19 语言词典同时注册进 better-locale,外部 `ctx.locale.lookup('betterSidebar', key)` 调用者同样可拿覆盖文本;未安装时 `ctx.get('betterLocale')` 为 undefined、整段 no-op,zh/en 行为不变
|
||||
- 🌿 **Git 多仓库选择 + linked worktree 变更发现**([#326](https://github.com/omdsh-dev/DSH-better-sidebar/pull/326) [#285](https://github.com/omdsh-dev/DSH-better-sidebar/pull/285)):会话 cwd 是工作区容器(非 Git 仓库)时自动发现直接子仓库并显示**仓库选择器**——status / 分支 / 历史 / diff / 暂存 / 提交 / 还原 / cherry-pick / 文件打开全部按所选仓库线程化;linked worktree 的变更发现与按工作树操作(含延迟分页响应的事务一致性),并拒绝过期 / 可修剪的 worktree 命令目标、对单库存取失败降级
|
||||
- 🖥️ **浏览器本地回环允许清单**([#365](https://github.com/omdsh-dev/DSH-better-sidebar/pull/365)):新增侧边卡设置 `browserAllowedLoopback`(逗号分隔 host 或 host:port;裸 host 匹配任意端口、带有端口精确匹配)——显式信任的本地开发服务器(如 Vite)可导航,并额外获得 iframe `allow-same-origin` 令牌(模块 / HMR / fetch 管线需要真实 origin,否则白屏);页面相对 GUI 与其他站点仍是跨源;服务端 `browser.probe` 镜像同一允许清单,本地服务器不再被误拒
|
||||
- 📝 **编辑器 Vue + 28 种 legacy 语言语法高亮**([#202](https://github.com/omdsh-dev/DSH-better-sidebar/pull/202)):`.vue` 映射 `@codemirror/lang-vue`(template / script / style 按 `lang` 属性分派、`<style lang="scss">` 预处理器);零新依赖用 legacy-modes 补齐 scss/sass/less/stylus/ruby/lua/perl/r/dart/scala/groovy/powershell/diff/protobuf/cmake/pug/tcl/haskell/clojure/erlang/julia/pascal/vb/vhdl/stex/objectivecpp;语言工厂抛错降级纯文本(console.warn),不再炸编辑器;`.v` / `.m` 跨语言歧义故意不映射
|
||||
- 🔄 **编辑器预览刷新三件套**([#215](https://github.com/omdsh-dev/DSH-better-sidebar/pull/215) [#228](https://github.com/omdsh-dev/DSH-better-sidebar/pull/228),修复 [#167](https://github.com/omdsh-dev/DSH-better-sidebar/issues/167)):文本预览新增**手动刷新**按钮;编辑保存后切回预览自动重载(dirty 时抑制,草稿不丢);预览模式下保存成功边沿自动重载;移除自动轮询与 `fs.stat` 版本端点(后台 API 零流量)
|
||||
- 🖼️ **Markdown 本地 / 相对图片**([#292](https://github.com/omdsh-dev/DSH-better-sidebar/pull/292)):``、`/cwd/img.png` 与引用式 `[id]: url` 目标重写为 `/sidebar/file` 媒体 URL(会话 cwd 边界不变)——预览不再只显示 alt 文本
|
||||
- ➕ **推荐插件目录新增 ego-browser**([#340](https://github.com/omdsh-dev/DSH-better-sidebar/pull/340)):`@dsh-external/ego-browser` Agent 浏览器 Tab(会话侧边栏自动注册本机浏览器页,无 better-sidebar 时回退浮动浮窗);描述词典 19 语言补全([#371](https://github.com/omdsh-dev/DSH-better-sidebar/pull/371))
|
||||
|
||||
**🐛 修复**
|
||||
|
||||
- 🛒 **DSH 市场受管安装兼容**([#338](https://github.com/omdsh-dev/DSH-better-sidebar/pull/338)):移除 `peerDependencies` 里的公开版 `cordis`(市场预览硬拒依赖字段出现 `cordis`,optional 无效)——npm 包满足 [dsh-community-market 安装规范](https://github.com/anywhere-labs/deepseek-harness-desktop/blob/master/dsh-community-market/docs/install-and-uninstall.zh.md),dshfind / 1024Store 目录里的条目重新获得 `repository_backlink` 验证目标,可直接从 Desktop 市场受管安装
|
||||
- 🔤 **类型基底迁移到 `@deepseek-ai/cordis`**([#338](https://github.com/omdsh-dev/DSH-better-sidebar/pull/338)):`Context` = 真实 vendored cordis Context 与结构化服务面的**交集**,`ctx.betterSidebar` 类型合并改挂 `@deepseek-ai/cordis`,公开版 cordis 不再被依赖。**消费者迁移**:`import type { Context } from 'cordis'` 改为 `import type { Context } from '@deepseek-ai/cordis'`(`import type {} from 'dsh-better-sidebar'` 的类型合并方式不变);未使用该导入的插件无影响
|
||||
- 🧩 **插件树内 `ctx.betterSidebar` 读取全面修复**([#357](https://github.com/omdsh-dev/DSH-better-sidebar/pull/357),修复 [#356](https://github.com/omdsh-dev/DSH-better-sidebar/issues/356)):npm 安装的 DSH 0.1.1-rc.x(web bundle)下侧边栏页面每次加载即崩(`cannot get property "betterSidebar" without inject`)——26 处内部直读 `ctx.betterSidebar` 改走 `ctx.get('betterSidebar')`(root reflect store 解析,不受 fiber 链影响);外部消费者 `inject: ['betterSidebar'] + ctx.betterSidebar` 契约不变
|
||||
- 🔐 **文件 API 会话工作区边界**([#345](https://github.com/omdsh-dev/DSH-better-sidebar/pull/345),修复 [#328](https://github.com/omdsh-dev/DSH-better-sidebar/issues/328)):`fs.tree / fs.read / fs.write` 的 workspace 越界访问修复;媒体、HTML 预览与上传统一 real-path 符号链接校验;新增绝对路径 / 符号链接 / 上传 / 嵌套 Git 会话回归测试
|
||||
- 🪟 **面板宿主层级与视口裁剪**([#330](https://github.com/omdsh-dev/DSH-better-sidebar/pull/330) [#278](https://github.com/omdsh-dev/DSH-better-sidebar/pull/278),修复 [#277](https://github.com/omdsh-dev/DSH-better-sidebar/issues/277)):面板宿主层 z-index 40→25——低于 DSH cordis 动态插件面板 30,工作台不再遮挡 cordis 清单 / 审批面(AppFrame 20 之上、100+ 浮层之下);宿主 `overflow: hidden` 裁剪视口边缘,收起的面板不再把文档撑出双向滚动(实测 `scrollWidth` 2289→1672 / `scrollHeight` 1280→1032,任意皮肤)
|
||||
- 📐 **布局推挤加固**([#310](https://github.com/omdsh-dev/DSH-better-sidebar/pull/310) [#130](https://github.com/omdsh-dev/DSH-better-sidebar/pull/130) [#180](https://github.com/omdsh-dev/DSH-better-sidebar/pull/180)):对话列补 `min-height: 0` + `overflow: hidden` + `overflow-wrap: anywhere`(长不可断 URL / OAuth 链接不再把 composer 与左侧设置按钮挤出视口);layout-push effect 拆「仅设置 + 仅卸载移除」并按 `panelOpen` 门控宽度 push——右栏关闭时拖底部高度不再挤压对话区、松手瞬间不再整页右铺再回弹;`useLayoutEffect` 消除跨 paint 全宽闪帧;松手 flush 最终帧 + `centerRect.right` 同步提交;底部高度按 `viewportHeight - PANEL_MIN` 封顶;拖拽手柄拖动中不再高亮
|
||||
- 📱 **移动端无会话状态说明 + 1px 溢出修复**([#254](https://github.com/omdsh-dev/DSH-better-sidebar/pull/254)):无会话时开关改用 `aria-disabled` 保持不可执行语义、同时允许触摸 / 键盘聚焦显示「选择一个会话以使用侧边栏」提示;panel 改 `border-box`——移动端 `100vw` 含左边框,不再产生 1px 横向溢出
|
||||
- 📏 **侧边栏宽度跨会话共享**([#36](https://github.com/omdsh-dev/DSH-better-sidebar/pull/36)):面板宽度是布局偏好而非会话内容——「最后一次拖拽胜出」写入全局 `dsh-sidebar:v1:width`,缓存会话切换与新建会话即时跟随;无全局键(首次运行 / 旧会话)时行为逐字节不变
|
||||
- 🧹 **会话删除立即关闭该会话终端**([#130](https://github.com/omdsh-dev/DSH-better-sidebar/pull/130)):新增 `PtyManager.closeSession()` + 订阅 DSH `session/disposed`——删除会话不再等 30s 重连宽限到期(agent 终端由 agent 生命周期管理,不受影响)
|
||||
- 🔍 **文件名搜索跳过噪声目录**([#342](https://github.com/omdsh-dev/DSH-better-sidebar/pull/342)):`node_modules` / `.pnpm-store` / `.yarn` / `.turbo` / `.next` / `dist` / `build` / `coverage` 等黑名单(小写不敏感,`.git` 仍跳)——超大依赖树不再耗尽 10 万访问预算提前 `truncated`,`docs/` 等后序目录里的真实文件能搜到;不引入 `.gitignore` 语义,保持「文件名查找」
|
||||
- 📝 **mermaid 全局错误渲染抑制**([#341](https://github.com/omdsh-dev/DSH-better-sidebar/pull/341)):开启 `suppressErrorRendering`——非法图表不再把大错误 SVG 注入 `document.body`;组件级错误回退与源码展示保留
|
||||
- 🖥️ **终端 Nerd Font 图标字体回退**([#190](https://github.com/omdsh-dev/DSH-better-sidebar/pull/190)):starship / powerlevel10k 提示符的补充平面 PUA 图标(Nerd Fonts v3 Material 图标集)不再显示豆腐块——`withIconFontFallbacks()` 为胜出的基础字体追加 Nerd Font 图标族(插入首个通用族之前、按族名去重、过滤 CSS 全局关键字、不列彩色 emoji 字体)
|
||||
- 🌐 **HTML 预览 UTF-8 声明**([#193](https://github.com/omdsh-dev/DSH-better-sidebar/pull/193),修复 [#170](https://github.com/omdsh-dev/DSH-better-sidebar/issues/170)):`/sidebar/html` 响应带 `charset=utf-8`(无 `<meta charset>` 的中文片段不再乱码),保留原始文件字节
|
||||
- 🧪 **trust-fence Origin 改按 hostname 比较**([#182](https://github.com/omdsh-dev/DSH-better-sidebar/pull/182)):Edge 151 把非默认端口 loopback 页面的 Origin 序列化为无端口形式——`http://127.0.0.1` 对 `Host: 127.0.0.1:3080` 不再 403(对齐 DSH 官方网关栅栏);不同 hostname / opaque null origin 仍拒绝
|
||||
- 🪟 **「在文件夹中显示」改为资源管理器揭示**([#94](https://github.com/omdsh-dev/DSH-better-sidebar/pull/94)):不再把目录当文件开进编辑器(`"..." is a directory`)——`revealInExplorer` 切到资源管理器 tab、面板折叠时自动展开、展开父目录并高亮滚动到本轮产出文件;产物行数据改读引擎 Turn deliverable(与 ui-deliverables 同源)
|
||||
- 🖱️ **面板拖动布局闪烁**([#180](https://github.com/omdsh-dev/DSH-better-sidebar/pull/180)):右侧栏关闭时拖底部高度不再左移挤压对话区;松手瞬间不再整体右铺再回弹
|
||||
- 🖥️ **PowerShell 安装脚本修复**([#47](https://github.com/omdsh-dev/DSH-better-sidebar/pull/47)):远程入口统一为「下载脚本 → 移除 UTF-8 BOM → 内存执行」,`-Version` / `-DryRun` 参数在 Windows PowerShell 5.1 下恢复生效(BOM 解析不再吃掉首行 `param(...)`);安装前校验 `pnpm --version`(主版本 <10 时明确报错并以退出码 1 结束,不再写一半 profile)
|
||||
- 🔄 **浏览器嵌入探测 GET 兜底**([#69](https://github.com/omdsh-dev/DSH-better-sidebar/pull/69)):HEAD 响应同时缺 CSP 与 X-Frame-Options 时回退 GET 重试一次——阿里云百炼等只在 GET 回头发嵌入策略的站点不再显示误导性「拒绝连接请求」,而是正确显示「该站点拒绝嵌入」面板 + 「在浏览器中打开」
|
||||
- 🔧 **git 源安装修复 `unrun` devDependency**([#336](https://github.com/omdsh-dev/DSH-better-sidebar/pull/336)):tsdown 0.22 经 `unrun` 加载配置而 pnpm 11 不自动装 peer——git-hosted 安装的 `prepare` 不再报 `Failed to import module "unrun"`(npm tarball 不受影响)
|
||||
- 🍃 **`ctx.effect` 严格化顺手修了 4 处**:拦截注册失败时 effect 体返回 `undefined` 改为 no-op disposer(vendored cordis 的 effect 契约要求返回 disposer,返回 `undefined` 属非法形状)
|
||||
|
||||
<details>
|
||||
<summary><b>历史版本(v0.12.0 – v0.15.2)</b></summary>
|
||||
|
||||
### v0.15.2
|
||||
|
||||
自 v0.15.1 以来的全部更改:
|
||||
|
||||
**✨ 新功能**
|
||||
|
||||
- 🗂️ **文件树「在应用中打开」子菜单**([#334](https://github.com/omdsh-dev/DSH-better-sidebar/pull/334)):文件树右键菜单新增「在应用中打开 >」子菜单——内置打开方式(资源管理器显示/选中、VS Code、Cursor、Zed),每行右侧图钉可固定为右键菜单顶层直达项(再点取消);配置可选 SSH host 后 VSCode 系条目改用 `vscode-remote/ssh-remote+<host>/<path>` 协议打开,本地专用条目自动隐藏;支持自定义编辑器(名称 + URL 模板 `{path}` + 是否 VSCode 系,配置入口在 Files 卡片齿轮弹窗)。打开动作经新宿主路由 `POST /sidebar/api/open.external`(argv 数组 spawn,无 shell 注入)([设计文档](docs/plans/2026-08-22-open-with-menu-design.md))
|
||||
- 📑 **Tab 右键菜单**([#331](https://github.com/omdsh-dev/DSH-better-sidebar/pull/331)):页签右键提供「关闭 / 关闭其他页签 / 关闭左侧页签 / 关闭右侧页签」,作用范围为当前 pane(标签组),无可关对象时置灰;仅打开菜单、不切换激活页签;批量关闭逐条走既有 `onClose` 路径,生命周期完整
|
||||
- 📄 **Diff 文件默认折叠**([#270](https://github.com/omdsh-dev/DSH-better-sidebar/pull/270)):改动文件头部改为可访问的展开/折叠控件;识别出的源文件默认展开,测试 / 文档 / 生成文件 / lockfile 与未知类型默认折叠;保留现有 500 行上限
|
||||
- 📖 **README 更新**:特性巡礼改为表格展示(每行两张图,节省空间);社区补全微信群 / QQ 群二维码([#325](https://github.com/omdsh-dev/DSH-better-sidebar/pull/325),QQ 群 577011007)
|
||||
|
||||
**🐛 修复**
|
||||
|
||||
- 🪟 **空分栏清理**([#268](https://github.com/omdsh-dev/DSH-better-sidebar/pull/268)):持久化的 split pane 在临时 diff tab 被清理后遗留全尺寸空分栏——`sanitizeState` 现在同时修剪空的 split leaf,并修复修剪后的失效激活 pane 指针;整个工作台为空时保留唯一空 pane
|
||||
- 🖥️ **Windows 下隐藏 Git 子进程窗口**([#301](https://github.com/omdsh-dev/DSH-better-sidebar/pull/301),关闭 [#124](https://github.com/omdsh-dev/DSH-better-sidebar/issues/124)):`runGit()` 统一加 `windowsHide: true`,仓库状态轮询与操作不再闪现控制台窗口(其他平台行为不变)
|
||||
- 📁 **未跟踪文件夹内文件差异**([#242](https://github.com/omdsh-dev/DSH-better-sidebar/pull/242)):`git status` 从 `--untracked-files=normal` 切换为 `--untracked-files=all`——新文件夹内每个文件独立成行、可正常加载差异(修正 `fs.read` 报 "is a directory",与 VSCode 默认行为一致)
|
||||
- ⚡ **开关/拖拽每帧 React 重渲染消除**(关闭 [#315](https://github.com/omdsh-dev/DSH-better-sidebar/issues/315)):centerRect 改 ref + 底栏 DOM 直写(零 React 渲染);TabContent memo(显式比较器);新增 frame-batcher 对 Divider/dock 拖拽按帧合并;拖拽期跳过无意义 locate。4x CPU 节流 A/B:开关 >17ms 帧 collapse 19→6 / expand 24→4~6,p95 21ms→15ms;拖拽不变(非回归)
|
||||
|
||||
### 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 本地化
|
||||
- 🔐 **文件 API workspace 边界加固**([#328](https://github.com/omdsh-dev/DSH-better-sidebar/issues/328)):`fs.tree/read/write`、媒体、HTML 预览和上传统一按真实路径限制在会话 workspace 内,拒绝越界绝对路径与外链符号链接
|
||||
|
||||
### 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))
|
||||
- 🖼️ **Markdown 预览支持本地/相对路径图片**:预览 `.md` 时把指向本地文件的图片目标(相对/绝对路径、引用式 `[id]: url`)重写为 `/sidebar/file` 媒体 URL 并显示(此前仅绝对 http(s) 图片能渲染,相对路径只显示 alt 文本)
|
||||
- 🐛 **node-pty 加载失败不再拖垮 server**([#140](https://github.com/omdsh-dev/DSH-better-sidebar/issues/140)):宿主半改为懒加载 node-pty,缺失时插件照常挂载,终端以修复提示横幅(可复制命令 + 重试按钮)呈现,agent 终端工具自动跳过
|
||||
- 🧪 测试工程:单元测试拆分(#141)+ smoke 偶发失败修复
|
||||
|
||||
</details>
|
||||
|
||||
## 💬 社区
|
||||
|
||||
推荐添加QQ群(577011007)
|
||||
|
||||
<div align="center">
|
||||
<img width="220" alt="微信群二维码" src="https://github.com/user-attachments/assets/39caafc7-9629-4b13-bb2b-eac17eab5b6a" />
|
||||
<img width="220" alt="QQ群二维码" src="https://github.com/user-attachments/assets/9be34629-26ef-4537-aad4-1393c147f81c" />
|
||||
</div>
|
||||
|
||||
## ⌨️ 快捷键
|
||||
|
||||
| 操作 | 按键 |
|
||||
|---|---|
|
||||
| 保存编辑 | `Ctrl/Cmd + S` |
|
||||
| Git 提交 | `Ctrl + Enter` |
|
||||
| 关闭 Tab | 鼠标中键 |
|
||||
| Tab 右键菜单 | 关闭 / 关闭其他页签 / 关闭左侧页签 / 关闭右侧页签(当前标签组) |
|
||||
| 拆分/合并分栏 | 拖 Tab 到分栏边缘 / 中间 |
|
||||
| 引用文件到输入框 | 悬浮行尾 `@文件` 按钮 |
|
||||
| 复制文件路径 | 右键行 → 复制相对/绝对地址 |
|
||||
|
||||
## 🔌 服务化扩展
|
||||
|
||||
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 7 tab + 6 viewer 亦通过同一服务注册)。v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、tab 角标、生命周期回调、定向打开、插件自有设置等)。
|
||||
|
||||
完整接入文档:
|
||||
- **[`AGENTS.md`](./AGENTS.md)**——仓库内维护的接入文档(全字段、匹配算法、HMR 陷阱、声明式设置、版本探测);
|
||||
- **[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)**——面向外部插件开发者的接入指南(含完整最小示例)。
|
||||
|
||||
### ➕ 添加插件(推荐插件目录)
|
||||
|
||||
设置页「侧边卡片」两个网格末尾的**虚线卡片**分别打开 Tab / 预览插件弹窗:声明扩展点、「**在 GitHub 上浏览更多插件**」按钮([GitHub topic `dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar))、推荐插件目录(名字 / 仓库 / 简介 / 安装脚本),每个条目「**跳转**」直达仓库、「**复制**」把安装命令写入剪贴板。
|
||||
|
||||
**收录新插件**:向 [`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)
|
||||
@@ -120,13 +479,50 @@ pnpm watch # tsdown --watch
|
||||
|
||||
## ⚠️ 已知限制
|
||||
|
||||
- Git 无 push/pull/fetch;无文件 watcher(手动刷新);工具行内文件打开按钮不可拦截
|
||||
- Git 无 push/pull/fetch;Markdown 预览提供手动刷新按钮,刷新未保存编辑前会确认是否丢弃草稿;无文件 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)无底部面板:进入窄屏时其标签页一次性并入右侧栏(迁移后回桌面仍保留在右侧栏),桌面端的底部面板只在宽视口下可用;移动端底部首展自动开终端不触发
|
||||
- 移动端(<768px)无底部面板:进入窄屏时其标签页一次性并入右侧栏(迁移后回桌面仍保留在右侧栏),桌面端的底部面板只在宽视口下可用;移动端底部首展自动开终端不触发。未选中会话时,点按弱化开关会显示选择会话提示;选中会话后开关打开全宽抽屉
|
||||
|
||||
## 🖥️ 平台支持
|
||||
|
||||
Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元测试覆盖);`node-pty` 优先预编译二进制,失败需编译工具链(Windows VS Build Tools / Linux make+g+++python3 / macOS Xcode CLT)。
|
||||
|
||||
## 🤝 参与贡献
|
||||
|
||||
- **代码改动走 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>
|
||||
|
||||
+537
@@ -0,0 +1,537 @@
|
||||
# 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 · 0.1.2-alpha.1" 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_%C2%B7_0.1.2--alpha.1-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
|
||||
- **📂 Model-driven sidebar opens (opt-in)**: with the global setting on, the `sidebar_open` tool lets the model actively open files / folders (tree rooted there) / HTTP(S) pages in the sidebar
|
||||
- **🌿 Git Panel**: real diff + VSCode-style diff tabs, history, right-click to stage / commit / revert; under a workspace container it discovers child repositories and shows a **repository selector**, with linked-worktree change discovery
|
||||
- **🧩 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
|
||||
- **🪟 Free Windows**: drag any tab onto the main conversation area to turn it into a movable / resizable / raiseable floating window (default 390×780); drag it back onto a pane to dock; persisted per session. `features` includes `'floatWindows'` and plugin tabs are supported identically
|
||||
- **📌 Pinned Terminals**: right-click a terminal tab to "Pin to Workspace / Pin Globally" — pinned terminals survive session switches and surface inline in the TabBar as virtual tabs (click activates in-place, PTY connects directly to the home session's PTY via WS, no session jump needed); agent terminals exempted from reconcile removal
|
||||
- **🔁 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; with the optional `@huanlin/dsh-plugin-better-locale` peer, 19 third-language overlays (ja / de / fr / …) are available
|
||||
|
||||
> 🔌 **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 · 0.1.2-alpha.1" 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_%C2%B7_0.1.2--alpha.1-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.16.1"`) 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.16.1"` 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 (two per row; click to zoom).
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **🗂️ File Workbench: Explorer**<br/><sub>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.</sub><br/><div align="center"><img width="420" alt="File explorer" src="https://github.com/user-attachments/assets/a410bfd2-a8ba-43e6-873e-22417756e94d" /></div> | **📝 Inline Preview: Markdown · Images · PDF**<br/><sub>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), **README-level inline HTML** (badge walls `<div align=center>`, `<details>` blocks nesting markdown, inline tags in table cells — DOMPurify-sanitized, `<script>` stripped, local media rewritten to the session media route) and a floating **table of contents** (appears with ≥3 headings, smooth-scroll jumping, auto-expanding folded blocks); images / PDFs display inline via the media route; the Office suite is covered by an ecosystem plugin.</sub><br/><div align="center"><img width="420" alt="Markdown + Mermaid preview" src="https://github.com/user-attachments/assets/fe0e5182-55bb-45cc-b98b-a2877c2bdd38" /></div> |
|
||||
| **🖥️ CodeMirror editor**<br/><div align="center"><img width="420" alt="CodeMirror editor" src="https://github.com/user-attachments/assets/b44b488e-568c-4ee0-b96c-e9c906598a77" /></div> | **🖼️ Inline image preview**<br/><div align="center"><img width="420" alt="Inline image preview" src="https://github.com/user-attachments/assets/f9a58c30-5b7a-48b5-9e22-37d7e071f593" /></div> |
|
||||
| **💻 Real Terminal**<br/><sub>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.</sub><br/><div align="center"><img width="420" alt="Real terminal" src="https://github.com/user-attachments/assets/0dad6ad3-ff3f-4b5a-86d2-f832ce65323e" /></div> | **🌿 Git Panel**<br/><sub>Stage / unstage / commit (`Ctrl+Enter`) / revert, plus a history list; clicking a changed file opens a **VSCode-style diff tab** (line-level red/green).</sub><br/><div align="center"><img width="420" alt="Git panel" src="https://github.com/user-attachments/assets/e7fc1220-305f-4bca-8583-e77ab4f4fa78" /></div> |
|
||||
| **🌐 Embedded Browser**<br/><sub>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).</sub><br/><div align="center"><img width="420" alt="Embedded browser" src="https://github.com/user-attachments/assets/9bc6b65a-64fc-4942-a685-76e391e55606" /></div> | **🧩 Tasks: Agent Topology + Background Jobs**<br/><sub>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).</sub><br/><div align="center"><img width="420" alt="Tasks: subagent topology" src="https://github.com/user-attachments/assets/dcd8ed2f-59fa-405b-937b-2d250f5034dd" /></div> |
|
||||
| **💬 Side Chat (beta)**<br/><sub>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.</sub><br/><div align="center"><img width="420" alt="Side Chat (beta)" src="https://github.com/user-attachments/assets/3a338c36-f5de-4000-95f3-4b1cd04f60fc" /></div> | **🪟 Dual Workbench: Sidebar + Bottom Panel + Split Panes**<br/><sub>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; drag a tab onto the main conversation area to turn it into a **free window** (float / resize / raise, drag back onto a pane to dock).</sub><br/><div align="center"><img width="420" alt="Dual workbench (right sidebar + bottom panel)" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" /></div> |
|
||||
| **⚙️ Declarative Settings**<br/><sub>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`.</sub><br/><div align="center"><img width="420" alt="Declarative settings: side cards" src="https://github.com/user-attachments/assets/0800ca64-621e-48da-b7df-aecfddc3ec29" /></div> | **📱 Mobile**<br/><sub>On narrow screens (<768px) the panels become a full-width drawer: bottom-panel tabs merge into the sidebar once, with touch-friendly dragging.</sub><br/><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>24 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 |
|
||||
| [Fisfzy/ego-browser](https://github.com/Fisfzy/ego-browser) | <img alt="stars" src="https://img.shields.io/github/stars/Fisfzy/ego-browser?style=flat&color=4d6bfe" /> | Agent browser: a local browsing tab (`@dsh-external/ego-browser`, auto-registers the sidebar page when better-sidebar is present, floating-bubble fallback otherwise) |
|
||||
| [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 · 0.1.2-alpha.1" 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_%C2%B7_0.1.2--alpha.1-4d6bfe" /></a> · full release history on the [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) page
|
||||
|
||||
### v0.17.1
|
||||
|
||||
- 🔗 **DSH 0.1.2-alpha.1 adaptation (dual-version compatible)**: fully adapted to DSH 0.1.2-alpha.1's Remote gateway, one-time-token browser authentication, and the `MarkdownText` labels contract change — the plugin works identically on 0.1.0-rc.8 ~ 0.1.1-rc.2 and 0.1.2-alpha.1 (the latter verified by a real-host mount smoke 14/14 against a source build of the GitHub tag; that version is not on npm yet, so the CI pin stays at 0.1.1-rc.2 until it publishes). Highlights: all four `MarkdownText` render sites now go through the dual-shape labels helper ([markdown-labels.tsx](./src/client/markdown-labels.tsx)), fixing the `reading 'code'` crash in markdown/mermaid previews on alpha.1; the e2e mount smoke speaks both wire dialects (token-URL cookie exchange, slash `/api` endpoints with parameter-named args, [tests/e2e/host-protocol.ts](./tests/e2e/host-protocol.ts)); dropped the `@deepseek-ai/dsh-client-runtime` peer removed upstream in 0.1.2-alpha.1
|
||||
|
||||
### v0.16.1
|
||||
|
||||
All changes since v0.16.0:
|
||||
|
||||
**🐛 Fixes**
|
||||
|
||||
- 🧊 **Git panel freeze + restart loop** ([#376](https://github.com/omdsh-dev/DSH-better-sidebar/pull/376), fixes [#369](https://github.com/omdsh-dev/DSH-better-sidebar/issues/369)): opening the Source Control panel could freeze the whole page, and the frozen layout restored itself after every reload with no way out — three unbounded layers compounding, now all bounded: **① status truncation** — the `git status --untracked-files=all` response is capped at 2,000 entries (capped results carry `truncated` and the panel shows a notice, mirroring `fs.read`'s truncation semantics; worktree change counts inherit the bound), so a huge untracked set can no longer freeze the browser main thread; **② bounded repository discovery** — a non-repository cwd (e.g. the home directory) no longer probes every visible child directory serially without limit: probe timeout 30s→5s, at most 200 probed directories, concurrent requests share one in-flight scan plus a 60s TTL cache, ending the `git rev-parse` spawn storm under `~`; **③ reset escape hatch** — loading with `?dsh-sidebar-reset` drops the persisted layout (shared width included) and starts from the default, breaking the loop even when the page is already hung; persisting resumes once the param is gone; `statusTruncated` copy synced across all 19 dictionaries
|
||||
|
||||
### v0.16.0
|
||||
|
||||
All changes since v0.15.2:
|
||||
|
||||
**✨ New features**
|
||||
|
||||
- 🪟 **Free windows** ([#354](https://github.com/omdsh-dev/DSH-better-sidebar/pull/354)): drag any tab (built-in or plugin-registered) out of the tab bar **onto the main conversation area** — a dashed drop hint marks the landing zone and release turns it into a floating window (default 390×780, phone-portrait ratio, clamped to the viewport and centered at the drop point). Windows can be moved by dragging the header, resized from the SE corner (≥320×200), raised to the top by clicking anywhere, sent back with the header context menu ("Back to sidebar" / "Close"), and closed with X through the normal `closeTab` lifecycle (pty release etc.). Dragging over a sidebar pane highlights it and releasing **docks** the window back into that pane. `floats` persist per session (restored as-is after refresh, lenient sanitize + geometry clamping). Service semantics: `features` gains `'floatWindows'` — `openTab`'s dedupe/id focus on a floating tab **raises its window** (no duplicate open, no panel expansion), `closeTab` / `activateTab` work on floating tabs and still fire their lifecycle callbacks, `visible` is always true inside a float, and agent-terminal reconcile covers floating windows. Tab content reuses the regular render path; plugin tabs are fully supported. Also ships the 8px-grid spacing cleanup of file sub-pages ([design](docs/plans/2026-08-23-free-window-design.md))
|
||||
- 📂 **Model-driven sidebar opens (`sidebar_open` tool)** ([#353](https://github.com/omdsh-dev/DSH-better-sidebar/pull/353)): a new global setting `agentOpenTools` (**off by default**) injects **one** tool letting the model open local **files** (editor tab, deduped by path), **folders** (full-window tree rooted there, `meta.dir`) and **HTTP(S) pages** (browser tab, URL prefilled) in the caller's sidebar. Turning the setting off unregisters the tool and clears the undelivered queue; already-open tabs stay. Opens targeting non-active sessions queue and replay when visible (`/sidebar/ws/agent-opens` push, same trust fence). No new public API and no new `/sidebar/api` routes ([design](docs/plans/2026-08-23-agent-open-tools-design.md))
|
||||
- 📝 **README-level inline HTML + table of contents (TOC) in Markdown previews** ([#360](https://github.com/omdsh-dev/DSH-better-sidebar/pull/360)): previews now genuinely render **block-level inline HTML** — badge walls `<div align=center>`, `<details>` blocks nesting markdown, `<br/>`/`<sub>`/`<img>` inside table cells, `<video>`/`<picture>` — all sanitized through a DOMPurify allowlist (`<script>` and other active content stripped, `<a>` forced `_blank rel=noopener`), with local media `src` rewritten to the session media route. With ≥3 headings a floating **TOC** button appears: smooth scroll, auto-expanding folded `<details>` ancestors, collecting headings from HTML segments too. The renderer is still the host `MarkdownText` (shiki / KaTeX / GFM preserved); pure-markdown documents take the original path with zero regression ([design](docs/plans/2026-08-24-markdown-html-toc-design.md))
|
||||
- 🌏 **Third-language coverage (19 languages)** ([#339](https://github.com/omdsh-dev/DSH-better-sidebar/pull/339)): optional peer `@huanlin/dsh-plugin-better-locale` adds full dictionaries for ja / de / fr / pt / ko / ar / hi / id / tr / vi / th / ru / it / nl / sv / pl / zh-HK / zh-TW / zh-MO (~340 keys each). Coverage **borrows the DSH English slot**: it is active only while DSH's active locale is `en` and is completely inert under `zh` (no mixed-language UI). The 19 dictionaries are also registered into better-locale so external `ctx.locale.lookup('betterSidebar', key)` callers get the override text. Without better-locale installed `ctx.get('betterLocale')` is undefined and the whole block is a no-op — zh/en behavior is unchanged
|
||||
- 🌿 **Multi-repository Git selection + linked-worktree discovery** ([#326](https://github.com/omdsh-dev/DSH-better-sidebar/pull/326) [#285](https://github.com/omdsh-dev/DSH-better-sidebar/pull/285)): when the session cwd is a workspace container rather than a Git repository, the Git panel discovers direct child repositories and shows a **repository selector** — status / branch / history / diff / stage / commit / revert / cherry-pick / file opening are all threaded through the selected repository. Linked-worktree change discovery and per-worktree git operations are transactionally bound to the selected checkout (including delayed pagination responses), stale/prunable worktree targets are rejected, and individual inventory failures degrade safely
|
||||
- 🖥️ **Browser loopback allowlist** ([#365](https://github.com/omdsh-dev/DSH-better-sidebar/pull/365)): a new side-card setting `browserAllowedLoopback` (comma-separated host or host:port entries; bare hosts match every port, host:port matches exactly) lets the sidebar browser navigate to explicitly trusted local addresses. Allowlisted loopback pages additionally get the `allow-same-origin` iframe sandbox token — local dev servers (Vite etc.) need a real origin for their module/HMR/fetch pipeline and would otherwise render blank. The page stays cross-origin to the GUI and to every other site; the server-side `browser.probe` route mirrors the same allowlist
|
||||
- 📝 **Vue + 28 legacy languages in the editor** ([#202](https://github.com/omdsh-dev/DSH-better-sidebar/pull/202)): `.vue` maps to `@codemirror/lang-vue` (template / script / style dispatch on the `lang` attribute, `<style lang="scss">` preprocessors); zero new dependencies bring scss/sass/less/stylus/ruby/lua/perl/r/dart/scala/groovy/powershell/diff/protobuf/cmake/pug/tcl/haskell/clojure/erlang/julia/pascal/vb/vhdl/stex/objectivecpp via legacy-modes. A throwing language factory degrades to plain text (console.warn) instead of breaking the editor; ambiguous `.v` / `.m` extensions stay unmapped on purpose
|
||||
- 🔄 **Editor preview refresh trio** ([#215](https://github.com/omdsh-dev/DSH-better-sidebar/pull/215) [#228](https://github.com/omdsh-dev/DSH-better-sidebar/pull/228), fixes [#167](https://github.com/omdsh-dev/DSH-better-sidebar/issues/167)): a **manual refresh** button for text previews; switching back to preview after an edit-save auto-reloads (suppressed while dirty so drafts survive); a successful save while in preview mode reloads on the saved edge. Automatic polling and the `fs.stat` version endpoint are gone (zero background API traffic)
|
||||
- 🖼️ **Local / relative images in Markdown** ([#292](https://github.com/omdsh-dev/DSH-better-sidebar/pull/292)): ``, `/cwd/img.png` and reference-style `[id]: url` destinations are rewritten to `/sidebar/file` media URLs (still bounded by the session cwd) — previews no longer show just the alt text
|
||||
- ➕ **New recommended-catalog entry: ego-browser** ([#340](https://github.com/omdsh-dev/DSH-better-sidebar/pull/340)): `@dsh-external/ego-browser` Agent browser tab (registers a sidebar page automatically when better-sidebar is present, falls back to a floating bubble otherwise); description dictionary completed across 19 languages ([#371](https://github.com/omdsh-dev/DSH-better-sidebar/pull/371))
|
||||
|
||||
**🐛 Fixes**
|
||||
|
||||
- 🛒 **DSH marketplace managed-install compatibility** ([#338](https://github.com/omdsh-dev/DSH-better-sidebar/pull/338)): the public `cordis` entry was removed from `peerDependencies` (the market preview hard-rejects `cordis` in any dependency field — optional does not help), so the npm package satisfies the [dsh-community-market install rules](https://github.com/anywhere-labs/deepseek-harness-desktop/blob/master/dsh-community-market/docs/install-and-uninstall.zh.md) — catalog sources (dshfind / 1024Store) can re-issue the `repository_backlink` verified target and the plugin becomes installable through the Desktop market
|
||||
- 🔤 **Type base migrated to `@deepseek-ai/cordis`** ([#338](https://github.com/omdsh-dev/DSH-better-sidebar/pull/338)): the declaration surface (`src/context-types.ts`) no longer depends on or restates the public `cordis` — `Context` is now an **intersection** of the real vendored cordis Context with the structural service faces, and the `ctx.betterSidebar` type merge lives on `@deepseek-ai/cordis`. **Consumer migration**: change `import type { Context } from 'cordis'` to `import type { Context } from '@deepseek-ai/cordis'` (the `import type {} from 'dsh-better-sidebar'` merge path is unchanged); plugins that never used that import are unaffected
|
||||
- 🧩 **All internal `ctx.betterSidebar` reads fixed** ([#357](https://github.com/omdsh-dev/DSH-better-sidebar/pull/357), fixes [#356](https://github.com/omdsh-dev/DSH-better-sidebar/issues/356)): on npm-installed DSH 0.1.1-rc.x (web bundle) the sidebar died on every page load (`cannot get property "betterSidebar" without inject`) — the 26 internal direct reads now go through `ctx.get('betterSidebar')` (root reflect-store resolution, immune to the fiber chain); external consumers keep `inject: ['betterSidebar'] + ctx.betterSidebar`
|
||||
- 🔐 **Session-workspace boundary for file APIs** ([#345](https://github.com/omdsh-dev/DSH-better-sidebar/pull/345), fixes [#328](https://github.com/omdsh-dev/DSH-better-sidebar/issues/328)): fixed `fs.tree / fs.read / fs.write` workspace escapes; media, HTML preview and uploads share real-path symlink validation; regression tests for absolute paths, symlinks, uploads and nested Git sessions
|
||||
- 🪟 **Panel-host layering and viewport clipping** ([#330](https://github.com/omdsh-dev/DSH-better-sidebar/pull/330) [#278](https://github.com/omdsh-dev/DSH-better-sidebar/pull/278), fixes [#277](https://github.com/omdsh-dev/DSH-better-sidebar/issues/277)): panel host z-index 40→25 — below the DSH cordis dynamic-plugin layer (30), so the workbench no longer covers cordis inventory/approval surfaces (still above AppFrame's 20 and below the 100+ float stack); `overflow: hidden` on the host clips at the viewport edge, so collapsed panels no longer stretch the document into bidirectional scrolling (measured `scrollWidth` 2289→1672 / `scrollHeight` 1280→1032, any skin)
|
||||
- 📐 **Layout-push hardening** ([#310](https://github.com/omdsh-dev/DSH-better-sidebar/pull/310) [#130](https://github.com/omdsh-dev/DSH-better-sidebar/pull/130) [#180](https://github.com/omdsh-dev/DSH-better-sidebar/pull/180)): conversation column gets `min-height: 0` + `overflow: hidden` + `overflow-wrap: anywhere` (long unbreakable URLs / OAuth links no longer push the composer and left-rail Settings out of the viewport); the layout-push effect is split into "set only" + "remove only on unmount" and the width push is gated on `panelOpen` — dragging the bottom height with the right panel closed no longer squeezes the conversation, and release no longer flashes full-width; `useLayoutEffect` removes cross-paint full-width frames; all three drags flush the final frame and sync `centerRect.right` before committing; bottom height caps at `viewportHeight - PANEL_MIN`; drag handles no longer highlight mid-drag
|
||||
- 📱 **Mobile: unavailable-sidebar clarity + 1px overflow** ([#254](https://github.com/omdsh-dev/DSH-better-sidebar/pull/254)): with no session the toggle uses `aria-disabled` — keeping the non-executable semantics while allowing touch/keyboard focus so the "select a session to use the sidebar" tooltip is reachable; the panel uses `border-box` so mobile `100vw` includes the left border and no longer causes 1px horizontal overflow
|
||||
- 📏 **Side-card width shared across sessions** ([#36](https://github.com/omdsh-dev/DSH-better-sidebar/pull/36)): panel width is a layout preference, not session content — "last drag wins" writes the global `dsh-sidebar:v1:width` key that fresh and cached session switches adopt; without a global key (first run / pre-existing sessions) behavior is byte-for-byte unchanged
|
||||
- 🧹 **Terminals close immediately on session delete** ([#130](https://github.com/omdsh-dev/DSH-better-sidebar/pull/130)): new `PtyManager.closeSession()` subscribing to DSH's `session/disposed` — deleted sessions no longer wait for the 30s reconnect grace to release their terminals (agent terminals are agent-lifecycle-owned and untouched)
|
||||
- 🔍 **Filename search skips noise directories** ([#342](https://github.com/omdsh-dev/DSH-better-sidebar/pull/342)): `node_modules` / `.pnpm-store` / `.yarn` / `.turbo` / `.next` / `dist` / `build` / `coverage` etc. (case-insensitive, `.git` still skipped) — huge dependency trees no longer burn the 100k visit budget and return `truncated` early, so real files in later directories (`docs/`) are found; no `.gitignore` semantics, still filename lookup
|
||||
- 📝 **Mermaid global error rendering suppressed** ([#341](https://github.com/omdsh-dev/DSH-better-sidebar/pull/341)): `suppressErrorRendering` enabled — invalid diagrams no longer inject a large error SVG into `document.body`; the component-level error fallback and source display remain
|
||||
- 🖥️ **Terminal Nerd Font icon fallback** ([#190](https://github.com/omdsh-dev/DSH-better-sidebar/pull/190)): supplementary-plane PUA icons in starship / powerlevel10k prompts (Nerd Fonts v3 Material icon set) no longer render as tofu — `withIconFontFallbacks()` appends Nerd Font icon families to the winning base font (before the first generic family, deduped by family name, CSS global keywords filtered, no color-emoji fonts)
|
||||
- 🌐 **HTML preview declares UTF-8** ([#193](https://github.com/omdsh-dev/DSH-better-sidebar/pull/193), fixes [#170](https://github.com/omdsh-dev/DSH-better-sidebar/issues/170)): `/sidebar/html` responses carry `charset=utf-8` (Chinese fragments without `<meta charset>` no longer mojibake); original file bytes preserved
|
||||
- 🧪 **trust-fence Origin compared by hostname** ([#182](https://github.com/omdsh-dev/DSH-better-sidebar/pull/182)): some Chromium builds (Edge 151) serialize the Origin of non-default-port loopback pages without the port — `http://127.0.0.1` against `Host: 127.0.0.1:3080` no longer 403s (mirrors DSH's official gateway fence); different hostnames and opaque null origins are still rejected
|
||||
- 🪟 **"Show in folder" now reveals in the explorer** ([#94](https://github.com/omdsh-dev/DSH-better-sidebar/pull/94)): the folder-reveal gesture no longer opens the directory as a file in the editor (`"..." is a directory`) — `revealInExplorer` switches to the explorer tab, auto-expands a collapsed panel, expands the parent folder and highlights/scrolls to the produced file; the produced-files selector now reads the engine's Turn deliverables (same source as ui-deliverables)
|
||||
- 🖱️ **Panel-drag layout flash fixed** ([#180](https://github.com/omdsh-dev/DSH-better-sidebar/pull/180)): dragging the bottom height with the right panel closed no longer shifts the conversation left; release no longer flashes full-width before snapping back
|
||||
- 🖥️ **PowerShell installer fixes** ([#47](https://github.com/omdsh-dev/DSH-better-sidebar/pull/47)): the remote entry is now "download the script → strip the UTF-8 BOM → run it in memory", so `-Version` / `-DryRun` work again under Windows PowerShell 5.1 (the BOM no longer eats the leading `param(...)`); `pnpm --version` is checked before touching the profile (major <10 fails with a clear error and exit code 1)
|
||||
- 🔄 **Browser embed probing GET fallback** ([#69](https://github.com/omdsh-dev/DSH-better-sidebar/pull/69)): when a HEAD response omits both `Content-Security-Policy` and `X-Frame-Options`, probe retries once with GET — sites like Alibaba Bailian that only advertise their embed policy on GET no longer show a misleading "refused to connect"; they get the friendly "site refuses embedding" panel with an "open in browser" button
|
||||
- 🔧 **git-source installs fixed via `unrun` devDependency** ([#336](https://github.com/omdsh-dev/DSH-better-sidebar/pull/336)): tsdown 0.22 loads its config through `unrun` and pnpm 11 does not auto-install peer deps — git-hosted `prepare` no longer fails with `Failed to import module "unrun"` (npm tarballs unaffected)
|
||||
- 🍃 **Strict `ctx.effect` also fixed 4 latent sites**: interception / IME-guard effect bodies returned `undefined` on failure instead of a disposer (invalid shape under the vendored cordis effect contract) — now a no-op disposer
|
||||
|
||||
<details>
|
||||
<summary><b>Older releases (v0.12.0 – v0.15.2)</b></summary>
|
||||
|
||||
### v0.15.2
|
||||
|
||||
All changes since v0.15.1:
|
||||
|
||||
**✨ New features**
|
||||
|
||||
- 🗂️ **"Open in app" submenu in the file tree** ([#334](https://github.com/omdsh-dev/DSH-better-sidebar/pull/334)): the file-tree context menu gains an "Open in app >" submenu — built-in openers (reveal/select in file manager, VS Code, Cursor, Zed), each with a pin button that promotes it to a top-level context-menu item (click again to unpin); with an optional SSH host configured, VSCode-family entries switch to the `vscode-remote/ssh-remote+<host>/<path>` protocol and local-only entries hide automatically; custom editors supported (name + URL template with `{path}` + "VSCode-family" flag, configured in the Files card gear popup). Open actions go through the new host route `POST /sidebar/api/open.external` (argv-array spawn, no shell injection) ([design](docs/plans/2026-08-22-open-with-menu-design.md))
|
||||
- 📑 **Tab context menu** ([#331](https://github.com/omdsh-dev/DSH-better-sidebar/pull/331)): right-clicking a tab offers **Close / Close others / Close to the left / Close to the right**, scoped to the current pane (tab group); items grey out when there is nothing to close; the menu only opens — it never switches the active tab; bulk closes go through the existing per-tab `onClose` path so lifecycle callbacks, pty release and agent-terminal shutdown all stay intact
|
||||
- 📄 **Diff files collapsed by default** ([#270](https://github.com/omdsh-dev/DSH-better-sidebar/pull/270)): each changed-file header is now an accessible expand/collapse control; recognized source files expand by default while tests, docs, generated files, lockfiles and unknown types stay collapsed; the existing 500-row cap for expanded content is preserved
|
||||
- 📖 **README update**: feature tour converted to a table (two screenshots per row, saves space); community section now shows the WeChat group and QQ group QR codes ([#325](https://github.com/omdsh-dev/DSH-better-sidebar/pull/325), QQ group 577011007)
|
||||
|
||||
**🐛 Fixes**
|
||||
|
||||
- 🪟 **Prune empty restored panes** ([#268](https://github.com/omdsh-dev/DSH-better-sidebar/pull/268)): persisted split panes left empty when ephemeral diff tabs were dropped no longer survive as full-size blanks — `sanitizeState` now also prunes the empty split leaf and repairs the stale active-pane pointer; a fully empty workbench keeps its single empty pane
|
||||
- 🖥️ **Hide spawned git windows on Windows** ([#301](https://github.com/omdsh-dev/DSH-better-sidebar/pull/301), fixes [#124](https://github.com/omdsh-dev/DSH-better-sidebar/issues/124)): the shared `runGit()` spawn options now set `windowsHide: true`, so repo-status polling and git actions no longer flash console windows on Windows (no behavior change on other platforms)
|
||||
- 📁 **Untracked files inside new folders** ([#242](https://github.com/omdsh-dev/DSH-better-sidebar/pull/242)): `git status` switched from `--untracked-files=normal` to `--untracked-files=all` — each file inside a new folder now shows as its own row and its diff can be loaded (no more `fs.read` "is a directory" error), matching VSCode's default behavior
|
||||
- ⚡ **Per-frame React re-renders eliminated for toggles/drags** (closes [#315](https://github.com/omdsh-dev/DSH-better-sidebar/issues/315)): `centerRect` moved to a ref with direct DOM writes to the bottom bar (zero React renders); `TabContent` memoized with an explicit comparator; a new frame-batcher coalesces Divider/dock drags per frame; meaningless locate passes skipped during drags. 4x CPU-throttled A/B: >17ms frames on toggle collapse 19→6 / expand 24→4~6, p95 21ms→15ms; drags unchanged (non-regression)
|
||||
|
||||
### 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
|
||||
- 🔐 **Workspace boundary hardening for file APIs** ([#328](https://github.com/omdsh-dev/DSH-better-sidebar/issues/328)): `fs.tree/read/write`, media, HTML preview, and uploads now share real-path containment checks that reject outside absolute paths and symlink escapes
|
||||
- 🔧 **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
|
||||
|
||||
### 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))
|
||||
- 🖼️ **Markdown preview renders local/relative images**: image destinations that point at local files (relative/absolute paths, reference-style `[id]: url`) are rewritten to `/sidebar/file` media URLs and displayed in the preview (previously only absolute http(s) images rendered; relative paths showed just the alt text)
|
||||
- 🐛 **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 |
|
||||
| Tab context menu (right-click) | Close / Close Other Tabs / Close Tabs to the Left / Close Tabs to the Right (current pane) |
|
||||
| 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; Markdown previews provide a manual refresh button with confirmation before discarding unsaved edits; no file watcher or automatic polling; 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. Without a selected session, tapping the subdued toggle shows the select-session message; with a selected session, it opens the full-width drawer
|
||||
|
||||
## 🖥️ 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)"
|
||||
+210
-38
@@ -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 / 禁用)时自动调用。
|
||||
|
||||
@@ -24,15 +24,15 @@ better-sidebar 从 v0.4.0 起把自己改造成一个**注册表服务**:
|
||||
|
||||
## 2. 前置:类型合并与依赖声明
|
||||
|
||||
### 2.1 双 cordis 实例问题
|
||||
### 2.1 类型合并(统一 `@deepseek-ai/cordis`)
|
||||
|
||||
外部插件在 DSH monorepo 之外解析,拿不到官方 cordis 的 augmentation,因此 `ctx.betterSidebar` 不会自动出现在你的 `Context` 类型上。解法由 better-sidebar 自己提供:
|
||||
DSH 运行时和生态的类型正主是 vendored `@deepseek-ai/cordis`。你的插件解析到它的 `Context` 时,`ctx.betterSidebar` 不会自动出现——由 better-sidebar 用 `declare module '@deepseek-ai/cordis'` 补上:
|
||||
|
||||
```ts
|
||||
import type {} from 'dsh-better-sidebar' // 触发 declare module 'cordis' 类型合并
|
||||
import type {} from 'dsh-better-sidebar' // 触发 declare module '@deepseek-ai/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",
|
||||
"@deepseek-ai/cordis": "^4.0.1",
|
||||
"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 '@deepseek-ai/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)
|
||||
@@ -76,7 +87,7 @@ import type { TabDescriptor } from 'dsh-better-sidebar/client/service' // 别
|
||||
```ts
|
||||
// my-plugin/src/client/index.ts
|
||||
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
|
||||
export const inject = ['betterSidebar', 'slots'] // 声明服务依赖(slots 可选,按需)
|
||||
|
||||
@@ -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,13 +480,15 @@ 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 只读 + 写操作 |
|
||||
| `pty.close` / `agent-pty.close` | 释放终端(外部 tab 一般用不到) |
|
||||
| `settings.get` / `settings.update` | 侧边栏偏好读写(revision 守卫) |
|
||||
|
||||
> **文件路径安全边界**:`fs.tree`、`fs.read`、`fs.write`、`/sidebar/file`、`/sidebar/html` 和 `/sidebar/upload` 都以请求对应 session 的权威 `cwd` 作为 workspace 根目录。路径会按真实文件系统路径检查,越界绝对路径、`..` 解析结果和指向 workspace 外部的符号链接都会被拒绝;消费插件不应把 `cwd` 当作可由用户扩大权限范围的参数。
|
||||
|
||||
媒体/下载字节走 `/sidebar/file` 路由(`?sessionId=&path=&cwd=&download=1`):
|
||||
|
||||
```ts
|
||||
@@ -453,14 +524,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 +608,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 +619,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;自定义面板自行负责)。
|
||||
|
||||
---
|
||||
|
||||
@@ -510,12 +666,13 @@ ctx.effect(() =>
|
||||
| 陷阱 | 说明 |
|
||||
|---|---|
|
||||
| **构建纯度门** | client bundle 禁止 value-import `@dsh-external/*` 或非白名单的 `@deepseek-ai/*`(`tsdown.config.ts` 的 `dsh-client-bundle-purity` 插件);**类型 `import type {}` 会被擦除,不触发门禁**——类型可自由共享,运行时符号不行。所有跨插件交互走 `ctx.betterSidebar` 方法调用 |
|
||||
| **双 cordis 实例** | 外部插件解析不到 DSH monorepo 的 cordis augmentation;better-sidebar 自己重述了 `interface Context { betterSidebar: ... }`(`src/context-types.ts`),你 `import type {} from 'dsh-better-sidebar'` 即拿到类型 |
|
||||
| **统一 cordis 分支(v0.15.2+)** | 类型基底与 augmentation 全部在 DSH 运行时正主 `@deepseek-ai/cordis` 上(`src/context-types.ts`:真实 cordis `Context` 与结构化服务面做**交集**,不再重述 `declare module 'cordis'`)。消费者 `import type {} from 'dsh-better-sidebar'` 即拿到 `ctx.betterSidebar`,或直接 `import type { Context } from 'dsh-better-sidebar'`。公开版 `cordis` 不再被依赖 |
|
||||
| **ModuleLoader 不跨插件** | 运行时 `require()` 虽支持跨 bundle,但被构建门挡;所有交互走 `ctx.betterSidebar` 方法调用 |
|
||||
| **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`) |
|
||||
| **不要 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 +692,7 @@ ctx.effect(() =>
|
||||
"./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }
|
||||
},
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/cordis": "^4.0.1",
|
||||
"dsh-better-sidebar": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"react": "^18.2.0"
|
||||
@@ -551,7 +708,7 @@ ctx.effect(() =>
|
||||
```tsx
|
||||
import { createElement } from 'react'
|
||||
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
|
||||
export const inject = ['betterSidebar']
|
||||
|
||||
@@ -598,7 +755,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 +763,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
|
||||
→ 侧边栏中文。
|
||||
@@ -46,6 +46,7 @@ chunk 脚本**不经过** `window.__ModuleLoader__` / `ClientModuleSystem.import
|
||||
| `pptx` | `src/client/chunks/pptx.tsx` | `PptxView` + pptx-renderer | lib/client-pptx.js(~2.4MB) | 打开 .pptx |
|
||||
| `terminal` | `src/client/chunks/terminal.tsx` | `TerminalView` + xterm + addon-fit + xterm.css | lib/client-terminal.js(~450KB) | 打开终端 tab |
|
||||
| `editor` | `src/client/chunks/editor.tsx` | `TextEditor` + @codemirror/* + lang.ts + cm-themes | lib/client-editor.js(~1.6MB) | 打开 code/md/html 文件 |
|
||||
| `mermaid` | `src/client/chunks/mermaid.tsx` | `MermaidBlocks` + mermaid + d3/dagre/cytoscape | lib/client-mermaid.js(~7MB) | 预览含 mermaid fence 的 md |
|
||||
| 核心 | `src/client/index.tsx` | 其余全部 | lib/client.js(~325KB) | 启动 |
|
||||
|
||||
关键拆分:**`office-view.tsx` 拆为 `docx-view.tsx` + `xlsx-view.tsx` + `office-shared.tsx`**(共享 `LoadState`/`BinaryFallback`)。若不拆,docx chunk 会经共享文件静态拖入 `xlsx-to-univer` → Univer 14.5MB。原路径 `office-view.tsx` 保留为 re-export 垫片,兼容 `./src/*` 深导入。
|
||||
|
||||
@@ -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,117 @@
|
||||
# 终端自定义字体(设置页二级设置)设计
|
||||
|
||||
**日期**: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)。
|
||||
|
||||
> **实施偏差(2026-08-17)**:`resolveTerminalFont` 不再**逐字**返回胜出的基础字体,而是经 `withIconFontFallbacks()` 追加 Nerd Font 图标族后返回。
|
||||
>
|
||||
> 起因:starship / powerlevel10k 等提示符的图标取自 Nerd Font 私有区,Chromium 的隐式系统回退能覆盖 BMP PUA(`U+E000–U+F8FF`,如 `U+E0B0`)但覆盖不到补充平面 PUA-B(`U+F0000+`,Nerd Fonts v3 把 Material Design 图标集迁到了这里),导致后者渲染为豆腐块。显式列出字体族可使浏览器逐字符查询,两个平面都能命中。
|
||||
>
|
||||
> 三条约束:①**只追加不前置**——xterm 用字体栈首项测量单元格宽度;②插入到**第一个**通用族(`monospace` 等)之前——通用族是 catch-all,其后的字体永不被查询;③按族名去重(引号/大小写/空白不敏感),用户自列的字体保持其原有优先级。基础字体的优先级链(用户 pref > 主题 > 内置栈)不变,另新增 CSS 全局关键字(`inherit`/`initial`/`unset`/`revert`/`revert-layer`)过滤——这类值只在作为整条声明值时合法,追加后会得到非法 `font-family` 而被 CSSOM 静默丢弃。
|
||||
|
||||
### 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,68 @@
|
||||
# 皮肤兼容(令牌驱动):面板表面、终端透明度回退与独立 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 间隙),行为不变。
|
||||
- **z-index 层级修正(2026-08-22)**:上文 "30–99 无任何 DSH 元素" 的假设不成立——DSH 的 ui-cordis 动态插件面板(`CordisPanel`,`position:fixed; z-index:30`,挂 `sidebar.footer.action` 槽)正落在该区间,且其清单/审批面会被 better-sidebar 的底部面板遮挡。修正:面板宿主层 40 → **25**(仍在 AppFrame overlayLayer 20 之上、ui-cordis 面板 30 与 DSH 浮层栈 100+ 之下),折叠按钮簇保持 host 内部 45(绝对层级随宿主 = 25)。
|
||||
- **拖拽禁用断言修正**:`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,50 @@
|
||||
# Edge 151 无端口 Origin 导致的侧边栏 403 forbidden 修复
|
||||
|
||||
日期:2026-08-17
|
||||
|
||||
## 现象
|
||||
|
||||
Windows + Edge 151(151.0.4129.86)下,`dsh web` 主应用一切正常,但 better-sidebar 的文件管理(Explorer / 文件树)显示 `forbidden`。抓包确认:`POST /sidebar/api/fs.tree` 返回 `403`,响应体为 `{"ok":false,"error":{"code":"forbidden","message":"forbidden"}}`——命中插件自身信任栅栏的拒绝分支,而不是业务错误。
|
||||
|
||||
## 根因
|
||||
|
||||
`src/trust-fence.ts` 是 DSH `/api` 网关信任栅栏(`@deepseek-ai/dsh-client-connection` 的 `api-request-trust.ts`)的行为镜像。Origin 校验一段,镜像版仍按 `host`(含端口)比较:
|
||||
|
||||
```ts
|
||||
return new URL(origin).host === hostUrl.host
|
||||
```
|
||||
|
||||
DSH 官方栅栏后来修复为按 `hostname` 比较,理由是:**部分 Chromium 构建(Edge 151)会把非默认端口 loopback 页面的 Origin 序列化为不带端口的形式**(`http://127.0.0.1:3080` 的页面,fetch 携带 `Origin: http://127.0.0.1`)。
|
||||
|
||||
于是 Edge 151 下:
|
||||
|
||||
- 页面 `http://127.0.0.1:3080` 发出的请求,`Origin: http://127.0.0.1`,`Host: 127.0.0.1:3080`;
|
||||
- 镜像栅栏比较 `new URL('http://127.0.0.1').host`(= `127.0.0.1`)与 Host `127.0.0.1:3080` → 不等 → 403;
|
||||
- DSH 自己的 `/api` 栅栏比较 hostname → 相等 → 放行,所以主应用正常,只有 `/sidebar/*` 全部 403。
|
||||
|
||||
镜像与官方行为漂移,正好打破镜像文件头声明的「behaviorally identical」契约。
|
||||
|
||||
## 证据
|
||||
|
||||
- 本机 Edge 版本 151.0.4129.86(DSH 注释点名的构建)。
|
||||
- curl 实测(对运行中的 `dsh web`,`/sidebar/api/fs.list` 用错方法名以区分 403 与方法分发):
|
||||
- `Origin: http://127.0.0.1:3080`(带端口)→ 200 `unknown sidebar API method`(过了栅栏);
|
||||
- `Origin: http://127.0.0.1`(Edge 151 形式)→ 403 `forbidden`。
|
||||
- DSH 修复参考:`packages/client/connection/src/api-request-trust.ts`(注释点名 Edge 151)及测试 `tests/api-request-trust.host.spec.ts` 的「accepts an Origin that names the Host hostname without its port (Edge 151 serialization)」。
|
||||
|
||||
## 方案
|
||||
|
||||
1. `src/trust-fence.ts` 的 Origin 校验改为 `new URL(origin).hostname === hostUrl.hostname`,注释写明与 DSH 官方栅栏保持一致的语义与理由(Host 栅栏已绑定 authority,端口不再重决信任)。
|
||||
2. `tests/trusted-hosts.spec.ts` 增加回归用例:loopback Host + 不带端口的 Origin → 200;同时保留带端口与跨域拒绝用例。
|
||||
|
||||
行为语义变化(有意为之,与 DSH 一致):Origin 与 Host 的 hostname 相同即认为同源,端口差异不再拒绝。安全前提不变:Host 栅栏仍要求 Host 为 loopback 或 `webRuntime.trustedHosts` 授权项,`sec-fetch-site: cross-site` 仍一律拒绝。
|
||||
|
||||
## 验证
|
||||
|
||||
1. `pnpm test`(vitest)新增用例通过,既有 trusted-hosts 用例不回归。
|
||||
2. `pnpm typecheck`。
|
||||
3. 本地挂载:`pnpm build` 后覆盖 web profile 的 node_modules 产物,重启 `dsh web`,curl 以 `Origin: http://127.0.0.1`(无端口)请求 `/sidebar/api/*` 应进入方法分发(404 unknown method)而非 403;浏览器硬刷新后文件管理恢复。
|
||||
|
||||
## 发布
|
||||
|
||||
按仓库 §0 流程:`fix/*` 分支 → PR → review 合并 → bump 版本打 tag 走 release workflow。即时修复可先本地覆盖 profile 产物验证,正式生效待 0.13.1 发布后 `dsh plugin add dsh-better-sidebar@latest`。
|
||||
@@ -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,27 @@
|
||||
# 编辑器预览刷新三件套(issue #167)
|
||||
|
||||
日期:2026-08-19
|
||||
|
||||
## 问题(#167,无评论无修复)
|
||||
|
||||
`.md` 文件编辑保存后,切到预览不立即渲染新内容,需关闭文件重新打开。根因:`EditorHost` 的加载 effect 只依赖 `[sessionId, cwd, path, ctx, showEmpty]`——保存(fsWrite)与模式切换(setMode)都不重跑加载,且工具栏没有手动刷新入口。
|
||||
|
||||
## 方案(最小三件套,全部在 EditorHost.tsx)
|
||||
|
||||
| 编号 | 行为 | 实现 |
|
||||
|------|------|------|
|
||||
| A | 手动刷新按钮(文本类 viewer,即 toolbar 非空时显示) | 新增 `reloadSeq` state;effect 依赖加 `reloadSeq`;header 刷新按钮 `setReloadSeq(s => s + 1)` |
|
||||
| B | 编辑保存后切回预览自动刷新 | header「预览」按钮 onClick:`toolbar.mode === 'edit'` 且非 dirty 且 saveState 非 failed → 先 `setReloadSeq(+1)` 再 `setMode('preview')`;dirty 时不刷新(草稿只在 CodeMirror 内,重载会丢) |
|
||||
| C | 预览模式下保存成功后刷新 | 监听 `toolbar.saveState` 从非 `'saved'` 变为 `'saved'` 且 `mode === 'preview'` → `setReloadSeq(+1)` |
|
||||
|
||||
刷新语义:重载走既有 load effect(AbortController 会中止旧加载;重挂载后 TextEditor 内部 state 重置为 preview 模式——与「关闭重开」等价,但保留 tab 与面板状态)。
|
||||
|
||||
## 范围外(记录,不实现)
|
||||
|
||||
- LLM 改文件后自动刷新(磁盘指纹对比 + silent 不闪屏)——分形有先例,另行立项;
|
||||
- image/pdf/binary viewer 的手动刷新(toolbar 为空,无入口;内容静态,低优先级)。
|
||||
|
||||
## 验证
|
||||
|
||||
- 单测:刷新按钮渲染 + 点击触发重载(mock fsRead 调用次数);B 的 dirty 抑制;C 的 saveState 边沿触发;
|
||||
- 手工:编辑 md → 保存 → 切预览即时渲染;预览模式 Ctrl+S 后刷新;刷新按钮任意 viewer 可用。
|
||||
@@ -0,0 +1,261 @@
|
||||
# 侧边栏面板注入方案设计:统一面板宿主(插件侧) + 官方槽位路线(上游侧)
|
||||
|
||||
**日期**: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:25(v0.15.x 起;初版为 40,2026-08-22 下调:ui-cordis 动态插件面板固定层 30,须在其上)
|
||||
├─ 右侧面板(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:25`,初版 40,2026-08-22 起 25);`.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`。
|
||||
- **z-index 40→25(2026-08-22)**:设计初稿的"30–99 无任何 DSH 元素"(源自 #52 修复记录)低估了官方 ui-cordis 的动态插件面板(fixed z-index 30,挂 `sidebar.footer.action` 槽)——better-sidebar 底部面板曾盖住其清单/审批面。下调宿主层至 25(AppFrame overlayLayer 20 之上、ui-cordis 30 与 DSH 浮层栈 100+ 之下),折叠按钮簇 45 保留为 host 内部层叠;`AGENTS.md` §8 与 `2026-08-15-skin-compat-design.md` 已同步。
|
||||
@@ -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 的标题前缀过滤兜底隐藏。
|
||||
@@ -0,0 +1,106 @@
|
||||
# 文件树「打开方式」子菜单设计(2026-08-22)
|
||||
|
||||
> 状态:已实现(分支 `feat/open-with-menu`)。本文档记录功能设计、关键取舍与实施偏差。
|
||||
|
||||
## 目标
|
||||
|
||||
文件树(`FileTree`)右键菜单新增「在应用中打开 >」子菜单:
|
||||
|
||||
- 内置 **资源管理器(在文件管理器中显示)、VS Code、Cursor、Zed** 四个打开方式;每个子菜单行右侧有**图钉**,点击可把该方式固定为右键菜单的**顶层直达项**(再点取消固定)。
|
||||
- **远端 SSH**:设置中配置可选 SSH host(`user@host` 或 `~/.ssh/config` 别名)。非空即整个工作区视为远端:VSCode 系条目(VS Code / Cursor / 自定义且勾选"VSCode 系")改用 `<scheme>://vscode-remote/ssh-remote+<host>/<path>` 打开;本地专用条目(资源管理器 / Zed / 非 VSCode 系自定义)从菜单隐藏。
|
||||
- **自定义编辑器**:名称 + URL 模板(`{path}` 占位符,如 `cursor://file/{path}`)+ 「是否 VSCode 系」开关;配置入口在**侧边栏设置 Files 卡片齿轮**(与既有 `editorExplorer` 下拉同一弹窗)。
|
||||
- 打开动作经宿主新路由 `POST /sidebar/api/open.external` 执行(argv 数组 spawn,无 shell 注入),浏览器模式与 DSH Desktop 行为一致;**不经过** `ctx.workspaces.openPath`(该入口已被 `wrapOpenPath` 劫持到侧边栏编辑器,见 `src/client/openpath-intercept.ts`)。
|
||||
|
||||
## 调研结论(reuse gate)
|
||||
|
||||
- DSH checkout(`~/.dsh/source/current`)无"文件树 open-with"、无 SSH 远程会话、无 `vscode://` 开启器;`host.openPath` 只能"默认应用打开",无法指定编辑器、无 reveal/select 语义 → 需要新增。
|
||||
- dsh-external hub 无同类成品;`dsh-open-in-vscode` 是工作区行菜单、依赖外部 slot,不适用。
|
||||
- primitives `Menu` 原生支持 `submenu?: readonly MenuItem[]`(子项点击走 `onSelect(child.id)`),`label: ReactNode` 可内嵌图钉;无现成图钉/品牌图标 → 引入 `react-icons`(`vsc` codicons 图钉 `VscPin`/`VscPinned` 与文件树 `VscFile`/`VscFolder(Opened)`、`si` 品牌剪影 Cursor/Zed、菜单父行 `VscLinkExternal`),VS Code 品牌剪影因 simple-icons 后期版本受微软商标政策下架而内嵌自 simple-icons@11.0.0(CC0,`IconVscode16`);菜单行图标统一 16px 与 DSH 原生菜单一致,子菜单父行的 `>` chevron 由 label 内右对齐元素提供(基元不渲染)。⚠️ react-icons 的 exports 把 `require` 排在 `import` 前,tsdown 共享 conditionNames 会选中不可树摇的 CJS 入口(bundle +6.4MB)——`tsdown.config.ts` 用 `resolve.alias` 钉死 `si`/`vsc` 的 ESM 入口,树摇后仅 +12KB。
|
||||
|
||||
## 数据模型
|
||||
|
||||
持久化在 `pluginSettings['editor'].openWith`(宿主 schema 的开放 map,无需改 PrefsSchema):
|
||||
|
||||
```json
|
||||
{
|
||||
"openWith": {
|
||||
"sshHost": "",
|
||||
"customEditors": [
|
||||
{ "id": "custom:<uuid>", "name": "Windsurf", "urlTemplate": "windsurf://file/{path}", "isVscodeFamily": false }
|
||||
],
|
||||
"pinned": ["vscode", "custom:<uuid>"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `parseOpenWithConfig`(`src/client/open-with.ts`)容错解析:坏行丢弃、未知 pinned 在解析目标时剪枝;**结构合法但未填完的行(空 name/模板)保留**——设置面板正在编辑的行必须能在弹窗往返中存活,由菜单侧的 `isValidCustomEditor` 过滤。
|
||||
- 目标 id:内置 `explorer` / `vscode` / `cursor` / `zed`;自定义 `custom:<uuid>`。
|
||||
- URL 构建:
|
||||
- 本地:模板原文替换 `{path}`(`vscode://file//home/u/f.ts`——POSIX 绝对路径自带前导 `/`,与 VS Code 官方文档 `vscode://file/c:/…` 的约定一致;反斜杠归一化为 `/`)。
|
||||
- SSH(VSCode 系):`<scheme>://vscode-remote/ssh-remote+<host>/<path>`,路径保留前导 `/`(如 `…+dev/home/u/f.ts`,与社区约定一致)。
|
||||
- 模板无 `scheme://` 或无 `{path}` → 返回 undefined(不打开)。
|
||||
|
||||
## 菜单结构(文件行与目录行一致)
|
||||
|
||||
```
|
||||
在新 Tab 中打开 ← 既有
|
||||
在侧边打开 ← 既有
|
||||
----------------------------
|
||||
VS Code (SSH) ← pinned 直达项(按内置顺序 + 自定义追加)
|
||||
Cursor (SSH)
|
||||
----------------------------
|
||||
在应用中打开 > ← 子菜单父项(始终在 pinned 之后)
|
||||
资源管理器 📌
|
||||
VS Code 📌(激活)
|
||||
Cursor 📌(激活)
|
||||
Zed 📌
|
||||
Windsurf 📌
|
||||
下载 ← 既有(仅文件行)
|
||||
复制相对地址 / 复制绝对地址 ← 既有
|
||||
```
|
||||
|
||||
- 图钉是 `label` 内的 `<span role="button" tabIndex={-1}>`(Menu 行本身是 `<button>`,嵌套原生 button 不合法);`stopPropagation` + `preventDefault` 使其**不关闭菜单、不触发行选择**,点击后经响应式 blob 重渲染立即翻转。
|
||||
- 未接线(props 缺省)或 SSH 过滤后无目标 → 整个区块隐藏,旧行为/旧测试不变。
|
||||
|
||||
## 设置面板:`settings.render` seam 扩展
|
||||
|
||||
**实施偏差(与最初"render 独占"契约不同,需同步文档)**:`SettingsBody`(`src/client/SideCardSection.tsx`)原先"有 `render` 就整个替换行列表"。本功能需要**行列表(`editorExplorer` 下拉)与自定义面板共存**,因此把 seam 改为:
|
||||
|
||||
> `render` 给定时**追加**渲染在行列表(`toggles` / `pluginToggles`)之后;未声明行列表时与旧行为一致(单独渲染)。
|
||||
|
||||
- 仓库内无既有 render 使用者,`tests/side-card-section.spec.tsx` 无"render 独占"断言,风险可控;`AGENTS.md` §3.1/§5 与本文档同步更新。
|
||||
- 面板组件 `OpenWithSettings`:SSH host 输入 + 自定义编辑器行(名称/模板/VSCode 系/删除)+「添加」;每次变更整体提交 `updatePluginSetting('openWith', next)`(含 pinned 原样保留)。
|
||||
- 图钉写入走 `src/client/plugin-settings.ts` 的 `updatePluginSettings`:Promise 链串行化 + `api.settingsUpdate` 后 `store.setPrefs(parsePrefs(view.value))`,避免快速连点丢写。(设置弹窗内部有自己独立的串行 commit,二者极少重叠。)
|
||||
|
||||
## 宿主路由
|
||||
|
||||
`POST /sidebar/api/open.external`(`src/index.ts` `buildApi`,与其他方法同 fence + 错误包裹):
|
||||
|
||||
```ts
|
||||
{ action: 'reveal', path } // 在文件管理器中显示/选中
|
||||
{ action: 'url', url } // 交给注册的协议处理器
|
||||
```
|
||||
|
||||
- `src/open-external.ts`:纯函数 `revealCommand` / `urlCommand`(platform 可注入,单测覆盖三平台)+ `launchExternal`(`spawn(..., { detached: true, stdio: 'ignore' })` + `unref`)。
|
||||
- `reveal`:darwin `open -R`;win32 `explorer.exe /select,`;linux `xdg-open <父目录>`(无统一 select 协议,KISS 打开所在目录)。
|
||||
- `url`:darwin `open`;win32 `rundll32 url.dll,FileProtocolHandler`(备选 `cmd /c start ""`);linux `xdg-open`。
|
||||
- 校验:`reveal` 路径必须绝对(`requireAbsolute`);`url` 必须是 `scheme://` 自定义协议(拒绝 http/https)。
|
||||
- **Windows 平台命令为约定实现,需在 Windows 实机验证**(本机为 macOS;CI 覆盖 linux)。
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 路径含 `#`/`?` 的文件名经 URL 打开可能被浏览器当作 fragment/query(不处理,注释说明)。
|
||||
- 编辑器未安装/协议未注册:由 OS 弹提示或静默失败,不做安装检测。
|
||||
- 自定义编辑器仅支持 URL 模板式,不支持 CLI 命令式;无 `{dir}` 占位符。
|
||||
- Linux reveal 退化为打开所在目录(精确 select 需要各文件管理器私有协议)。
|
||||
- SSH 模式为**全局**(非每会话):DSH 无远程会话概念,文件树路径即宿主路径;该模型对应"DSH 跑在远端开发机上"的场景。
|
||||
|
||||
## 测试
|
||||
|
||||
- `tests/open-with.spec.ts`:解析容错、目标解析与 SSH 过滤、URL 构建(本地/SSH/custom/坏模板)、校验器。
|
||||
- `tests/open-external.spec.ts`:三平台命令表、URL 校验、spawn 前校验。
|
||||
- `tests/file-tree-open-with.spec.tsx`(jsdom):右键 → 子菜单/图钉;pin 不选中不关闭;选子项回调关闭菜单;SSH 标签后缀;未接线隐藏。
|
||||
- `tests/open-with-settings.spec.tsx`:SSH 输入、添加/删除(删除剪枝 pinned)、无效提示、VSCode 系开关。
|
||||
- `tests/side-card-section.spec.tsx`:`SettingsBody` 行列表 + render 共存、仅 render 时不变。
|
||||
- `tests/builtins.spec.ts`:editor 描述符断言补 `settings.render`。
|
||||
- 全量 `pnpm test`(810 passed)、`pnpm typecheck`、`pnpm build` 全绿;未跑挂载冒烟(由 CI plugin-mount 门禁覆盖)。
|
||||
@@ -0,0 +1,96 @@
|
||||
# 设计:模型主动在侧边栏打开(`sidebar_open` 工具 + `agentOpenTools` 设置)
|
||||
|
||||
> 日期:2026-08-23 · 状态:已实现 · 仓库:omdsh-dev/DSH-better-sidebar
|
||||
|
||||
## 需求
|
||||
|
||||
新增一个侧边栏**全局设置**(Side card 设置页「常规」分组):`agentOpenTools`,**默认关闭**。
|
||||
开启后向模型注入**一个**工具 `sidebar_open`,允许模型主动在**调用方会话**的侧边栏中打开:
|
||||
|
||||
- 本地**文件** → 内置 editor tab(按 path 去重,聚焦既有 tab)
|
||||
- 本地**文件夹** → 内置 editor tab 的**文件夹窗口**(文件树以该目录为根)
|
||||
- **HTTP(S) 网页** → 内置 browser tab(URL 预填)
|
||||
|
||||
## 关键决策
|
||||
|
||||
### 1. 作用域 = 调用方 agent 会话
|
||||
|
||||
与 `terminal_*` 工具一致:工具只绑定 `exec.agent.session.id`,模型不传 sessionId。
|
||||
子代理(或未激活会话)调用时若无客户端连接,打开请求排队,该会话侧边栏下次可见时重放。
|
||||
工具结果通过 `delivered` 如实报告「当前是否已推送到已连接的侧边栏视图」。
|
||||
|
||||
### 2. 投递通道:host→client WS 推送(无 ack 协议)
|
||||
|
||||
- 新增 WS 升级路由 `/sidebar/ws/agent-opens?sessionId=...`(与 `/sidebar/ws/agent-terminals` 同模式,过同一 trust fence)。
|
||||
- host 侧 `AgentOpenRegistry` 维护会话级队列 + 已连接视图集合:
|
||||
- `enqueue`:有订阅者 → 即时推送并**消费**(`delivered: true`);无订阅者 → 保留队列(`delivered: false`)。
|
||||
- `attach`:注册订阅者并**重放**队列(消费即出队),返回 disposer(close/error 时退订)。
|
||||
- `drainAll`:特性关闭时丢弃未投递队列。
|
||||
- **消费即出队**是关键:browser tab 无 per-URL 去重,重连重放会铸造重复 tab;消费语义保证每个请求恰好推送一次。
|
||||
- 不加 ack 回执、不加 `/sidebar/api` 路由——结果的最佳努力语义(`delivered`)已足够,符合 KISS 与 terminal 先例。
|
||||
|
||||
### 3. `sidebar_open` 工具(唯一新增工具)
|
||||
|
||||
- 参数:`target`(必填:绝对/相对路径,或 http(s) URL)、`title`(可选:tab 标题)。
|
||||
- 执行流程:`throwIfAborted` → 分类 → 目标 tab 启用检查 → 入队 → 返回规范结果。
|
||||
- 分类规则:
|
||||
- `http://` / `https://`(大小写不敏感)→ `url`,默认标题 = hostname;
|
||||
- 其他 scheme(`file:` / `javascript:` / `vscode:` 等)→ 报错(Windows 盘符前缀 `C:\`/`C:/` 除外,它是路径不是 scheme);
|
||||
- 本地路径 → 相对路径以会话 cwd(`sessionCwdOf`)解析为绝对路径,`stat` 定 `file`/`folder`;不存在/不可读 → 报错;
|
||||
- 目标 tab 类型被用户禁用(`tabsEnabled['editor'|'browser'] === false`)→ 报错并提示启用(避免客户端静默 no-op)。
|
||||
- 输出(canonical JSON,`render` 纯文本投影):`{ kind, target, title, delivered }`。
|
||||
- 工具描述/错误消息为英文(与 8 个 `terminal_*` 一致)。
|
||||
|
||||
### 4. 文件夹窗口:editor tab 的 `meta.dir`
|
||||
|
||||
- 打开文件夹 = `openTab({ type: 'editor', path: <dir>, id: 'editor:'+<dir>, title, meta: { dir: true } })`。
|
||||
- EditorHost 识别 `meta.dir === true`:不进入 viewer 加载流程,渲染**全窗 TreePanel**(`cwd = tab.path`);
|
||||
FileTree 根部即 `cwd`(`fs.tree` 接受绝对路径),搜索结果/上传仍受会话工作区约束(已知限制,见下)。
|
||||
- `meta` 为可持久化的 tab 状态(v0.12.0+),`sanitizeState` 原样保留——刷新后文件夹窗口恢复。
|
||||
- 折叠/展开、右键打开、拖拽上传等全部复用 TreePanel 既有能力。
|
||||
|
||||
### 5. 设置与门控
|
||||
|
||||
- 新 prefs 字段 `agentOpenTools: boolean`(`SidebarPrefs` + `PrefsSchema` + 客户端 `parsePrefs`),默认 `false`。
|
||||
- SideCardSection「常规」分组新增一行 Switch(标题/描述走新 locale key)。
|
||||
- host 门控完全拷贝 `agentTerminalTools` 的 `syncToolsGate` 模式:
|
||||
- 开启 → `registerOpenTool(...)`;关闭 → 注销工具 + `agentOpenRegistry.drainAll()`;
|
||||
- 一次 `scope.watch` 同时驱动两个门控(各持有自己的 disposer,互不干扰);
|
||||
- 工具不依赖 node-pty,降级模式下也可用。
|
||||
- 客户端防御:Sidebar 的 agent-opens 推送订阅在 `prefs.agentOpenTools !== true` 时忽略消息(host 已门控,此为双保险)。
|
||||
|
||||
### 6. 与既有拦截开关的关系
|
||||
|
||||
- URL 打开**不受** `browserInterceptLinks` / `browserInterceptHttp` / `browserInterceptHttps` 约束——那是用户点击外链的接管开关;
|
||||
模型显式调用 `sidebar_open` 只受 browser tab 自身启用开关约束(openTab 既有 gating)。
|
||||
- 文件打开**不受** `interceptOpenPath` 影响(那是聊天点击路径的接管开关)。
|
||||
|
||||
## 既有模式复用(零新公共 API)
|
||||
|
||||
| 新事物 | 复用/镜像的既有模式 |
|
||||
|---|---|
|
||||
| `agentOpenTools` 门控 | `agentTerminalTools` 门控(settings seam `watch` → 注册/注销) |
|
||||
| `/sidebar/ws/agent-opens` | `/sidebar/ws/agent-terminals` 推送(升级注册、fence、每会话 attach) |
|
||||
| 客户端推送订阅 | Sidebar 的 agent-terminals effect(重连上限 `FAILURE_LIMIT`、防抖/清理) |
|
||||
| 文件/URL 打开 | `service.openFile` / `openTab({ type:'browser', url })`(link-intercept 同款) |
|
||||
| 文件夹窗口 | path-less explorer 的全窗 TreePanel 分支(仅根目录改为 `tab.path`) |
|
||||
|
||||
不新增 `BetterSidebarService` 方法;不改变任何现有路由协议。
|
||||
|
||||
## 已知限制
|
||||
|
||||
- **多浏览器窗口**:推送按「有订阅者即发送并消费」执行,多窗口同时连接时后连接的窗口不会收到已消费的请求
|
||||
(其本体状态由 localStorage 恢复——与 agent-terminals 的全量列表 fan-out 不同,属尽力而为语义)。
|
||||
- **文件夹窗口的全局搜索/上传**:TreePanel 的搜索盒仍以**会话 cwd** 为根(`fs.search` 是有意的工作区限定),
|
||||
目录内上传经 host 会话工作区校验(目录在 cwd 内可上传,cwd 外拒绝)。
|
||||
- **无 ack**:`delivered: true` 只表示「推送已发出」,客户端处理失败(罕见)不重试。
|
||||
- **关闭特性不清已打开 tab**:与 tab 启用开关语义一致,只注销工具并清空未投递队列。
|
||||
|
||||
## 测试覆盖
|
||||
|
||||
- `tests/agent-opens.spec.ts`(新):注册表语义(排队/重放/消费/作用域隔离/drain/dispose)+ 工具行为
|
||||
(分类、相对路径、缺路径、非 http(s) scheme、tab 禁用、schema 校验、无 agent 错误)。
|
||||
- `tests/smoke.spec.ts`:WS 升级路由清单 + `settings.get` 默认值 + 独立 gating 测试(默认 off → 0 工具;on → 1;off → 注销)。
|
||||
- `tests/plugin-shape.spec.ts` / `tests/prefs.spec.ts`:schema/parse 默认值 + 全量 prefs 字面量。
|
||||
- `tests/editor-host.spec.tsx`:`meta.dir` 文件夹窗口(树以目录为根、无编辑器 chrome、不触发 viewer 加载)。
|
||||
- `tests/locales.spec.ts`:zh/en/ja 词典 key 集相等(自动覆盖新键)。
|
||||
@@ -0,0 +1,408 @@
|
||||
# Bug PR 一次性合并方案(merge train + 并行 subagent)
|
||||
|
||||
> 仓库:`omdsh-dev/DSH-better-sidebar`(本地 clone:`D:\Projects\deepseek-harness\DSH-better-sidebar`)
|
||||
> 基准:main `d6d8db0` (= 0.15.2 + #339 locale ja)
|
||||
> 目标:把 12 个 bug PR 一次性合入 main,**保留每个原作者的贡献归属**
|
||||
> 输入:`bug-pr-review.md` 中 12 个 NOT_FIXED PR
|
||||
> 日期:2026-08-23
|
||||
|
||||
---
|
||||
|
||||
## 0. 设计原则
|
||||
|
||||
| 原则 | 说明 |
|
||||
|---|---|
|
||||
| 保留作者归属 | 用 `git merge --no-ff`(**不 squash**),每个 PR 的原始 commits 全部进 main 历史;GitHub 通过 commit author email 匹配到 12 位作者 |
|
||||
| 跨平台 commit trailer | #285 作者邮箱 `fix@local.invalid` 不匹配 GitHub → 在 merge commit message 加 `Co-authored-by: xxxg0001 <xxxg0001@users.noreply.github.com>` |
|
||||
| 零源码 patch | train 分支所有改动都来自 PR 本身或冲突解决;**不修改任何 PR 的 commits** |
|
||||
| 并行加速 | 4 个 subagent 各自 worktree 独立合并簇内 PR;跨簇冲突由主 agent 串行解决 |
|
||||
| 每步验证 | 每个 subagent 完成簇内合并后跑 `pnpm typecheck`;主 agent 跨簇合并后跑 `pnpm typecheck && pnpm test` |
|
||||
|
||||
---
|
||||
|
||||
## 1. PR 清单与作者
|
||||
|
||||
| PR | 标题 | 作者 | author email |
|
||||
|---|---|---|---|
|
||||
| #345 | 限制文件 API 访问会话工作区 | wang-kaopu | 1970138194@qq.com |
|
||||
| #342 | 搜索跳过 node_modules 等噪声目录 | sunrain520 | sunrain520@users.noreply.github.com |
|
||||
| #341 | mermaid 抑制全局错误渲染 | duyiliu | 278950578@qq.com |
|
||||
| #338 | 移除公开版 cordis peer,迁移类型基底 | Menghuan1918 | menghuan2003@outlook.com |
|
||||
| #336 | tsdown 配置加载缺 unrun devDependency | MilchstraBe-Endless10 | milchstrasse.endless10@users.noreply.github.com |
|
||||
| #330 | panel host 降到 DSH dynamic-plugin 层下 | ZgblKylin | 13114351+ZgblKylin@users.noreply.github.com |
|
||||
| #326 | 支持选择子 git 仓库 | loeanxi | 2012065136@qq.com |
|
||||
| #310 | layout push 下保 composer 在视口内 | Zzy-min | zzy19812007@gmail.com |
|
||||
| #292 | markdown 预览本地/相对路径图片 | huzilin | huzilinha@gmail.com |
|
||||
| #285 | linked worktrees 移植到 RC8 | xxxg0001 | fix@local.invalid ⚠ |
|
||||
| #278 | 面板宿主裁剪视口、收起面板不再撑出双向滚动 | ch3n4y | ch3n4y@gmail.com |
|
||||
| #254 | mobile 说明不可用侧边栏并防溢出 | SparkElf | laoyh@chinatelecom.cn |
|
||||
|
||||
⚠ #285 author email 不匹配 GitHub 账号,需在 merge commit 加 `Co-authored-by` trailer 补 credit。
|
||||
|
||||
---
|
||||
|
||||
## 2. 文件冲突矩阵(跨簇)
|
||||
|
||||
> 仅列出被 ≥2 个 PR 修改、且**跨簇**的文件。簇内冲突由 subagent 解决。
|
||||
|
||||
| 文件 | 涉及 PR(按簇) |
|
||||
|---|---|
|
||||
| `README.md` / `README_EN.md` | A: #338 #345;B: #292;C: #254 |
|
||||
| `AGENTS.md` | A: #338;B: #342;C: #330 |
|
||||
| `docs/external-plugin-guide.md` | A: #338 #345(簇内解决) |
|
||||
| `package.json` | A: #338 #336;C: #254;D: #326 |
|
||||
| `dsh.plugin.json` | A: #338;C: #254 |
|
||||
| `src/index.ts` | A: #338 #345;D: #285 #326 |
|
||||
| `src/client/sidebar.module.css` | C: #330 #278 #254(簇内解决);D: #285 |
|
||||
| `src/client/Sidebar.tsx` | B: #310;C: #254 |
|
||||
| `src/client/service.ts` | A: #338;C: #254 |
|
||||
| `src/client/DiffTab.tsx` / `GitView.tsx` / `api.ts` / `state.ts` / `src/git.ts` | D: #285 #326(簇内解决) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 分簇(簇内串行,跨簇并行)
|
||||
|
||||
### Cluster A — 基础设施(package.json / cordis / path security)
|
||||
|
||||
**分支**:`train/base`
|
||||
**worktree**:`D:\Projects\deepseek-harness\dsb-train-base`
|
||||
**合并顺序**:
|
||||
1. `#338` (cordis peer 迁移) — 改 package.json/dsh.plugin.json/src/index.ts/service.ts/AGENTS.md/README/docs
|
||||
2. `#336` (unrun devDep) — 只改 package.json
|
||||
3. `#345` (path security) — 改 README/docs/src/index.ts/fs-operations/html-route/path-security 新增
|
||||
|
||||
**簇内冲突点**:
|
||||
- #336 vs #338:package.json(#338 移除 cordis peer,#336 加 unrun devDep,行不冲突但邻近)
|
||||
- #345 vs #338:README.md / README_EN.md / docs/external-plugin-guide.md / src/index.ts(#338 迁移基底,#345 加 path-security 引用,可能 src/index.ts 注册新路由撞)
|
||||
|
||||
### Cluster B — 独立修复
|
||||
|
||||
**分支**:`train/misc`
|
||||
**worktree**:`D:\Projects\deepseek-harness\dsb-train-misc`
|
||||
**合并顺序**:
|
||||
1. `#341` (mermaid suppressErrorRendering) — 改 src/client/mermaid.tsx + 测试
|
||||
2. `#310` (layout-push composer clip) — 改 Sidebar.tsx + layout-push.ts + breakpoints.ts + layout.css + 测试
|
||||
3. `#342` (fs-search skip dirs) — 改 fs-search.ts + AGENTS.md + 测试
|
||||
4. `#292` (markdown images) — 改 TextEditor.tsx + markdown-images.ts + README + 测试
|
||||
|
||||
**簇内冲突点**:基本无(彼此文件不重叠)。#342 的 AGENTS.md 和 #292 的 README 是跨簇冲突,本 worktree 内基于 main,无冲突。
|
||||
|
||||
### Cluster C — CSS 簇(sidebar.module.css)
|
||||
|
||||
**分支**:`train/css`
|
||||
**worktree**:`D:\Projects\deepseek-harness\dsb-train-css`
|
||||
**合并顺序**:
|
||||
1. `#330` (z-index drop) — 改 sidebar.module.css:35 + AGENTS.md + docs/plans + e2e 测试
|
||||
2. `#278` (overflow hidden + panelHidden transform) — 改 sidebar.module.css + e2e 测试
|
||||
3. `#254` (mobile no-session feedback + overflow) — 改 Sidebar.tsx + sidebar.module.css + README + dsh.plugin.json + package.json + service.ts
|
||||
|
||||
**簇内冲突点**:
|
||||
- #278 vs #330:sidebar.module.css(#330 改 z-index 行,#278 改 overflow/transform 行,可能行邻近但不直接冲突)
|
||||
- #254 vs #330/#278:sidebar.module.css(mobile media query 区域,可能撞 e2e 测试)
|
||||
|
||||
### Cluster D — git 簇
|
||||
|
||||
**分支**:`train/git`
|
||||
**worktree**:`D:\Projects\deepseek-harness\dsb-train-git`
|
||||
**合并顺序**:
|
||||
1. `#285` (worktree RC8 forward-port) — 改 DiffTab/GitView/api/state/git.ts/index.ts/builtins/tabs.tsx/locales.ts/sidebar.module.css + 测试
|
||||
2. `#326` (multi-repo selector) — 改 DiffTab/GitView/api/state/git.ts/index.ts/package.json + 截图 + 测试
|
||||
|
||||
**簇内冲突点**(严重):
|
||||
- #326 vs #285:5 个核心文件全撞(DiffTab/GitView/api/state/git.ts)+ src/index.ts
|
||||
- #326 应建立在 #285 的 worktree 支持基础上(多仓选择是 worktree list 的扩展)
|
||||
- 合并 #326 时需要手工缝合:保留 #285 的 worktree list 逻辑 + #326 的子仓选择 UI
|
||||
|
||||
---
|
||||
|
||||
## 4. 执行流程
|
||||
|
||||
### Phase 0 — 准备(主 agent,串行)
|
||||
|
||||
```powershell
|
||||
$repo = "D:\Projects\deepseek-harness\DSH-better-sidebar"
|
||||
|
||||
# 0.1 拉所有 PR 分支到本地 ref(避免 subagent 重复 fetch)
|
||||
$prs = 345,342,341,338,336,330,326,310,292,285,278,254
|
||||
foreach ($n in $prs) {
|
||||
git -C $repo fetch origin "pull/$n/head:pr-$n"
|
||||
}
|
||||
|
||||
# 0.2 创建 4 个 worktree(每个独立目录,独立分支基于 origin/main)
|
||||
git -C $repo worktree add "D:\Projects\deepseek-harness\dsb-train-base" -b train/base origin/main
|
||||
git -C $repo worktree add "D:\Projects\deepseek-harness\dsb-train-misc" -b train/misc origin/main
|
||||
git -C $repo worktree add "D:\Projects\deepseek-harness\dsb-train-css" -b train/css origin/main
|
||||
git -C $repo worktree add "D:\Projects\deepseek-harness\dsb-train-git" -b train/git origin/main
|
||||
|
||||
# 0.3 每个 worktree 都 pnpm install(独立 node_modules,避免锁文件竞争)
|
||||
foreach ($wt in @("dsb-train-base","dsb-train-misc","dsb-train-css","dsb-train-git")) {
|
||||
Push-Location "D:\Projects\deepseek-harness\$wt"
|
||||
pnpm install --prefer-offline
|
||||
Pop-Location
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 1 — 簇内并行合并(4 个 subagent 同时跑)
|
||||
|
||||
每个 subagent 接收:
|
||||
- 自己的 worktree 路径
|
||||
- 簇内 PR 编号和合并顺序
|
||||
- PR 的预期改动文件清单(见 §3)
|
||||
- 验证命令:`pnpm typecheck`(必跑)+ `pnpm test`(如簇内有相互依赖的测试)
|
||||
|
||||
#### Subagent A(base)
|
||||
|
||||
```
|
||||
worktree: D:\Projects\deepseek-harness\dsb-train-base
|
||||
合并顺序: #338 → #336 → #345
|
||||
命令:
|
||||
git merge --no-ff pr-338 -m "merge #338: 移除公开版 cordis peer,迁移类型基底到 @deepseek-ai/cordis"
|
||||
# 解决冲突(README/docs/package.json/src/index.ts)
|
||||
pnpm typecheck
|
||||
|
||||
git merge --no-ff pr-336 -m "merge #336: add unrun devDependency for tsdown config loading"
|
||||
# 解决 package.json 冲突
|
||||
pnpm typecheck
|
||||
|
||||
git merge --no-ff pr-345 -m "merge #345: 限制文件 API 访问会话工作区"
|
||||
# 解决 README/docs/src/index.ts 冲突
|
||||
pnpm typecheck
|
||||
|
||||
完成后输出:
|
||||
- train/base 的 HEAD SHA
|
||||
- 解决的冲突文件清单
|
||||
- 跨簇预期冲突点(README.md / package.json / src/index.ts / docs/external-plugin-guide.md / AGENTS.md / dsh.plugin.json / src/client/service.ts)
|
||||
```
|
||||
|
||||
#### Subagent B(misc)
|
||||
|
||||
```
|
||||
worktree: D:\Projects\deepseek-harness\dsb-train-misc
|
||||
合并顺序: #341 → #310 → #342 → #292
|
||||
命令:
|
||||
git merge --no-ff pr-341 -m "merge #341: fix(mermaid): suppress global error rendering"
|
||||
pnpm typecheck
|
||||
|
||||
git merge --no-ff pr-310 -m "merge #310: keep conversation composer in viewport under layout push"
|
||||
pnpm typecheck
|
||||
|
||||
git merge --no-ff pr-342 -m "merge #342: fs-search skip node_modules and other noise dirs"
|
||||
# AGENTS.md 可能无冲突(基于 main,A 簇的 AGENTS.md 改动在另一个 worktree)
|
||||
pnpm typecheck
|
||||
|
||||
git merge --no-ff pr-292 -m "merge #292: markdown preview local/relative images"
|
||||
pnpm typecheck
|
||||
|
||||
完成后输出:
|
||||
- train/misc 的 HEAD SHA
|
||||
- 跨簇预期冲突点(AGENTS.md 来自 #342;README.md 来自 #292;Sidebar.tsx 来自 #310)
|
||||
```
|
||||
|
||||
#### Subagent C(css)
|
||||
|
||||
```
|
||||
worktree: D:\Projects\deepseek-harness\dsb-train-css
|
||||
合并顺序: #330 → #278 → #254
|
||||
命令:
|
||||
git merge --no-ff pr-330 -m "merge #330: drop panel host below DSH cordis dynamic-plugin layer"
|
||||
pnpm typecheck
|
||||
|
||||
git merge --no-ff pr-278 -m "merge #278: panel host clip viewport, hidden panel no longer causes双向 overflow"
|
||||
# sidebar.module.css 可能冲突
|
||||
pnpm typecheck
|
||||
|
||||
git merge --no-ff pr-254 -m "merge #254: mobile explain unavailable sidebar and prevent overflow"
|
||||
# sidebar.module.css 可能冲突;其他文件基于 main 无冲突
|
||||
pnpm typecheck
|
||||
|
||||
完成后输出:
|
||||
- train/css 的 HEAD SHA
|
||||
- 跨簇预期冲突点(sidebar.module.css 来自 #285;README/package.json/dsh.plugin.json/Sidebar.tsx/service.ts 来自 #254)
|
||||
```
|
||||
|
||||
#### Subagent D(git)
|
||||
|
||||
```
|
||||
worktree: D:\Projects\deepseek-harness\dsb-train-git
|
||||
合并顺序: #285 → #326
|
||||
命令:
|
||||
git merge --no-ff pr-285 -m "merge #285: forward-port linked worktrees to RC8
|
||||
|
||||
Co-authored-by: xxxg0001 <xxxg0001@users.noreply.github.com>"
|
||||
# 注意:#285 author email = fix@local.invalid,必须加 Co-authored-by trailer
|
||||
pnpm typecheck
|
||||
|
||||
git merge --no-ff pr-326 -m "merge #326: support selecting child git repositories"
|
||||
# 严重冲突:DiffTab/GitView/api/state/git.ts/src/index.ts
|
||||
# 缝合策略:保留 #285 的 worktree list 逻辑 + #326 的子仓选择 UI
|
||||
pnpm typecheck
|
||||
|
||||
完成后输出:
|
||||
- train/git 的 HEAD SHA
|
||||
- 跨簇预期冲突点(sidebar.module.css 来自 #285;src/index.ts 与 base 簇撞;package.json 与 base 簇撞)
|
||||
- #326 缝合的关键决策(哪些函数来自 #285,哪些来自 #326)
|
||||
```
|
||||
|
||||
### Phase 2 — 跨簇串行合并(主 agent)
|
||||
|
||||
```powershell
|
||||
$repo = "D:\Projects\deepseek-harness\DSH-better-sidebar"
|
||||
|
||||
# 切回主 worktree
|
||||
# 2.1 创建 mega-train 分支
|
||||
git -C $repo checkout -b merge/bug-pr-train origin/main
|
||||
|
||||
# 2.2 merge train/base(应该 ff,因为 base 直接基于 main)
|
||||
git -C $repo merge --no-ff train/base -m "integrate train/base: cordis peer migration + unrun devDep + path security"
|
||||
|
||||
# 2.3 merge train/css
|
||||
git -C $repo merge --no-ff train/css -m "integrate train/css: panel z-index + overflow clip + mobile sidebar"
|
||||
# 解决冲突:
|
||||
# - sidebar.module.css(train/base 没改,应该无冲突)
|
||||
# - README.md(train/base 改了,train/css 的 #254 也改了 → 冲突)
|
||||
# - package.json(train/base 加了 unrun devDep,train/css 的 #254 可能加 mobile deps → 冲突)
|
||||
# - dsh.plugin.json(train/base 改了,train/css 的 #254 也改了 → 冲突)
|
||||
# - src/client/service.ts(train/base 迁移了 cordis,train/css 的 #254 可能改了 mobile 检测 → 冲突)
|
||||
pnpm install # 合并 package.json 后必须重装
|
||||
pnpm typecheck
|
||||
|
||||
# 2.4 merge train/git
|
||||
git -C $repo merge --no-ff train/git -m "integrate train/git: worktree RC8 forward-port + multi-repo selector"
|
||||
# 解决冲突:
|
||||
# - src/index.ts(train/base 改了 cordis 迁移,train/git 改了 worktree 注册 → 冲突)
|
||||
# - src/client/sidebar.module.css(train/css 改了 z-index/overflow/mobile,train/git 的 #285 可能改了 git 面板样式 → 冲突)
|
||||
# - package.json(train/git 的 #326 可能加了依赖 → 冲突)
|
||||
pnpm install
|
||||
pnpm typecheck
|
||||
|
||||
# 2.5 merge train/misc
|
||||
git -C $repo merge --no-ff train/misc -m "integrate train/misc: mermaid + layout-push + fs-search + markdown-images"
|
||||
# 解决冲突:
|
||||
# - AGENTS.md(train/base 改了,train/misc 的 #342 也改了 → 冲突)
|
||||
# - README.md(train/base + train/css 都改了,train/misc 的 #292 又改 → 三方合并冲突)
|
||||
# - src/client/Sidebar.tsx(train/misc 的 #310 改了 layout,train/css 的 #254 改了 mobile → 冲突)
|
||||
pnpm typecheck
|
||||
|
||||
# 2.6 全量验证
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
```
|
||||
|
||||
### Phase 3 — 推送与开 PR(主 agent)
|
||||
|
||||
```powershell
|
||||
# 3.1 推 mega-train 到 fork(不是 origin,因为 origin 是 omdsh-dev 上游)
|
||||
git -C $repo push fork merge/bug-pr-train
|
||||
|
||||
# 3.2 开 mega-PR(base = main,head = HuanLinOTO:merge/bug-pr-train)
|
||||
gh pr create `
|
||||
--repo omdsh-dev/DSH-better-sidebar `
|
||||
--base main `
|
||||
--head HuanLinOTO:merge/bug-pr-train `
|
||||
--title "merge: bug PR train (12 PRs, --no-ff to preserve authors)" `
|
||||
--body "Closes #345, #342, #341, #338, #336, #330, #326, #310, #292, #285, #278, #254
|
||||
|
||||
一次性合并 12 个 bug PR,使用 --no-ff 保留每个 PR 的原始 commits 和作者归属。
|
||||
|
||||
合并顺序与分簇:
|
||||
- Cluster A (base): #338 → #336 → #345
|
||||
- Cluster B (misc): #341 → #310 → #342 → #292
|
||||
- Cluster C (css): #330 → #278 → #254
|
||||
- Cluster D (git): #285 → #326
|
||||
|
||||
并行 subagent 在 4 个独立 worktree 中合并簇内 PR,主 agent 串行合并 4 个 train 子分支。
|
||||
|
||||
#285 author email 不匹配 GitHub,merge commit 加了 Co-authored-by: xxxg0001 trailer。
|
||||
|
||||
详细方案:docs/plans/2026-08-23-bug-pr-merge-train.md"
|
||||
|
||||
# 3.3 PR 创建后,13 个 PR(12 个原始 + 1 个 mega)会显示在 PR 列表
|
||||
# 等 CI 通过后用 --merge 合并(不要 squash):
|
||||
gh pr merge <mega-PR-number> --repo omdsh-dev/DSH-better-sidebar --merge
|
||||
# 合并后 12 个原始 PR 因 Closes 关键字自动 close
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 验证清单
|
||||
|
||||
### Phase 1 完成后(每个 subagent 自查)
|
||||
|
||||
- [ ] 簇内所有 PR 已 `--no-ff` 合并
|
||||
- [ ] `pnpm typecheck` 通过
|
||||
- [ ] git log 显示每个 PR 的原始 commits 保留
|
||||
- [ ] 输出 train 子分支的 HEAD SHA 给主 agent
|
||||
- [ ] 输出解决的冲突文件清单
|
||||
- [ ] 输出跨簇预期冲突点
|
||||
|
||||
### Phase 2 完成后(主 agent 自查)
|
||||
|
||||
- [ ] 4 个 train 子分支全部并入 `merge/bug-pr-train`
|
||||
- [ ] `pnpm install` 成功
|
||||
- [ ] `pnpm typecheck` 通过
|
||||
- [ ] `pnpm test` 通过(win32 平台已知失败项可记录,不算回归)
|
||||
- [ ] `git log --oneline d6d8db0..HEAD` 显示 12 个 merge commit + 各 PR 的原始 commits
|
||||
|
||||
### Phase 3 完成后
|
||||
|
||||
- [ ] mega-PR 在 omdsh-dev/DSH-better-sidebar 创建成功
|
||||
- [ ] PR body 包含 12 个 `Closes #N`
|
||||
- [ ] CI 通过(或失败项是已知非回归)
|
||||
- [ ] 用 `--merge` 合并(**不是 squash**)
|
||||
- [ ] 合并后 12 个原始 PR 全部 close
|
||||
- [ ] main 上 `git log --author=<每位作者>` 都能查到 commits
|
||||
|
||||
---
|
||||
|
||||
## 6. 风险点与应对
|
||||
|
||||
| 风险 | 概率 | 应对 |
|
||||
|---|---|---|
|
||||
| Phase 1 某个 subagent 簇内冲突解决失败 | 中 | 主 agent 介入协助;最坏退回该簇重做 |
|
||||
| Phase 2 跨簇冲突比预期复杂(特别是 README 三方合并) | 高 | 手工缝合;以 #338 的 cordis 迁移为基底,叠加其他 PR 的章节 |
|
||||
| Phase 2 #326 缝合 #285 后逻辑不一致(worktree list + 子仓选择) | 中 | 优先保 #285 的 RC8 forward-port 完整性;#326 的 UI 适配其 API |
|
||||
| `pnpm test` 在 win32 平台已知失败 | 中 | 记录为已知非回归,不阻塞合并(参考 AGENTS.md "上游测试可能有 win32 平台假设失败") |
|
||||
| GitHub SSL 抖动(fetch 失败) | 低 | 重试 / 用 `gh pr diff` 替代 fetch / 走 fork remote |
|
||||
| `--merge` 合并 mega-PR 后 contributors 列表未立即更新 | 低 | GitHub 异步计算 contributors,等 24h;只要 commit author email 匹配即可 |
|
||||
| #285 的 `fix@local.invalid` 邮箱即使加 Co-authored-by 也不被 GitHub 识别 | 中 | 已用 noreply 邮箱作为 Co-authored-by,GitHub 会识别;最坏在 PR body 注明 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 时间预估
|
||||
|
||||
| 阶段 | 串行耗时 | 并行耗时 |
|
||||
|---|---|---|
|
||||
| Phase 0 准备 | 5 min | 5 min |
|
||||
| Phase 1 簇内合并 | 60 min | **20 min**(4 个 subagent 并行,取最慢的 git 簇) |
|
||||
| Phase 2 跨簇合并 | 30 min | 30 min(必须串行) |
|
||||
| Phase 3 推送 + PR | 5 min | 5 min |
|
||||
| **总计** | 100 min | **60 min** |
|
||||
|
||||
并行加速比 ≈ 1.7×(受 Phase 2 串行合并限制)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 回滚
|
||||
|
||||
```powershell
|
||||
# 任意阶段失败,回滚到 main
|
||||
git -C $repo checkout main
|
||||
git -C $repo branch -D merge/bug-pr-train train/base train/misc train/css train/git
|
||||
|
||||
# 清理 worktree
|
||||
git -C $repo worktree remove "D:\Projects\deepseek-harness\dsb-train-base" --force
|
||||
git -C $repo worktree remove "D:\Projects\deepseek-harness\dsb-train-misc" --force
|
||||
git -C $repo worktree remove "D:\Projects\deepseek-harness\dsb-train-css" --force
|
||||
git -C $repo worktree remove "D:\Projects\deepseek-harness\dsb-train-git" --force
|
||||
|
||||
# 删除本地 PR ref
|
||||
$prs = 345,342,341,338,336,330,326,310,292,285,278,254
|
||||
foreach ($n in $prs) { git -C $repo branch -D "pr-$n" }
|
||||
```
|
||||
|
||||
如果 mega-PR 已合并到 main 但发现严重回归:
|
||||
|
||||
```powershell
|
||||
# Revert mega-PR 的 merge commit(保留历史,不删除 PR 记录)
|
||||
gh pr merge <mega-PR-number> --repo omdsh-dev/DSH-better-sidebar --revert
|
||||
```
|
||||
@@ -0,0 +1,82 @@
|
||||
# 自由窗口(Free Window)设计文档
|
||||
|
||||
- 日期:2026-08-23
|
||||
- 版本:v0.16.0
|
||||
- 分支:`feat/free-window`
|
||||
- 状态:已实现(本文含实施偏差记录)
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
侧边栏的 tab 只能活在右面板 / 底面板的 pane 里。用户在主会话区域工作的同时想盯一个终端、一个文件或一个插件页面时,只能挤压面板宽度或来回切换 tab。
|
||||
|
||||
**目标**:把任一 tab 拖到主会话区域(conversation 列)上松开,它变成一个可自由移动/缩放的**自由窗口**,悬浮在会话区之上;随时可拖回侧边栏停靠或一键返回。
|
||||
|
||||
**非目标(KISS 排除项)**:
|
||||
|
||||
- 无四边/四角缩放(只有 SE 角)、无双击最大化;
|
||||
- 浮窗单 tab(无内嵌迷你标签栏);
|
||||
- 停靠不支持 edge split(只 center 合并进目标 pane);
|
||||
- 无窗口数量上限、无磁吸/网格对齐;
|
||||
- 窄视口(合并抽屉)禁用拖出手势(抽屉覆盖会话区,无处可放);右键菜单入口仍可用。
|
||||
|
||||
## 2. 交互设计(用户视角)
|
||||
|
||||
| 手势/入口 | 行为 |
|
||||
|---|---|
|
||||
| 按住 tab 拖到主会话区域 | 会话列出现虚线提示浮层「松开以在自由窗口中打开」;松开 → tab **移动**(pane 中移除)为浮窗,默认 390×780(手机竖屏比例;创建时按视口钳制)居中于松点、钳入视口 |
|
||||
| tab 右键菜单「移动到自由窗口」 | 同上,落点为会话列中心(右面板与底面板的 TabBar 共用组件,一处生效) |
|
||||
| 浮窗头部按住拖动 | 移动窗口(pointer capture + rAF 直写 DOM + 松手提交 store);拖到侧边栏 pane 上方时该 pane 高亮,松开即停靠(center 合并) |
|
||||
| 浮窗头部右键 | 菜单:「回到侧边栏」(停靠到激活 pane)/「关闭」 |
|
||||
| 浮窗右下角拖动 | SE 角缩放(≥320×200,锚定左上角,钳入视口) |
|
||||
| 点击浮窗任意处 | 置顶(层叠顺序 = `floats` 数组序,末位最上) |
|
||||
| 浮窗头部 X | 关闭该 tab(走 `closeTab` 正常流程:触发 `onClose`、释放终端 pty) |
|
||||
|
||||
刷新页面:浮窗随会话状态从 localStorage 原样恢复(tab + 几何)。
|
||||
|
||||
## 3. 状态模型(`src/client/state.ts`)
|
||||
|
||||
```ts
|
||||
interface FloatWindow {
|
||||
id: string // uid('float'),与 pane/tab/split 共享计数器
|
||||
tab: SidebarTab // 移动语义:浮窗拥有 tab,pane 不再持有
|
||||
x: number; y: number // 视口坐标(左上角)
|
||||
w: number; h: number
|
||||
}
|
||||
// SidebarState.floats: FloatWindow[](数组序 = 层叠序)
|
||||
```
|
||||
|
||||
新增纯 reducer:`floatTab`(两棵树定位 + 移除 + 清空折叠 + activePane 回落)、`moveFloat` / `resizeFloat`(钳制,no-op 返回原引用避免持久化搅动)、`raiseFloat`(移末尾,幂等)、`dockFloat`(目标 pane 缺省 activePane,stale 回落右树首叶)、`closeFloatByTab`、查找助手 `floatWithTab` / `floatById`。
|
||||
|
||||
**既有查找路径全部扩展到 floats**(一致性关键):`tabOpenIn`、`patchTab`(updateTab,浏览器浮窗导航持久化 URL)、`openTabInActivePane` 的 id 安全网(命中 → raise)、`reconcileAgentTerminals`(浮窗 agent 终端随宿主列表消失则整窗移除)、`maxCounterId`(`float:N` 前缀参与 uid 播种,防 reload 碰撞)。
|
||||
|
||||
**sanitize**:`floats` 缺省 → `[]`;逐条宽容校验(id/tab 复用与叶子共享的 `sanitizePersistedTab`;几何非有限数或重复 id → 丢弃该条,不动整体布局);几何钳入当前视口;diff/ephemeral tab 不持久化;`explorer` 旧类型在浮窗内同样迁移为 editor home。
|
||||
|
||||
## 4. 实现要点
|
||||
|
||||
- **拖出检测**(`Sidebar.tsx`,document 捕获层):`dragover`/`drop`/`dragend` 捕获监听,以 `body[data-dsh-tab-dragging']`(TabBar 维护的 tab 拖拽标记)门控;目标在 `[data-dsh-panel-host]` 内则无视(pane 的 split/merge 自治);在 conversation 列(`#root [data-slot="conversation"]` 父列实时 `getBoundingClientRect()`)内 `preventDefault()`(武装 drop)+ 显示提示浮层(同值 rect 跳过 setState,避免 60Hz 重渲染);`drop` 读 `application/x-dsh-tab` payload 调 `floatTab(tabId, clientX, clientY)`。
|
||||
- **FreeWindow**(新文件 `FreeWindow.tsx`):头部/SE 角 pointer capture 拖拽——照抄面板拖拽的「rAF 直写 `style.left/top/width/height` + 松手提交 store + pointercancel/lostpointercapture 取 last-applied 兜底」模式;拖动中每帧对 `[data-dsh-pane]` 矩形命中检测,命中 pane 直接 DOM 打 `data-dsh-float-dock-over` 高亮(不经 React state,避免拖拽期全树重渲染);松手命中 → `dockFloat(paneId)`,未命中 → `moveFloat`。jsdom 无 pointer capture API,取 `setPointerCapture?.()` 容忍模式(与 `EditorHost.tsx` 同款)。
|
||||
- **服务层**(`service.ts`):`closeTab`/`activateTab` 的浮窗分支;`openTab` 的 dedupeKey/id 匹配与生命周期分类纳入 floats——**聚焦浮动 tab = 置顶窗口**,且内容型打开不触发面板自动展开(浮窗与面板开合无关,恒可见);`features` 追加 `'floatWindows'`。
|
||||
- **渲染**:浮窗渲染在 `[data-dsh-panel-host]` 内(视口坐标、免疫壳层 transform 劫持),z-index 42(面板 40 之上、开关簇 45 与 DSH 浮层之下);内容复用 `renderTab`/`TabContent`(每 tab RenderBoundary、memo),`visible` 恒 true。
|
||||
- **样式**(`sidebar.module.css`):全令牌驱动(`--dsw-alias-bg-layer-1` 表面、`--dsw-alias-border-l2` 边、`--dsw-shadow-lv3` 阴影、accent 虚线提示);窗口盒无 transition(拖拽逐帧直写);头部 `-webkit-app-region: no-drag`。
|
||||
- **i18n**:`moveToFreeWindow` / `floatDropHint` / `dockToSidebar` 三键,19 语言词典全量同步(zh/en/ja 一致性由 `tests/locales.spec.ts` 守护)。
|
||||
|
||||
## 5. 测试
|
||||
|
||||
- `tests/state.spec.ts`(free windows 块):reducer 语义(移动/清空折叠/钳制/幂等/停靠回退/未知 id 严格 no-op)、查找路径扩展、agent 终端浮窗移除、sanitize(缺省/逐条丢弃/几何钳制/uid 播种/round-trip)。
|
||||
- `tests/service.spec.ts`(free windows 块):dedupe 聚焦浮窗 = 置顶不展开面板、closeTab 关窗 + onClose、activateTab 置顶 + onActivate。
|
||||
- `tests/free-window.spec.tsx`(新,jsdom 全 Sidebar 挂载):拖出检测(提示出现/消失、drop 落位、无武装 drop 不浮动)、右键菜单浮动落点、头部拖动(rAF 直写 + 提交)、SE 缩放、拖到 pane 停靠(高亮断言)、X 关闭、reload 恢复。
|
||||
- `tests/e2e/float-window.e2e.ts`(新,Playwright 真实 `dsh web`):右键浮动 → 拖动移动 → reload 恢复 → 右键停靠回归;HTML5 拖出(提示浮层中拖断言)→ 终端浮窗 + 标签栏移除;全程无 crash 标记。
|
||||
|
||||
## 6. 已知取舍
|
||||
|
||||
- 浮窗头部同时承担「移动」与「拖回停靠」——统一为一种 pointer 手势(落点命中 pane 即停靠,否则移动),未用 HTML5 DnD(浏览器接管后 pointer 流中断,与顺滑移动冲突)。
|
||||
- 停靠只支持 center 合并:从浮窗拖回不提供 edge split(KISS;pane 内拖拽仍支持全部分区操作)。
|
||||
- 浮窗内 tab 被 `openTab` 再次打开时聚焦的是**浮窗**(置顶),不会把 tab 拉回 pane——窗口就是 tab 的「pane」。
|
||||
- 窄视口下拖出手势禁用,但右键菜单入口保留(窗口钳入小视口仍可用)。
|
||||
|
||||
## 7. 实施偏差记录
|
||||
|
||||
- **portal 事件劫持拖拽(e2e 发现,jsdom 不复现)**:浮窗头部内嵌 `Menu`(portal 渲染到 `document.body`),但其 React 合成事件仍沿 React 树冒泡进头部的 `onPointerDown`——右键菜单行的**左键点击**会被误判为头部拖拽:`preventDefault` 吞掉行的兼容 mousedown,`setPointerCapture` 把后续 pointer 事件重定向到头部,行的 click 永不触发(「回到侧边栏」与 X 关闭在真实浏览器中双双失效)。修复:头部/缩放把手的 `onPointerDown` 加**同源守卫**——`event.currentTarget.contains(event.target)` 为假(portal 后代)或目标在按钮内(X)时直接返回。回归测试:`tests/free-window.spec.tsx`「a pointerdown on the portaled header menu must not start a header drag」。教训:任何承载 pointer 拖拽的表面,若其 React 子树里有 portal 覆盖层(菜单/弹窗),必须做 DOM 包含守卫——jsdom 合成事件不走这条路径,只有真实浏览器 e2e 能抓到。
|
||||
- **e2e tarball 选择修复**(`scripts/e2e-mount.sh`):缺省 tarball 解析原为 `ls | head -1`(字典序),仓库根残留旧版本号 tarball 时会把冒烟挂到**过期产物**上(本次 0.15.0 劫持了 0.16.0 的 lane,菜单项整体消失)。改为 `ls -t` 取 mtime 最新并在多候选时告警。
|
||||
- e2e 细节修正:conversation 列须取 `[data-slot="conversation"]` 的**父列**(slot 包裹器零尺寸,`boundingBox()` 返回 null);reload 后需重跑 DSH 首启 takeover 清场(takeover 在 settings join 后才挂载,须先有界等待再清);提示浮层断言按 2 计(`[class*="floatDropHint"]` 子串命中浮层与其文案 label 两个节点);拖拽后 reload 前留 500ms 让 store 的 200ms 防抖持久化落盘。
|
||||
- 其余与本文一致。
|
||||
@@ -0,0 +1,52 @@
|
||||
# Markdown 预览内嵌 HTML 渲染 + 目录大纲(TOC)设计
|
||||
|
||||
> 日期:2026-08-24
|
||||
> 状态:已实现
|
||||
> 范围:`dsh-better-sidebar` 插件,Markdown 文件预览(`TextEditor.tsx` preview 模式);Sidechat 与其他渲染面**零改动**
|
||||
> 基线:复用 mermaid 预览(PR #164)确立的「检测切分 + 单次 MarkdownText 解析 + commit 后 DOM 手术」架构与安全姿势
|
||||
|
||||
## 1. 背景与问题
|
||||
|
||||
DSH 宿主的 `MarkdownText` 出于聊天安全把 raw HTML 按字面文本渲染(协议白名单、相对链接禁用、图片仅绝对 http(s))。这对聊天是对的,但对**文件预览**是灾难:GitHub 风格的 README(本仓库自己的 README 即典型)大量使用内嵌 HTML——`<div align="center">` 徽章墙、`<details>` 折叠块内嵌 markdown、表格单元格里的 `<br/>`/`<sub>`/`<img>`、`<video>`/`<picture>`——预览时全部显示为源码文本。
|
||||
|
||||
用户需求(确认记录):覆盖 Markdown 文件预览;块级 + 内联 HTML 都做;安全模型选 DOMPurify 白名单内联渲染(非沙箱 iframe);TOC 选浮动按钮 + 弹出面板;Sidechat 暂不动;**不更换渲染器**(保住 MarkdownText 的 shiki 高亮 + KaTeX 数学 + GFM 表格)。
|
||||
|
||||
## 2. 方案
|
||||
|
||||
### 2.1 检测与切分:`src/client/markdown-html.ts`(纯函数)
|
||||
|
||||
- `splitHtmlBlocks(text)`:行扫描器把文档切成 `markdown | html` 段。HTML 段 = 围栏外的**块级标签行**(CommonMark type-6 名单 + `<summary>`,开或闭标签)开头、延伸到空行的连续行;`<!-- -->` 注释段在含 `-->` 的行结束。**围栏识别复用 `mermaid-blocks.ts` 导出的 fence 助手**——```html 围栏内的 `<div>` 示例不会被误切。段边界的空行是分隔符,被丢弃。
|
||||
- `analyzeHtmlSegment(source)`:段内标签 tokenizer(跳过注释,void/自闭合标签不进栈,错配闭标签按 HTML 解析器的隐式闭合弹出)归约为有序 parts:`html`(平衡的原始片段,渲染为消毒叶子)/ `open`(未闭合开标签 + 原始 attrs,成为包裹后续段的容器)/ `close`(多余闭标签,弹出一层)。
|
||||
- `collectReferenceDefinitions(segments)`:从 markdown 段收集 `[label]: dest` 行。**每个 markdown 段渲染时追加密全文档定义**——跨 HTML 段的 `[text][id]` 语义保真(追加副本在已含定义的段内因 first-match-wins 而惰性)。这是 mermaid 预览「单次解析」约束(引用定义跨 fence 解析,e2e 钉死)在切分渲染下的等价物。
|
||||
- `analyzeMarkdownHtml(text)`:整体入口,含 `hasInlineHtml` 廉价正则门(fence 内误报只多走增强路径,内联 pass 本身跳过代码块,无行为差异)。
|
||||
|
||||
### 2.2 渲染:`src/client/MarkdownHtml.tsx`
|
||||
|
||||
- **安全管线**(三个入口共用):块叶子 / 内联文本 / 包裹开标签 attrs 全部过 DOMPurify(显式 `FORBID_TAGS: script/style/iframe/object/embed/form/input/button/select/textarea/meta/link/base/frame/frameset/applet` + `FORBID_ATTR: srcdoc/formaction`,叠加在默认白名单之上);消毒后 DOM 遍历强制 `<a>` → `target=_blank rel=noopener noreferrer`;本地 `img/video/audio/source` 的 `src` 经 `resolveLocalMediaDest`(从 `markdown-images.ts` 抽出的共享助手)重写为 `/sidebar/file` 会话媒体 URL——与 markdown 图片重写同一信任围栏。包裹标签的 attrs 走「迷你文档消毒再读回」;`class/for` 映射 React 名,`style` 丢弃(React 需对象,不值得引入 CSS 解析器);整标签被禁时容器透明(子内容照常渲染)。
|
||||
- **`MarkdownDocument` 段渲染状态机**:markdown 段 → `MarkdownSegment`;平衡 html 段 → `dangerouslySetInnerHTML` 叶子(`[data-dsh-html-segment]`);`open`/`close` 部件驱动一个**跨段持久的帧栈**——`<details>` 开段把后续段降为自己的子元素,`</details>` 段弹栈(GitHub 线性 HTML 输出的同款嵌套)。顶层多余闭标签渲染为无(消毒器/解析器本就会丢弃);文档结束仍开着的帧按浏览器解析器语义收口。消毒在 `useMemo` 内按段缓存。
|
||||
- **`MarkdownSegment` 内联替换**:段内含 mermaid fence 走现有 `LazyMermaidMarkdown`(chunk 逻辑不变),否则 `MarkdownText`;`useLayoutEffect` + **MutationObserver**(微任务去抖、幂等、跳过 `code/pre/.md-code-block/[data-mermaid-processed]/[data-html-inline]`)把含标签样式文本的文本节点整体消毒后包进 `<span data-html-inline>` 替换——纯散文(`a < b`)消毒后无元素子节点则原样保留;observer 覆盖懒 chunk 迟挂载、shiki 异步高亮等迟到内容。与 mermaid 换装同款「React 持有宿主树、只动叶子」姿势。
|
||||
- **接线(`TextEditor.tsx`)**:`analyzeMarkdownHtml` 发现块级或内联 HTML 才走 `MarkdownDocument`;**纯 markdown 文档走原路径原样**(无 HTML → 单次 `MarkdownText`/mermaid chunk,现有 e2e「单次解析」守卫继续成立——种子 md 文件不含 HTML,断言一字未改)。
|
||||
|
||||
### 2.3 TOC:`src/client/md-toc.tsx`
|
||||
|
||||
- 零高度 sticky 条(`position: sticky; top: 0; height: 0; pointer-events: none`,不占布局、滚动常驻)+ 右上角按钮 `[data-dsh-md-toc]`(≥3 个标题才出现)+ 弹出面板 `[data-dsh-md-toc-panel]`(按层级缩进、独立滚动、Esc 关闭)。
|
||||
- 标题从**渲染后的 DOM** 收集(h1–h6,含 HTML 段内标题),MutationObserver 重扫(签名比对防重渲染循环)。
|
||||
- 跳转:展开 `closest('details:not([open])')` → `scrollIntoView({behavior:'smooth'})` → CSS 动画高亮 1.2s。
|
||||
- **⚠️ 关键实现陷阱(本次踩坑)**:MdToc **不能**通过传入父容器 ref 定位——React 的 layout 阶段子先于父,子组件的 `useLayoutEffect` 执行时父 div 的 ref **尚未挂上**(实测 `containerRef.current === null`,且依赖数组恒定导致 effect 永不重跑,按钮永不出现;jsdom 单测传预填 ref 对象会掩盖此 bug,只有真实挂载 e2e 能抓到)。修复:MdToc 用**自身 bar div 的 ref**(自身 effect 前必然已挂)取 `parentElement` 作为容器——挂载契约「渲染为目标滚动容器的直接子元素」写入组件文档;bar 常驻渲染(零高度无副作用)以保证 ref 恒在。
|
||||
|
||||
### 2.4 依赖与产物
|
||||
|
||||
- `dompurify ^3.4`(已是 mermaid 的传递依赖,提升为直接依赖后 pnpm 去重单版本,零额外体积)随 editor chunk 内联,未新增 chunk。
|
||||
- 市场清单守卫合规(dependencies 无 `cordis`、无 install 脚本)。
|
||||
|
||||
## 3. 测试
|
||||
|
||||
- `tests/markdown-html.spec.ts`:切分器(21 例:块级标签成段/空行终止/注释段/闭标签段/围栏免疫/CommonMark 恢复/README details 三段形)+ tokenizer 平衡分析(9 例)+ 引用定义收集 + 内联门。
|
||||
- `tests/markdown-html-render.spec.tsx`:真实 `MarkdownText` 渲染——消毒叶子(script 剥除、a 加固、本地 src 重写)、details 跨段嵌套(fence 在 details 元素内、后续段是兄弟)、跨段引用定义解析、禁用标签容器透明、内联替换(表格单元格 `<br/>` 生效、代码块与 `a < b` 不动、内联本地 img 重写)。
|
||||
- `tests/md-toc.spec.tsx`:阈值门、标题收集、跳转(mock `scrollIntoView`、折叠祖先展开、高亮清除)、observer 迟到标题、Esc 关闭。
|
||||
- `tests/e2e/mount.e2e.ts`:新增 `readme-style.md` 种子(徽章 div 含 script、details 包 fence+标题、表格内联 `<br/>`、跨段引用定义)+ 探针(徽章 img 真实元素、无 script、details 嵌套、内联 br、引用解析、TOC 按钮→面板→跳转展开)。**现有 mermaid 探针与断言一字不动**(纯 markdown 文档零回归证明)。
|
||||
|
||||
## 4. 已知取舍(明确不做)
|
||||
|
||||
- GFM 脚注跨段不保(宿主本就把脚注渲染为纯文本);`srcset` 不重写(实际只有远程 URL);HTML 实体不解码;标题锚点 permalink、TOC 滚动联动高亮不做;Sidechat 不动;不换渲染器。
|
||||
- CommonMark HTML 块类型 1(script/pre/style/textarea 按闭标签终止)与类型 7(单行完整标签)不做专门处理——块级名单 + 空行终止已覆盖 README 级真实用法。
|
||||
@@ -0,0 +1,173 @@
|
||||
# 固定终端(Pinned Terminals)设计
|
||||
|
||||
> 2026-08-26 · 状态:设计已评审,待实施
|
||||
> 需求:现所有 Tab 跟随会话,切换会话后终端仍留在原会话(不可见但 PTY 保活)。
|
||||
> 希望右键终端 Tab 可「固定」→ 二级菜单「固定到工作区 / 固定到全局」,固定后切换会话不消失。
|
||||
|
||||
## 0. 决策记录(已确认)
|
||||
|
||||
| 决策点 | 结论 |
|
||||
|---|---|
|
||||
| 可 pin 的终端类型 | **UI 终端 + Agent 终端均可**(Agent 终端被 reconcile 移除时保留为「已断开」tab) |
|
||||
| 跨会话呈现形态 | **独立「固定区」列表**(PinnedRail,面板顶部紧凑条),不投影进目标会话 tab 条 |
|
||||
| Menu 二级菜单 | `ui-primitives` 的 `Menu` **原生支持 `submenu`**(`MenuItem.submenu`,hover/focus 展开右侧子卡),零改动可用 |
|
||||
|
||||
## 1. 现状关键事实(实施依据)
|
||||
|
||||
- **状态模型**(`src/client/state.ts`):`SidebarStore.bySession: Map<sessionId, SidebarState>`;`setSession()` 整体换快照。`SidebarTab = { id, type, title, path?, diff?, meta? }`。终端 tab:`type: 'terminal'`。
|
||||
- **终端两类**(id 前缀判别,无需新字段):
|
||||
- `terminal:<uuid>` —— UI 终端(用户 + 菜单开,`createTab` 铸造)
|
||||
- `agent:<uuid>` —— Agent 终端(模型 `terminal_create` 工具,`reconcileAgentTerminals()` 跟宿主 WS 列表同步;`isAgentTabId()` / `agentUuidOf()` / `agentTabId()` 已存在)
|
||||
- **PTY 保活**(`src/client/TerminalView.tsx` 卸载三分支):tab 关闭 → `{type:'close'}` 杀 PTY;**切会话 → `{type:'park'}` 宿主机无限保活**;同会话重挂载 → bare drop(30s 重连宽限)。Agent 终端 bare drop 已无限保活,无需 park。
|
||||
- **关闭路径**(`src/client/Sidebar.tsx` `actions.closeTab`):service `closeTab` + 终端额外 `api.ptyClose({sessionId,cwd},tabId)` / `api.agentPtyClose(uuid)`。
|
||||
- **持久化**:`localStorage` `dsh-sidebar:v1:<sessionId>`,`sanitizeState` 白名单校验;`sanitizePersistedTab` 逐 tab 校验(未知 type → OrphanedTab 占位)。
|
||||
- **TabBar 右键菜单**(`src/client/TabBar.tsx`):flat `items`,`onSelect(id)` 分发。Menu `MenuEntry = MenuItem | MenuSeparator | MenuLabel`。
|
||||
- **宿主会话切换**:Sidebar 只读 `ctx.sessions.list` 的 `current`(`store.setSession(current)` 跟随);「跳转到子代理会话」先例 = `subagentJumpRef` 记录目标 + 宿主列表侧点击导航,effect 在 `sessionId === pending` 时执行后续动作(PinnedRail 点击沿用此模式)。
|
||||
- **cwd 来源**:`sessionList.byId[sessionId]?.cwd`(`Sidebar.tsx:303` 先例)。
|
||||
|
||||
## 2. 数据模型
|
||||
|
||||
```ts
|
||||
// state.ts — SidebarTab 新增可选字段
|
||||
interface SidebarTab {
|
||||
// ...现有字段
|
||||
/** 固定标记(v0.x+)。scope=工作区|全局;homeCwd=pin 时刻会话 cwd(workspace 可见性判据)。 */
|
||||
pin?: { scope: 'workspace' | 'global'; homeCwd?: string }
|
||||
}
|
||||
```
|
||||
|
||||
- `sanitizePersistedTab` 白名单校验 `pin`(`scope` 枚举值校验,`homeCwd` string 可选;非法值降级为无 pin,不丢 tab)。缺字段 = 未 pin,旧持久化状态向后兼容。
|
||||
- pin 元数据**随宿主会话 state 持久化**,无独立 localStorage 注册表(YAGNI)。
|
||||
- **权威副本唯一**:pinned tab 只存在于宿主会话(home session)的 state 里,其他会话仅跨会话渲染——现有 park/保活语义零改动。
|
||||
|
||||
## 3. 跨会话解析层(新模块 `src/client/pinned.ts`,纯函数)
|
||||
|
||||
```ts
|
||||
/** 该 pinned tab 对 viewer 会话是否可见。cwd 未知(undefined)时保守可见,避免水合期闪烁消失。 */
|
||||
export function pinnedVisibleTo(
|
||||
tab: SidebarTab,
|
||||
viewer: { sessionId: string; cwd: string | undefined },
|
||||
): boolean
|
||||
|
||||
/** 从所有缓存会话的 state 收集对 viewer 可见的 pinned 终端 tab。
|
||||
* 返回 { tab, homeSessionId }[],按 pin 先后稳定排序;排除 viewer 自己会话的
|
||||
* (那些本就在 tab 条上,避免双渲染)。 */
|
||||
export function collectPinnedTabs(
|
||||
bySession: ReadonlyMap<string, SidebarState>,
|
||||
viewer: { sessionId: string; cwd: string | undefined },
|
||||
): { tab: SidebarTab; homeSessionId: string }[]
|
||||
```
|
||||
|
||||
可见性规则:
|
||||
|
||||
| pin.scope | 可见条件 |
|
||||
|---|---|
|
||||
| `global` | 任意会话(不同 cwd 也可见) |
|
||||
| `workspace` | `viewer.cwd === tab.pin.homeCwd`(两者皆 undefined 视为匹配;仅 viewer.cwd 未知时保守可见) |
|
||||
|
||||
`SidebarStore` 新增只读方法:
|
||||
|
||||
```ts
|
||||
/** 当前所有会话 state 的只读视图(供 collectPinnedTabs)。 */
|
||||
getSessionStates(): ReadonlyMap<string, SidebarState>
|
||||
```
|
||||
|
||||
渲染期解析,不写跨会话投影——每会话 state 保持纯净,reduce/reduceFor 语义不变。store 任意会话更新已全局 notify,rail 随 uSES 自动重渲染。
|
||||
|
||||
## 4. UI:PinnedRail(新组件 `src/client/PinnedRail.tsx`)
|
||||
|
||||
- **挂载**:`Sidebar.tsx` 内、右面板 tab 区之上(右/底面板共享一条,pinned tab 不归属任何树)。仅当可见 pinned ≥ 1 时渲染。稳定寻址面 `[data-dsh-pinned-rail]`、`[data-dsh-pinned-tab="<tabId>"]`。样式走 CSS Modules + `--dsw-alias-*` 令牌(§8 皮肤契约),hairline 分隔。
|
||||
- **条目**:pin 图标(📌 风格 SVG,icons.tsx 新增 `IconPinOutline16`)+ 终端类型图标(Agent 终端用机器人/思考图标,UI 终端用终端图标)+ 标题 + tooltip(`类型 + 范围 + 来源 cwd`,如「Agent 终端 · 固定到工作区 · D:\proj\x」)。
|
||||
- **交互**:
|
||||
- **点击**:当前会话含该 tab id → `activateTab`;否则记录 `pinnedJumpRef`(沿用 subagentJumpRef 模式)→ 触发宿主导航到 home 会话 → effect 命中后 `reduceFor` 激活 tab(面板折叠则展开)。
|
||||
- **右键菜单**(复用 Menu portal):`取消固定` / `关闭终端`。
|
||||
- **中键**:关闭(同右键关闭)。
|
||||
- **关闭语义**:走 home 会话路径——`reduceFor(homeSessionId, closeTab)` + `api.ptyClose({sessionId: homeSessionId, cwd: homeCwd}, tabId)` / `api.agentPtyClose(uuid)`。正处 home 会话时退化为现有 closeTab 路径。关闭同时清 pin(tab 消失即 pin 消失,无需单独解 pin 步骤)。
|
||||
|
||||
## 5. TabBar 右键菜单扩展(`src/client/TabBar.tsx`)
|
||||
|
||||
终端类型 tab(`tab.type === 'terminal'`,含 UI 与 Agent)菜单项变为:
|
||||
|
||||
```
|
||||
移动到自由窗口
|
||||
固定 ▸ ← 未 pin;MenuItem.submenu
|
||||
├ 固定到工作区
|
||||
└ 固定到全局
|
||||
取消固定 ← 已 pin(替换「固定 ▸」行)
|
||||
─────────────
|
||||
关闭 / 关闭其他页签 / 关闭左侧 / 关闭右侧
|
||||
```
|
||||
|
||||
- 菜单 label 按终端类型区分:`固定终端`(UI)/ `固定 Agent 终端`(Agent)。
|
||||
- TabBar 新增 props:`onPinTab(tabId: string, scope: 'workspace' | 'global' | null): void`(null = 取消)。`isPinned` 从 tab 对象自身读(`tab.pin !== undefined`),无需新 prop。
|
||||
- pin 实现(Sidebar 层):`store.reduce(s => patchTab(s, tabId, { meta: ... }))` 不够——`patchTab` 只 patch title/path/meta。**新增 reducer `setTabPin(state, tabId, pin | null)`**(纯函数,walk 两树 + floats,与 patchTab 同构),pin 时从当前会话 cwd 快照 homeCwd。
|
||||
|
||||
## 6. 生命周期与安全
|
||||
|
||||
| 场景 | 行为 |
|
||||
|---|---|
|
||||
| 关闭 pinned tab(tab 条 X 或 rail 关闭) | 清 pin + 正常关闭 + 杀 PTY(home 路径) |
|
||||
| Agent 终端被 reconcile 移除(宿主 uuid 消失) | `reconcileAgentTerminals` **豁免 pinned tab**:保留 tab,标题渲染时追加 `(已断开)`(不改持久化 title,reconcile uuid 匹配不受影响)。xterm 重连失败走现有 fatal banner |
|
||||
| UI 终端 PTY 死亡 | 现有行为(xterm 显示退出输出);pinned 不改变重连/重开语义 |
|
||||
| 取消固定 | 仅清 `tab.pin`;tab 留在 home 会话 tab 条,切走恢复旧行为 |
|
||||
| 刷新/重载 | pin 随会话 state 持久化恢复;rail 从各会话缓存 state 重建 |
|
||||
| 底部面板/窄屏迁移 | pinned tab 若在 bottomSplits,`migrateBottomTabs` 照常迁移(pin 语义不受影响) |
|
||||
| 自由窗口中的 pinned tab | 允许;rail 点击切回 home 会话后 `raiseFloat`(floatWithTab 命中走置顶,openTabInActivePane 已有此分支) |
|
||||
|
||||
## 12. 实施偏差记录
|
||||
|
||||
### M3:断开信号收敛
|
||||
|
||||
设计 §6 原表「Agent 终端被 reconcile 移除」行提到「标题渲染时追加 `(已断开)`」。实施时收敛为:**M3 只做豁免,断开的用户可见信号由 xterm 现有重连失败 banner 承担**。原因:rail/tab 标题渲染处无法知道宿主列表(`reconcileAgentTerminals` 的 uuid 匹配是 state 级的,渲染层不持有该信息),改 meta 会写持久化(违反「reconcile uuid 匹配不受影响」)。xterm 的 WS 连接 1011/超时已有 fatal UI,足以承担断开信号。设计 §6 表相应行实施时修正为「banner 承担断开信号」。
|
||||
|
||||
### M4:内联虚拟 Tab(取代 PinnedRail)
|
||||
|
||||
设计 §3 原描述「PinnedRail 组件——面板顶部的紧凑条,渲染其他会话的 pinned tab」。实施时改为**内联虚拟 Tab**:pinned tab 作为虚拟 `SidebarTab` 注入到当前会话 split tree 的第一个 leaf 的 `tabs` 数组尾部,与普通 tab 并排在 TabBar 中渲染。
|
||||
|
||||
**变更原因**:用户反馈「固定的终端是永远显示在 Tabs 栏处,而不是单独有一个地方让我点击后回到那个会话」。PinnedRail 是面板顶部的独立条,点击跳回宿主会话——这两个设计点都被否定。
|
||||
|
||||
**新设计**:
|
||||
- **虚拟 Tab 注入**:`injectPinnedIntoTree(state.splits, pinnedVirtualTabs, activePinnedTabId)` 将虚拟 tab 追加到第一个 leaf,并在 `activePinnedTabId` 设置时覆盖 leaf 的 `active`
|
||||
- **就地激活**:点击虚拟 tab 设 `activePinnedTabId`(本地 state),TerminalView 用 home session 的 scope(sessionId + cwd)连接宿主 PTY 的 WS(`/sidebar/ws/terminal?sessionId=<home>&tab=<originalId>`),不跳转会话
|
||||
- **effectiveTabId**:`TabContent` 新增 `effectiveTabId` prop,将虚拟 tab 的原始 id 传给 descriptor component(TerminalView 的 `tabId` 参数),虚拟 id 仅作 React key
|
||||
- **不可拖拽**:TabBar 检测 `isPinnedVirtualTab(tab)` → `draggable={false}`,跳过 drop handler
|
||||
- **右键菜单精简**:pinned 虚拟 tab 右键只有 Unpin / Close(无 float / close-others / close-left / close-right)
|
||||
- **reduceFor 不 notify**:`pinnedRevision` state bump 替代 `railRevision`,force `pinnedEntries` useMemo 重算
|
||||
|
||||
## 7. 测试计划(Unit + 组件,vitest)
|
||||
|
||||
| 文件 | 覆盖 |
|
||||
|---|---|
|
||||
| `tests/pinned.spec.ts`(新) | `pinnedVisibleTo` 全组合(global/workspace × cwd 匹配/不匹配/双方 undefined/单方 undefined);`collectPinnedTabs` 跨会话收集、排除 viewer 自身会话、稳定排序、home 会话 id 携带 |
|
||||
| `tests/state.spec.ts`(追加) | `sanitizePersistedTab` 接受合法 pin / 拒绝非法 scope 降级 / 缺字段兼容;`setTabPin` reducer(设置/取消/未知 tabId no-op/浮动窗口 tab);`reconcileAgentTerminals` 豁免 pinned agent tab(uuid 消失仍保留)+ 未 pin 的照旧移除 |
|
||||
| `tests/tab-bar-context-menu.spec.tsx`(追加) | 终端 tab 出现「固定 ▸」子菜单(UI 与 Agent 文案区分);非终端 tab 不出现;已 pin 显示「取消固定」;onPinTab 回调参数正确 |
|
||||
| `tests/pinned-rail.spec.tsx`(新) | rail 渲染条件(0 个不渲染);类型图标与 tooltip;点击切回 home 会话(reduceFor 激活);右键取消固定/关闭;「已断开」后缀渲染 |
|
||||
|
||||
回归:`pnpm typecheck && pnpm test`;终端相关改动加跑 `tests/agent-terminal-reconcile.spec.ts`、`tests/terminal-*.spec.ts`、`tests/bottom-auto-terminal.spec.tsx`。
|
||||
|
||||
## 8. i18n(19 语言词典同步,遵循 locales-*.ts 模式)
|
||||
|
||||
新增 key:`pinTerminal` / `pinAgentTerminal` / `pinToWorkspace` / `pinToGlobal` / `unpinTerminal` / `pinnedTerminalTooltip`(类型/范围/cwd 插值)/ `terminalDisconnected`。
|
||||
zh-CN 基准 + en;ja 必须同步(better-locale 契约:缺 ja key 回退 en)。其余语言沿用现有词典文件的同步方式。
|
||||
|
||||
## 9. 文档同步
|
||||
|
||||
- `README.md` / `README_EN.md`:功能清单加「固定终端」
|
||||
- `AGENTS.md` §3.4 内置 tab 表:terminal 行补充 pin 能力说明;§7 陷阱表视情况加「pinned tab 跨会话解析在渲染期,勿在 reducer 里投影」
|
||||
- 实施偏差记录在本文档追加
|
||||
|
||||
## 10. YAGNI(明确不做)
|
||||
|
||||
- ❌ 独立 localStorage pin 注册表
|
||||
- ❌ pinned tab 投影进目标会话 tab 条(独立固定区已选定)
|
||||
- ❌ pin 非终端 tab
|
||||
- ❌ 宿主机(host 半)改动——PTY 保活现有逻辑完备(park + agent 无限保活)
|
||||
- ❌ pin 数量上限(终端本身有 `TERMINAL_LIMIT` 配额兜底)
|
||||
|
||||
## 11. 风险与缓解
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|---|---|
|
||||
| rail 点击需触发**宿主**会话切换(非 sidebar store) | 沿用 subagentJumpRef 模式;PinnedRail 点击项渲染为宿主会话列表可识别的导航(或经 `ctx.sessions` 可用 API),effect 在 sessionId 命中后激活 tab。实施时先验证该链路(PoC 先行) |
|
||||
| 「已断开」标题与后续 reconcile push 兼容性 | 后缀在**渲染期**追加,不改持久化 title;reconcile 按 uuid 匹配不受影响 |
|
||||
| bySession 未缓存的会话(从未加载)其 pinned tab 不可见 | 接受:会话 state 在首次切换时 loadState 进缓存;rail 只覆盖本次运行内访问过的会话 + 当前会话。如需全覆盖需扫 localStorage 全量 key(复杂且慢),YAGNI |
|
||||
@@ -0,0 +1,108 @@
|
||||
# 固定终端(Pinned Terminals)实施计划
|
||||
|
||||
> 2026-08-26 · 设计文档:[2026-08-26-pinned-terminal-design.md](2026-08-26-pinned-terminal-design.md)
|
||||
> 仓库:dsh-better-sidebar · 分支策略:`feat/pinned-terminal`(仓库硬约束:代码改动必须走 PR)
|
||||
|
||||
## 里程碑总览
|
||||
|
||||
| # | 里程碑 | 产出 | 验证 |
|
||||
|---|---|---|---|
|
||||
| M1 | 数据模型 + 解析层 | `SidebarTab.pin`、`setTabPin` reducer、`sanitizePersistedTab` 校验、`src/client/pinned.ts`、`SidebarStore.getSessionStates()` | `tests/state.spec.ts` + `tests/pinned.spec.ts` 绿 |
|
||||
| M2 | TabBar 右键菜单 | 「固定 ▸」二级菜单(UI/Agent 文案区分)、「取消固定」、`onPinTab` prop 接线 | `tests/tab-bar-context-menu.spec.tsx` 绿 |
|
||||
| M3 | reconcile 豁免 + 断开标注 | pinned agent tab 豁免移除 + 渲染期「已断开」后缀 | `tests/agent-terminal-reconcile.spec.ts` 追加用例绿 |
|
||||
| M4 | PinnedRail 组件 | rail 渲染、类型图标、tooltip、点击切回 home 会话、右键取消固定/关闭 | `tests/pinned-rail.spec.tsx` 绿 |
|
||||
| M5 | i18n + 文档 + 收尾 | 19 语言 key、README/AGENTS.md、全量回归 | `pnpm typecheck && pnpm test` 全绿 |
|
||||
|
||||
## M1:数据模型 + 解析层
|
||||
|
||||
**改动文件**:
|
||||
- `src/client/state.ts`:`SidebarTab.pin?: { scope: 'workspace' | 'global'; homeCwd?: string }`;`sanitizePersistedTab` 白名单校验 pin(非法 scope → 降级无 pin,不丢 tab);新 reducer `setTabPin(state, tabId, pin | null)`(walk splits + bottomSplits + floats,同构 `patchTab`;未知 tabId 严格同引用 no-op)
|
||||
- `src/client/pinned.ts`(新):`pinnedVisibleTo(tab, viewer)` + `collectPinnedTabs(bySession, viewer)`(排除 viewer 自身会话、按收集顺序稳定)
|
||||
- `src/client/state.ts` `SidebarStore`:新增 `getSessionStates(): ReadonlyMap<string, SidebarState>`(暴露 `bySession` 只读视图)
|
||||
|
||||
**测试**:
|
||||
- `tests/state.spec.ts`:pin 字段 sanitize(合法/非法 scope/缺字段)、`setTabPin`(设置/取消/未知 id/浮动窗口 tab/同引用 no-op)
|
||||
- `tests/pinned.spec.ts`(新):`pinnedVisibleTo` 矩阵(global × 任意 cwd;workspace × cwd 匹配/不匹配/双方 undefined/仅 viewer undefined);`collectPinnedTabs`(多会话收集、排除自身、空 map)
|
||||
|
||||
**提交**:`feat: pin data model + cross-session resolver`
|
||||
|
||||
## M2:TabBar 右键菜单
|
||||
|
||||
**改动文件**:
|
||||
- `src/client/TabBar.tsx`:新增 prop `onPinTab?: (tabId: string, scope: 'workspace' | 'global' | null) => void`;终端 tab(`tab.type === 'terminal'`)菜单插入 pin 行——未 pin 用 `MenuItem.submenu`(`固定到工作区` / `固定到全局`),已 pin 用单行「取消固定」;label 经 t() 且按 `isAgentTabId` 区分文案
|
||||
- `src/client/Sidebar.tsx`:实现 `onPinTab` —— `store.reduce(s => setTabPin(s, tabId, scope === null ? null : { scope, homeCwd: cwd }))`(cwd 取当前会话 summaryCwd);传入 WorkbenchActions → 各 TabBar 实例
|
||||
- 检查 `FreeWindow.tsx` 头部右键菜单是否也需 pin 项(与 TabBar 保持一致;若 FreeWindow 菜单独立实现则同步加)
|
||||
|
||||
**测试**(`tests/tab-bar-context-menu.spec.tsx` 追加):
|
||||
- 终端 tab 菜单含「固定 ▸」且 hover 展开两项;点击回调 `(tabId, 'workspace' | 'global')`
|
||||
- 已 pin tab 显示「取消固定」,回调 `(tabId, null)`
|
||||
- 非终端 tab 无 pin 行;Agent 终端 label 为「固定 Agent 终端」
|
||||
|
||||
**提交**:`feat: tab context menu pin submenu`
|
||||
|
||||
## M3:reconcile 豁免 + 断开标注
|
||||
|
||||
**改动文件**:
|
||||
- `src/client/state.ts` `reconcileAgentTerminals`:`toRemove` 过滤掉 `tab.pin !== undefined` 的 tab(豁免移除)
|
||||
- 渲染期「已断开」:pinned agent tab 的标题后缀在**读取侧**追加——`TabBar`/rail 渲染处无法知道宿主列表,故在 `reconcileAgentTerminals` 豁免时给 tab meta 打标?**否**(改 meta 会写持久化)。方案:rail/tab 标题渲染保持原 title;「已断开」状态由 TerminalView 现有重连失败 banner 承担(WS 连接 1011/超时已有 fatal UI)。**收敛**:M3 只做豁免,断开的用户可见信号 = xterm banner,不加标题后缀(避免渲染期需要宿主列表的耦合)。设计文档 §6 表相应行实施时修正为「banner 承担断开信号」。
|
||||
|
||||
**测试**(`tests/agent-terminal-reconcile.spec.ts` 追加):
|
||||
- pinned agent tab 在 uuid 消失后保留;未 pin 的照旧移除;豁免不影响 toAdd 路径
|
||||
|
||||
**提交**:`feat: reconcile exempts pinned agent terminals`
|
||||
|
||||
## M4:PinnedRail 组件
|
||||
|
||||
**改动文件**:
|
||||
- `src/client/PinnedRail.tsx`(新):props `{ entries: { tab, homeSessionId }[], currentSessionId, onFocus(homeSessionId, tabId), onUnpin(tabId), onClose(homeSessionId, tabId) }`;`[data-dsh-pinned-rail]` 锚点;pin 图标 + 类型图标 + tooltip;右键 Menu(portal);中键关闭(沿用 TabBar middlePressed 模式)
|
||||
- `src/client/PinnedRail.module.css`(新):紧凑条样式,`--dsw-alias-*` 令牌,hairline 分隔,面板折叠时隐藏
|
||||
- `src/client/Sidebar.tsx`:
|
||||
- 渲染期 `collectPinnedTabs(store.getSessionStates(), { sessionId, cwd: summaryCwd })`(useMemo on snapshot + sessionList)
|
||||
- `pinnedJumpRef`(沿用 subagentJumpRef 模式)+ effect:`sessionId === pending` 时 `reduceFor` 激活 tab(`activateTab` 命中 float 则 raiseFloat)+ 面板折叠则 `togglePanel`
|
||||
- onClose:home 路径 `reduceFor(home, s => closeTab(...))` + `api.ptyClose({ sessionId: home, cwd: homeCwd }, tabId)` / `api.agentPtyClose(uuid)`;当前会话退化为现有 `actions.closeTab`
|
||||
- rail 挂载点:右面板内、第一个 TabBar 之上;窄视口(合并抽屉)同样渲染
|
||||
- `src/client/icons.tsx`:`IconPinOutline16`(若图标库无现成 pin)
|
||||
|
||||
**宿主会话切换链路(关键风险,先 PoC)**:rail 点击 → 宿主会话列表导航。调研 `ctx.sessions` 客户端 API 是否有主动切换方法(参考 subagent 跳转实际触发方式);若无公开 API,用 `location.hash` 导航(与宿主路由一致)。PoC 验证后再写组件。
|
||||
|
||||
**测试**(`tests/pinned-rail.spec.tsx` 新):
|
||||
- 0 条目不渲染;≥1 渲染条目(图标/tooltip/标题)
|
||||
- 点击非当前会话条目 → 记录 jump + 导航调用(stub);effect 命中后 reduceFor 激活
|
||||
- 右键取消固定(onUnpin)、关闭(onClose 参数 home 路径);中键关闭
|
||||
- 浮动 pinned tab 点击 → raiseFloat 分支
|
||||
|
||||
**提交**:`feat: pinned rail component + cross-session focus`
|
||||
|
||||
## M5:i18n + 文档 + 收尾
|
||||
|
||||
**i18n**(`src/client/locales.ts` + 19 个 `locales-*.ts`):
|
||||
- 新 key:`pinTerminal` / `pinAgentTerminal` / `pinToWorkspace` / `pinToGlobal` / `unpinTerminal` / `pinnedTerminalTooltip`(插值:类型/范围/cwd)
|
||||
- zh-CN 基准 + en 必写;ja 必同步(better-locale 契约);其余语言沿用既有同步惯例
|
||||
- `tests/locales.spec.ts` 守护同步性
|
||||
|
||||
**文档**:
|
||||
- `README.md` / `README_EN.md` 功能清单加「固定终端」
|
||||
- `AGENTS.md` §3.4 内置 tab 表 terminal 行补 pin 说明
|
||||
- 设计文档追加「实施偏差」节(如 M3 的断开信号收敛)
|
||||
|
||||
**回归门禁**:
|
||||
```powershell
|
||||
pnpm typecheck; pnpm test
|
||||
# 重点回归:agent-terminal-reconcile / terminal-* / bottom-auto-terminal / tab-bar-* / state / prefs
|
||||
```
|
||||
|
||||
**版本**:`package.json` minor bump(新功能);`src/client/service.ts` `features` 数组若对外暴露 pin 能力则加 `'pinnedTerminals'`(消费插件 gate 用)——本功能不开放服务 API,**不加**。
|
||||
|
||||
**提交**:`feat: pinned terminal i18n + docs` → PR `feat/pinned-terminal`
|
||||
|
||||
## 依赖与顺序
|
||||
|
||||
M1 →(M2 ∥ M3)→ M4 → M5。M2/M3 互不依赖可并行;M4 依赖 M1 的 pin 字段与 reducer;M5 最后。
|
||||
|
||||
## 风险登记
|
||||
|
||||
| 风险 | 缓解 | 状态 |
|
||||
|---|---|---|
|
||||
| 宿主会话切换无公开 API | M4 开工先做 PoC(读 subagent 跳转链路 + `ctx.sessions` 面);实在不行用 hash 导航 | 待验证 |
|
||||
| rail 在窄视口/底面板合并态的布局 | 复用现有面板 CSS 变量;`tests/breakpoints*.spec.*` 观察 | 实施期 |
|
||||
| bySession 未缓存会话的 pin 不可见 | 设计已接受(YAGNI 全量扫 localStorage) | 已确认 |
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 200 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.4 KiB |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "dsh-external/dsh-better-sidebar",
|
||||
"version": "0.8.1",
|
||||
"version": "0.17.1",
|
||||
"main": "./lib/index.js",
|
||||
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
|
||||
"engines": {
|
||||
|
||||
+73
-48
@@ -1,9 +1,15 @@
|
||||
{
|
||||
"name": "dsh-better-sidebar",
|
||||
"version": "0.8.1",
|
||||
"version": "0.17.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"
|
||||
}
|
||||
@@ -48,44 +59,55 @@
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/client-registry.js",
|
||||
"lib/client-docx.js",
|
||||
"lib/client-xlsx.js",
|
||||
"lib/client-pptx.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",
|
||||
"build": "node -e \"require('node:fs').rmSync('lib',{recursive:true,force:true})\" && 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-ui-conversation": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-session": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-subagent": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-tools": "^0.1.0-rc.8",
|
||||
"@huanlin/dsh-plugin-better-locale": "^0.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@huanlin/dsh-plugin-better-locale": {
|
||||
"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",
|
||||
@@ -99,6 +121,7 @@
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/lang-rust": "^6.0.2",
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/lang-vue": "^0.1.3",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/lang-yaml": "^6.1.3",
|
||||
"@codemirror/language": "^6.12.4",
|
||||
@@ -107,45 +130,47 @@
|
||||
"@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",
|
||||
"dompurify": "^3.4.14",
|
||||
"mermaid": "^11.16.1",
|
||||
"node-pty": "^1.1.0",
|
||||
"react-icons": "5.7.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",
|
||||
"@huanlin/dsh-plugin-better-locale": "^0.1.0",
|
||||
"@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",
|
||||
"react-dom": "18.2.0",
|
||||
"tsdown": "^0.22.2",
|
||||
"typescript": "^5.6.0",
|
||||
"unrun": "^0.2.39",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
+2741
-4575
File diff suppressed because it is too large
Load Diff
+7
-5
@@ -9,10 +9,11 @@ allowBuilds:
|
||||
protobufjs: true
|
||||
|
||||
minimumReleaseAgeExclude:
|
||||
- '@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'
|
||||
- '@deepseek-ai/cosmokit@1.8.2-rc.1'
|
||||
- '@deepseek-ai/*'
|
||||
- '@deepseek-ai/cordis-plugin-include@1.0.5-rc.1 || 1.0.6'
|
||||
- '@deepseek-ai/cordis-plugin-loader@1.0.1-rc.1 || 1.0.2'
|
||||
- '@deepseek-ai/cordis@4.0.1-rc.1 || 4.0.1'
|
||||
- '@deepseek-ai/cosmokit@1.8.2-rc.1 || 1.8.2'
|
||||
- '@deepseek-ai/dsh-agent-default-model@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-agent-presets@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-agent@0.0.1-rc.1'
|
||||
@@ -64,4 +65,5 @@ minimumReleaseAgeExclude:
|
||||
- '@deepseek-ai/dsh-user-approval@0.0.1-rc.1'
|
||||
- '@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/schemastery@3.18.1-rc.1 || 3.18.1'
|
||||
- '@huanlin/dsh-plugin-better-locale@0.1.0'
|
||||
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
#!/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"
|
||||
# The vendored cordis base (and, through its realpath, cosmokit / standard-schema)
|
||||
# must resolve so the consumer can type `Context` against the DSH runtime scope.
|
||||
mkdir -p "$WORK/node_modules/@deepseek-ai"
|
||||
ln -s "$(pwd)/node_modules/@deepseek-ai/cordis" "$WORK/node_modules/@deepseek-ai/cordis"
|
||||
|
||||
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'
|
||||
// The vendored-cordis path: `Context` from '@deepseek-ai/cordis' plus the
|
||||
// side-effect type import above must expose `ctx.betterSidebar` — the consumer
|
||||
// never needs to import this package's own Context type.
|
||||
import type { Context as VendoredContext } from '@deepseek-ai/cordis'
|
||||
|
||||
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
|
||||
// Vendored-cordis augmentation path.
|
||||
declare const vctx: VendoredContext
|
||||
vctx.betterSidebar.registerTab(tab)
|
||||
vctx.betterSidebar.registerFileViewer(viewer)
|
||||
void vctx.betterSidebar.getSnapshot()
|
||||
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.
|
||||
# Upstream noise (vendored cordis / cosmokit declaration warnings under the
|
||||
# repo's pinned TS lib) lives anywhere under node_modules and is filtered.
|
||||
if grep -E "dsh-better-sidebar|lib/types|check\.ts" "$WORK/strict.log" | grep -vE "node_modules/[^ ]*(cordis|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 || true
|
||||
fi
|
||||
echo "[check-consumer-types] OK: the client/service declaration surface is node-free and self-contained."
|
||||
|
||||
Executable
+176
@@ -0,0 +1,176 @@
|
||||
#!/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)。这里有意只取 origin:0.1.2-alpha.1+
|
||||
# 的就绪行是 `…/?token=<43字符>` 鉴权 URL,但下方的探活全部打插件的
|
||||
# `/sidebar/api/*` 路由——webserver carrier 不做鉴权(只有 /api、index 与
|
||||
# remote.mux 升级在 browser auth 之后),origin 拼路径即正确且两版通用。
|
||||
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
+176
@@ -0,0 +1,176 @@
|
||||
#!/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 解析(多个候选时取 mtime 最新——`ls | head -1` 的字典序会拿到
|
||||
# 旧版本号的历史 tarball,把冒烟挂到过期产物上)
|
||||
if [ -z "$TARBALL" ]; then
|
||||
TARBALL="$(ls -t "$ROOT"/dsh-better-sidebar-*.tgz 2>/dev/null | head -1 || true)"
|
||||
COUNT="$(ls "$ROOT"/dsh-better-sidebar-*.tgz 2>/dev/null | wc -l | tr -d ' ')"
|
||||
[ "$COUNT" -le 1 ] || warn "发现 $COUNT 个 tarball,按 mtime 选用最新:$(basename "$TARBALL")(建议清理其余)"
|
||||
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=$!
|
||||
|
||||
# 就绪行解析:DSH 0.1.2-alpha.1+ 打印的是带一次性 token 的鉴权 URL
|
||||
# (`dsh web: http://127.0.0.1:<port>/?token=<43字符>`,页面导航用它换取
|
||||
# 浏览器 cookie;干净 URL 只会得到 401);0.1.1-rc.x 及更早是裸 origin。
|
||||
# 匹配必须延伸到空白(`[^ ]*`)——在 `/` 处截断会丢掉 token,alpha.1+
|
||||
# 宿主上的整条 lane 都会挂在首屏 401。LAN 后缀 `(LAN: …)` 是下一个
|
||||
# 空格分段,不会被并进来。
|
||||
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,306 @@
|
||||
## =============================================================================
|
||||
# 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 引用。
|
||||
#
|
||||
# 用法(任选其一):
|
||||
# # 默认最新版
|
||||
# $script = (irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1').TrimStart([char]0xFEFF)
|
||||
# & ([scriptblock]::Create($script))
|
||||
# # 指定版本 / 装完重启
|
||||
# $script = (irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1').TrimStart([char]0xFEFF)
|
||||
# & ([scriptblock]::Create($script)) -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 (Get-Command pnpm -ErrorAction SilentlyContinue)) {
|
||||
Die '未找到 pnpm;安装脚本需要 pnpm >= 10。请先安装或升级 pnpm 并加入 PATH。'
|
||||
}
|
||||
$pnpmVersion = (& pnpm --version 2>$null | Select-Object -Last 1)
|
||||
if ($LASTEXITCODE -ne 0 -or -not $pnpmVersion) {
|
||||
Die '无法读取 pnpm 版本;安装脚本需要 pnpm >= 10。'
|
||||
}
|
||||
$pnpmVersionText = ([string]$pnpmVersion).Trim()
|
||||
$pnpmMajor = 0
|
||||
if (-not [int]::TryParse(($pnpmVersionText -split '\.')[0], [ref]$pnpmMajor)) {
|
||||
Die "无法解析 pnpm 版本(输出:$pnpmVersionText);安装脚本需要 pnpm >= 10。"
|
||||
}
|
||||
if ($pnpmMajor -lt 10) {
|
||||
Die "安装脚本需要 pnpm >= 10;当前版本为 $pnpmVersionText。请升级 pnpm 后重试。"
|
||||
}
|
||||
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
|
||||
@@ -31,12 +31,6 @@ const files = [
|
||||
'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-docx.js',
|
||||
'lib/client-docx.js.map',
|
||||
'lib/client-xlsx.js',
|
||||
'lib/client-xlsx.js.map',
|
||||
'lib/client-pptx.js',
|
||||
'lib/client-pptx.js.map',
|
||||
'lib/client-terminal.js',
|
||||
'lib/client-terminal.js.map',
|
||||
'lib/client-editor.js',
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* The model-facing `sidebar_open` tool and its delivery registry.
|
||||
*
|
||||
* One tool lets the model actively open a local file, a local folder (as a
|
||||
* tree rooted there), or an HTTP(S) page in the CALLING session's sidebar.
|
||||
* Mirroring the agent-terminal tools, the tool binds to the calling agent's
|
||||
* session through `exec.agent.session.id` — the model never passes a
|
||||
* sessionId, and opens for non-active sessions are queued until that
|
||||
* session's sidebar view is next connected.
|
||||
*
|
||||
* Delivery is a host→browser push over the dedicated `/sidebar/ws/agent-opens`
|
||||
* endpoint (the same pattern as `/sidebar/ws/agent-terminals`): the registry
|
||||
* keeps a per-session queue; a push is consumed on send (`delivered: true`
|
||||
* means a sidebar view was attached at call time), otherwise the request
|
||||
* stays queued and is replayed when a view for that session attaches.
|
||||
*
|
||||
* Conventions (per plugin-development-guide.md §3):
|
||||
* C1 — parameters schema-validated before `execute` runs.
|
||||
* C4 — `execute` returns one canonical JSON value; `render` is a separate
|
||||
* pure text projection.
|
||||
* C6 — `exec.signal.throwIfAborted()` before any fs work.
|
||||
* C10 — no UI/transport vocabulary in the canonical value.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { isAbsolute, join, resolve } from 'node:path'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolRunContext } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { Context } from './context-types.ts'
|
||||
import type { SidebarPrefs } from './prefs-shared.ts'
|
||||
|
||||
/** What the model asked to open. */
|
||||
export type AgentOpenKind = 'file' | 'folder' | 'url'
|
||||
|
||||
/** One pending/broadcast open request (the wire face over the push socket). */
|
||||
export interface AgentOpenRequest {
|
||||
/** Opaque id (host-generated; the client ignores it beyond dedupe/debug). */
|
||||
id: string
|
||||
/** The session whose sidebar the open is targeted at. */
|
||||
sessionId: string
|
||||
kind: AgentOpenKind
|
||||
/** Absolute local path (file/folder) or http(s) URL. */
|
||||
target: string
|
||||
/** Tab title the client should use (basename / hostname / caller-supplied). */
|
||||
title: string
|
||||
}
|
||||
|
||||
/** One subscribed sidebar view's sender. */
|
||||
type Sender = (request: AgentOpenRequest) => void
|
||||
|
||||
/**
|
||||
* Per-session queue of open requests plus the connected sidebar views.
|
||||
*
|
||||
* Lifecycle: `enqueue` adds a request and — when at least one view for the
|
||||
* session is attached — pushes it immediately and removes it from the queue
|
||||
* (consume-on-send: a reconnect must never replay an open the client already
|
||||
* applied, and the browser tab type has no per-URL dedupe, so replaying
|
||||
* would mint duplicate tabs). With no attached view the request stays queued
|
||||
* and `attach` replays it on connect. `drainAll` drops every queued request
|
||||
* (the feature was turned off); `dispose` also drops every subscriber.
|
||||
*/
|
||||
export class AgentOpenRegistry {
|
||||
private pending = new Map<string, AgentOpenRequest[]>()
|
||||
private subscribers = new Map<string, Set<Sender>>()
|
||||
|
||||
/** Queue one open and deliver it immediately when a view is attached.
|
||||
* @returns the request id and whether a connected view received it now. */
|
||||
enqueue(sessionId: string, kind: AgentOpenKind, target: string, title: string): { id: string; delivered: boolean } {
|
||||
const request: AgentOpenRequest = { id: randomUUID(), sessionId, kind, target, title }
|
||||
const list = this.pending.get(sessionId) ?? []
|
||||
list.push(request)
|
||||
this.pending.set(sessionId, list)
|
||||
const views = this.subscribers.get(sessionId)
|
||||
if (views !== undefined && views.size > 0) {
|
||||
for (const send of views) send(request)
|
||||
this.pending.delete(sessionId)
|
||||
return { id: request.id, delivered: true }
|
||||
}
|
||||
return { id: request.id, delivered: false }
|
||||
}
|
||||
|
||||
/** Attach one sidebar view (replays queued requests; consume-on-send).
|
||||
* @returns the disposer detaching the view. */
|
||||
attach(sessionId: string, send: Sender): () => void {
|
||||
let views = this.subscribers.get(sessionId)
|
||||
if (views === undefined) {
|
||||
views = new Set()
|
||||
this.subscribers.set(sessionId, views)
|
||||
}
|
||||
views.add(send)
|
||||
const queued = this.pending.get(sessionId) ?? []
|
||||
if (queued.length > 0) {
|
||||
for (const request of queued) send(request)
|
||||
this.pending.delete(sessionId)
|
||||
}
|
||||
return () => {
|
||||
const current = this.subscribers.get(sessionId)
|
||||
current?.delete(send)
|
||||
if (current !== undefined && current.size === 0) this.subscribers.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop every queued request (the feature was turned off mid-session). */
|
||||
drainAll(): void {
|
||||
this.pending.clear()
|
||||
}
|
||||
|
||||
/** Drop the queue and every subscriber (plugin teardown). */
|
||||
dispose(): void {
|
||||
this.pending.clear()
|
||||
this.subscribers.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract the calling agent or throw the canonical "no agent" error. */
|
||||
function requireAgent(agent: ToolRunContext['agent']): NonNullable<ToolRunContext['agent']> {
|
||||
if (agent === undefined) {
|
||||
throw new Error('sidebar_open requires an initiating agent')
|
||||
}
|
||||
return agent
|
||||
}
|
||||
|
||||
/** Resolve the calling agent's session id (the queue scope + ownership key). */
|
||||
function sessionIdOf(exec: ToolRunContext): string {
|
||||
return requireAgent(exec.agent).session.id
|
||||
}
|
||||
|
||||
/** Pure text projection helper (the canonical value is already structured). */
|
||||
function textRender<T>(fn: (value: T) => string): (_args: unknown, value: unknown) => ContentBlock[] {
|
||||
return (_args, value) => [{ type: 'text', text: fn(value as T) }]
|
||||
}
|
||||
|
||||
/** Classify a raw target: http(s) URL or a local path (stat-driven). */
|
||||
async function classifyTarget(raw: string, cwd: string): Promise<{ kind: AgentOpenKind; target: string; title: string }> {
|
||||
if (/^https?:\/\//i.test(raw)) {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(raw)
|
||||
} catch {
|
||||
throw new Error(`"${raw}" is not a valid URL`)
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error('sidebar_open only accepts http:// and https:// URLs')
|
||||
}
|
||||
const title = parsed.hostname !== '' ? parsed.hostname : raw
|
||||
return { kind: 'url', target: raw, title }
|
||||
}
|
||||
// Any other scheme (`file:`, `javascript:`, `vscode:`, ...) is refused —
|
||||
// the model must use the plain URL form or a local path. A Windows drive
|
||||
// prefix (`C:\` / `C:/`) is a PATH, not a scheme, and must not be caught.
|
||||
if (!isWindowsDrivePrefix(raw) && /^[a-z][a-z0-9+.-]*:/i.test(raw)) {
|
||||
throw new Error('sidebar_open only accepts http:// and https:// URLs; use a local path for files')
|
||||
}
|
||||
const target = resolve(isAbsolute(raw) ? raw : join(cwd, raw))
|
||||
let info
|
||||
try {
|
||||
info = await stat(target)
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code === 'ENOENT') throw new Error(`"${raw}" does not exist (resolved to "${target}")`)
|
||||
if (code === 'EACCES' || code === 'EPERM') throw new Error(`"${target}" is not readable`)
|
||||
throw new Error(`cannot open "${target}": ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
const title = basenameOf(target)
|
||||
return { kind: info.isDirectory() ? 'folder' : 'file', target, title: title === '' ? raw : title }
|
||||
}
|
||||
|
||||
/** The last path segment (mirror of the client's FileTree baseName). */
|
||||
function basenameOf(path: string): string {
|
||||
const trimmed = path.replace(/[\\/]+$/, '')
|
||||
const at = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
|
||||
return at === -1 ? trimmed : trimmed.slice(at + 1)
|
||||
}
|
||||
|
||||
/** Whether a raw target starts with a Windows drive prefix (`C:\` / `C:/`). */
|
||||
function isWindowsDrivePrefix(raw: string): boolean {
|
||||
return /^[a-zA-Z]:[\\/]/.test(raw)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `sidebar_open` tool against the host tool registry. The tool
|
||||
* is gated by the side-card setting `agentOpenTools` (the caller registers
|
||||
* and unregisters it); `readPrefs` supplies the live prefs so a disabled
|
||||
* target tab type (editor/browser) is reported to the model instead of
|
||||
* silently no-oping on the client. `resolveCwd` threads the calling
|
||||
* session's live cwd so relative paths resolve the same way the sidebar's
|
||||
* own routes do.
|
||||
* @param ctx - host plugin context (carries the tools service).
|
||||
* @param registry - the open-request registry (per-session queue + views).
|
||||
* @param resolveCwd - async cwd resolver for one session id. Resolves through
|
||||
* the session header, the client-supplied cwd, and the persistence index
|
||||
* before falling back to the host process cwd (production always provides
|
||||
* persistence, so the fallback is reached only in tests / stripped-down hosts).
|
||||
* @param readPrefs - live resolved side card prefs (for tab enable gates).
|
||||
* @returns a disposer that unregisters the tool.
|
||||
*/
|
||||
export function registerOpenTool(
|
||||
ctx: Context,
|
||||
registry: AgentOpenRegistry,
|
||||
resolveCwd: (sessionId: string) => Promise<string>,
|
||||
readPrefs: () => SidebarPrefs,
|
||||
): () => void {
|
||||
return ctx.tools.register(defineTool({
|
||||
name: 'sidebar_open',
|
||||
description:
|
||||
'Open a local file, a local folder, or an HTTP(S) page in the sidebar of the calling conversation. '
|
||||
+ 'A file opens in the sidebar editor (per-path dedupe: an already-open file is focused); a folder opens a file window whose tree is rooted at that folder; '
|
||||
+ 'a URL opens in the sidebar browser (sandboxed iframe). '
|
||||
+ 'The panel auto-expands for content opens and the tab title defaults to the file/folder name or the URL hostname. '
|
||||
+ 'The path may be absolute or relative to the session working directory. '
|
||||
+ 'The open lands in the CALLING session\'s sidebar: while that session\'s sidebar view is not connected '
|
||||
+ '(e.g. the session is not the active one), the open is queued and delivered when the session sidebar is next shown — '
|
||||
+ 'the result reports `delivered` so you know whether it is visible right now. '
|
||||
+ 'The side card setting "model opens files/folders/pages in the sidebar" must be on, and the target tab type must be enabled in that session\'s settings.',
|
||||
parameters: {
|
||||
target: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Absolute or session-cwd-relative local path, or an http:// / https:// URL.',
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
description: 'Optional tab title (defaults to the file/folder name or the URL hostname).',
|
||||
},
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
kind: { type: 'string', required: true, description: 'What was opened: file | folder | url.' },
|
||||
target: { type: 'string', required: true, description: 'The absolute path or URL the open was requested for.' },
|
||||
title: { type: 'string', required: true, description: 'The tab title used (provided title, basename, or hostname).' },
|
||||
delivered: {
|
||||
type: 'boolean',
|
||||
required: true,
|
||||
description: 'Whether the open was pushed to a connected sidebar at call time (false = queued until the session sidebar is next shown).',
|
||||
},
|
||||
},
|
||||
},
|
||||
render: textRender((v: { kind: AgentOpenKind; target: string; title: string; delivered: boolean }) =>
|
||||
v.delivered
|
||||
? `Opened ${v.kind} "${v.title}" (${v.target}) in the sidebar.`
|
||||
: `Requested opening ${v.kind} "${v.title}" (${v.target}) in the sidebar — the session sidebar is not connected yet, so the open is queued and will appear when it is next shown.`,
|
||||
),
|
||||
},
|
||||
execute: async (args: { target: string; title?: string }, exec: ToolRunContext) => {
|
||||
exec.signal.throwIfAborted()
|
||||
const sessionId = sessionIdOf(exec)
|
||||
const cwd = await resolveCwd(sessionId)
|
||||
const { kind, target, title: defaultTitle } = await classifyTarget(args.target, cwd)
|
||||
// A disabled target tab type would make the client no-op the open:
|
||||
// report the real cause to the model instead of a silent success.
|
||||
const prefs = readPrefs()
|
||||
const tab = kind === 'url' ? 'browser' : 'editor'
|
||||
if (prefs.tabsEnabled[tab] === false) {
|
||||
throw new Error(`the built-in ${tab} tab is disabled in the side card settings; ask the user to enable it (or disable this tool)`)
|
||||
}
|
||||
const title = args.title !== undefined && args.title.trim() !== '' ? args.title : defaultTitle
|
||||
const { delivered } = registry.enqueue(sessionId, kind, target, title)
|
||||
return { kind, target, title, delivered }
|
||||
},
|
||||
}))
|
||||
}
|
||||
+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,
|
||||
|
||||
+6
-7
@@ -16,11 +16,10 @@ 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 { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import type { Context } from './context-types.ts'
|
||||
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 = ['docx', 'xlsx', 'pptx', 'terminal', 'editor'] as const
|
||||
export const CHUNK_NAMES = ['terminal', 'editor', 'mermaid'] as const
|
||||
export type ChunkName = (typeof CHUNK_NAMES)[number]
|
||||
|
||||
/** Directory of this host-half module (lib/ — the chunk scripts live next to it). */
|
||||
@@ -68,9 +67,9 @@ async function etagOf(name: ChunkName, chunkDir: string): Promise<string | undef
|
||||
* chunk scripts live in (overridable for tests).
|
||||
*/
|
||||
export function createBundleRouteHandler(
|
||||
fence: (req: IncomingMessage) => boolean,
|
||||
fence: (req: SidebarHttpRequest) => boolean,
|
||||
chunkDir: string = LIB_DIR,
|
||||
): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
|
||||
): (req: SidebarHttpRequest, res: SidebarHttpResponse) => Promise<void> {
|
||||
return async (req, res): Promise<void> => {
|
||||
if (!fence(req)) {
|
||||
res.writeHead(403)
|
||||
@@ -121,8 +120,8 @@ export function createBundleRouteHandler(
|
||||
}
|
||||
|
||||
/** Register the /sidebar/bundle route (disposed with the fiber). */
|
||||
export function registerBundleRoute(ctx: Context, fence: (req: IncomingMessage) => boolean): () => void {
|
||||
return ctx.httpServer.register({
|
||||
export function registerBundleRoute(ctx: Context, fence: (req: SidebarHttpRequest) => boolean): () => void {
|
||||
return ctx.webServer.register({
|
||||
kind: 'prefix',
|
||||
path: '/sidebar/bundle',
|
||||
handler: createBundleRouteHandler(fence),
|
||||
|
||||
@@ -22,11 +22,11 @@ import {
|
||||
IconChevronRightOutline14,
|
||||
IconLinkOutline14,
|
||||
IconRefreshOutline14,
|
||||
IconRightUpOutline16,
|
||||
IconWarningOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { VscLinkExternal } from 'react-icons/vsc'
|
||||
import { api } from './api.ts'
|
||||
import { embeddabilityOf, normalizeBrowserUrl } from './browser.ts'
|
||||
import { embeddabilityOf, isAllowedLoopbackUrl, normalizeBrowserUrl } from './browser.ts'
|
||||
import { patchTab } from './state.ts'
|
||||
import { SandboxStatusBar } from './SandboxStatusBar.tsx'
|
||||
import { t } from './locales.ts'
|
||||
@@ -43,6 +43,42 @@ import css from './sidebar.module.css'
|
||||
export const BROWSER_IFRAME_SANDBOX =
|
||||
'allow-scripts allow-forms allow-popups allow-downloads allow-modals allow-popups-to-escape-sandbox'
|
||||
|
||||
/** allow-same-origin appended for explicitly allowlisted local addresses. */
|
||||
const BROWSER_IFRAME_SANDBOX_SAME_ORIGIN =
|
||||
`${BROWSER_IFRAME_SANDBOX} allow-same-origin`
|
||||
|
||||
/**
|
||||
* The sandbox tokens for one URL: allowlisted loopback addresses (local dev
|
||||
* servers the user explicitly trusts) additionally get `allow-same-origin`
|
||||
* so Vite/module/HMR pipelines that need a real origin work; every other
|
||||
* site keeps the opaque-origin sandbox. `allow-same-origin` does NOT give
|
||||
* the page access to the GUI — it stays cross-origin to it and to every
|
||||
* other site — but it does give it its OWN origin privileges (localStorage,
|
||||
* fetch without CORS), so it is only granted for the explicit allowlist.
|
||||
*
|
||||
* The GUI itself is the one hard exception: even when its own host is
|
||||
* allowlisted (a bare-host entry covers every port, so the GUI origin
|
||||
* matches), a page at the GUI's exact origin must never get
|
||||
* `allow-same-origin` — that would make it same-origin with its parent and
|
||||
* hand it the GUI's storage/API (and the ability to shed the sandbox). The
|
||||
* GUI keeps the opaque-origin sandbox no matter what the allowlist says.
|
||||
*/
|
||||
export function iframeSandboxFor(url: string | undefined, allowedLoopback: string, selfOrigin?: string): string | undefined {
|
||||
if (url === undefined) return undefined
|
||||
if (selfOrigin !== undefined) {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(url)
|
||||
} catch {
|
||||
return BROWSER_IFRAME_SANDBOX
|
||||
}
|
||||
if (parsed.origin === selfOrigin) return BROWSER_IFRAME_SANDBOX
|
||||
}
|
||||
return isAllowedLoopbackUrl(url, allowedLoopback)
|
||||
? BROWSER_IFRAME_SANDBOX_SAME_ORIGIN
|
||||
: BROWSER_IFRAME_SANDBOX
|
||||
}
|
||||
|
||||
export function BrowserView(props: TabComponentProps) {
|
||||
const { store, tab } = props
|
||||
// The current address (initialized from the persisted tab.path so a
|
||||
@@ -88,7 +124,7 @@ export function BrowserView(props: TabComponentProps) {
|
||||
}
|
||||
|
||||
const navigateTo = (raw: string): void => {
|
||||
const result = normalizeBrowserUrl(raw, window.location.origin)
|
||||
const result = normalizeBrowserUrl(raw, window.location.origin, store.getPrefs().browserAllowedLoopback)
|
||||
if (result.kind === 'ok') {
|
||||
const next = result.url
|
||||
setUrl(next)
|
||||
@@ -186,7 +222,7 @@ export function BrowserView(props: TabComponentProps) {
|
||||
if (url !== undefined) window.open(url, '_blank', 'noopener')
|
||||
}}
|
||||
>
|
||||
<IconRightUpOutline16 size={15} />
|
||||
<VscLinkExternal size={15} />
|
||||
</button>
|
||||
</div>
|
||||
{message !== null && <div className={css.browserMessage}>{message}</div>}
|
||||
@@ -210,7 +246,7 @@ export function BrowserView(props: TabComponentProps) {
|
||||
key={`${reloadKey}:${noSandbox ? 'ns' : 'sb'}`}
|
||||
className={css.browserFrame}
|
||||
src={url}
|
||||
sandbox={noSandbox ? undefined : BROWSER_IFRAME_SANDBOX}
|
||||
sandbox={noSandbox ? undefined : iframeSandboxFor(url, store.getPrefs().browserAllowedLoopback, window.location.origin)}
|
||||
referrerPolicy="no-referrer"
|
||||
allow=""
|
||||
title={url}
|
||||
|
||||
+10
-6
@@ -14,6 +14,7 @@ import { api } from './api.ts'
|
||||
import type { SidebarDiffRef } from './state.ts'
|
||||
import { DiffView } from './DiffView.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import { resolveSidebarPath } from './produced-files.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** The loaded diff surface (untracked content rendered as a full addition). */
|
||||
@@ -33,24 +34,24 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const scope: SessionScope = { sessionId, cwd }
|
||||
const scope: SessionScope = { sessionId, cwd, ...(diff.repoRoot !== undefined ? { repoRoot: diff.repoRoot } : {}) }
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setData(null)
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
if (diff.kind === 'commit') {
|
||||
const result = await api.gitCommitDiff(scope, diff.hashFull)
|
||||
const result = await api.gitCommitDiff(scope, diff.hashFull, diff.worktree)
|
||||
if (!cancelled) setData({ diff: result.diff })
|
||||
return
|
||||
}
|
||||
let result = await api.gitDiff(scope, diff.path, diff.staged)
|
||||
let result = await api.gitDiff(scope, diff.path, diff.staged, diff.worktree)
|
||||
if (result.diff === '') {
|
||||
// The requested side is empty — try the OTHER side once: the ref
|
||||
// may predate the staged-flag fix, or the change moved sides (a
|
||||
// file staged after its tab opened). Both sides empty means the
|
||||
// file genuinely has no text changes.
|
||||
const other = await api.gitDiff(scope, diff.path, !diff.staged)
|
||||
const other = await api.gitDiff(scope, diff.path, !diff.staged, diff.worktree)
|
||||
if (other.diff !== '') result = other
|
||||
}
|
||||
if (result.diff !== '') {
|
||||
@@ -60,7 +61,10 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
|
||||
// Empty diff: an untracked file (git diff never lists it) falls back
|
||||
// to a full-file addition; anything else is a genuine no-text-change.
|
||||
if (diff.untracked === true && !diff.staged) {
|
||||
const text = await api.fsRead(scope, diff.path)
|
||||
// A child-repo path is relative to diff.repoRoot, not the session
|
||||
// cwd or the linked-worktree root; resolve against whichever the
|
||||
// diff ref carries so the untracked fallback reads the right file.
|
||||
const text = await api.fsRead(scope, resolveSidebarPath(diff.repoRoot ?? diff.worktree ?? cwd, diff.path))
|
||||
if (!cancelled) {
|
||||
setData(text.kind === 'text' ? { diff: '', untracked: text.content } : { diff: '' })
|
||||
}
|
||||
@@ -90,7 +94,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>}
|
||||
|
||||
+48
-9
@@ -9,7 +9,7 @@
|
||||
* The parser is a pure function (`parseUnifiedDiff`) so the interesting
|
||||
* cases are unit-tested without a DOM.
|
||||
*/
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
@@ -171,6 +171,25 @@ function fileTag(file: DiffFile): string | null {
|
||||
/** Cap the flattened rows like DiffBlock: head + tail, expand button between. */
|
||||
const MAX_DIFF_ROWS = 500
|
||||
|
||||
const TEST_PATH = /(^|\/)(?:__tests__|tests?|specs?|fixtures?|mocks?|snapshots?)(?:\/|$)|\.(?:test|spec)\.[^/]+$/i
|
||||
const DOC_PATH = /(^|\/)(?:docs?|documentation)(?:\/|$)|(^|\/)(?:readme|changelog|contributing|license|authors|notice)(?:\.[^/]*)?$/i
|
||||
const GENERATED_PATH = /(^|\/)(?:dist|build|coverage|generated|vendor|node_modules)(?:\/|$)|(^|\/)(?:package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|composer\.lock|cargo\.lock|poetry\.lock)$/i
|
||||
const SOURCE_PATH = /\.(?:js|jsx|mjs|cjs|ts|tsx|mts|cts|py|pyw|rb|php|java|kt|kts|scala|go|rs|swift|c|h|cc|cpp|cxx|hpp|hh|hxx|cs|fs|fsx|vb|dart|lua|r|ex|exs|erl|hrl|clj|cljs|cljc|groovy|sh|bash|zsh|fish|ps1|sql|vue|svelte|astro|html|htm|css|scss|sass|less)$/i
|
||||
|
||||
/** Source files open by default; tests, docs, generated files and unknown types stay folded. */
|
||||
function defaultExpandedFiles(files: DiffFile[]): Set<number> {
|
||||
const expanded = new Set<number>()
|
||||
files.forEach((file, index) => {
|
||||
const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath)
|
||||
if (!file.binary && file.hunks.length > 0
|
||||
&& !TEST_PATH.test(path) && !DOC_PATH.test(path) && !GENERATED_PATH.test(path)
|
||||
&& SOURCE_PATH.test(path)) {
|
||||
expanded.add(index)
|
||||
}
|
||||
})
|
||||
return expanded
|
||||
}
|
||||
|
||||
export interface DiffViewProps {
|
||||
/** Unified diff text (`git.diff` or `git.commit-diff` payloads). */
|
||||
diff: string
|
||||
@@ -187,22 +206,25 @@ export function DiffView({ diff, untrackedPath, untrackedContent }: DiffViewProp
|
||||
return parseUnifiedDiff(diff)
|
||||
}, [diff, untrackedPath, untrackedContent])
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [expandedFiles, setExpandedFiles] = useState<Set<number>>(() => defaultExpandedFiles(parsed.files))
|
||||
|
||||
useEffect(() => { setExpandedFiles(defaultExpandedFiles(parsed.files)) }, [parsed])
|
||||
|
||||
// Flatten into display rows so the cap can slice a single list.
|
||||
const rows = useMemo(() => {
|
||||
const out: Array<{ key: string; file: DiffFile; type: 'path' | 'hunk' | 'line'; hunk?: DiffHunk; line?: DiffLine }> = []
|
||||
const out: Array<{ key: string; file: DiffFile; fileIndex: number; type: 'path' | 'hunk' | 'line'; hunk?: DiffHunk; line?: DiffLine }> = []
|
||||
parsed.files.forEach((file, fileIndex) => {
|
||||
out.push({ key: `f${fileIndex}`, file, type: 'path' })
|
||||
if (file.binary) return
|
||||
out.push({ key: `f${fileIndex}`, file, fileIndex, type: 'path' })
|
||||
if (file.binary || !expandedFiles.has(fileIndex)) return
|
||||
file.hunks.forEach((hunk, hunkIndex) => {
|
||||
out.push({ key: `f${fileIndex}h${hunkIndex}`, file, type: 'hunk', hunk })
|
||||
out.push({ key: `f${fileIndex}h${hunkIndex}`, file, fileIndex, type: 'hunk', hunk })
|
||||
hunk.lines.forEach((line, lineIndex) => {
|
||||
out.push({ key: `f${fileIndex}h${hunkIndex}l${lineIndex}`, file, type: 'line', hunk, line })
|
||||
out.push({ key: `f${fileIndex}h${hunkIndex}l${lineIndex}`, file, fileIndex, type: 'line', hunk, line })
|
||||
})
|
||||
})
|
||||
})
|
||||
return out
|
||||
}, [parsed])
|
||||
}, [parsed, expandedFiles])
|
||||
|
||||
const hidden = rows.length - MAX_DIFF_ROWS
|
||||
const capped = hidden > 0 && !expanded
|
||||
@@ -218,12 +240,29 @@ export function DiffView({ diff, untrackedPath, untrackedContent }: DiffViewProp
|
||||
const tag = fileTag(row.file)
|
||||
const from = displayPath(row.file.oldPath)
|
||||
const to = displayPath(row.file.newPath)
|
||||
const expandable = !row.file.binary && row.file.hunks.length > 0
|
||||
const fileExpanded = expandedFiles.has(row.fileIndex)
|
||||
return (
|
||||
<div key={row.key} className={css.gitDiffFile}>
|
||||
<button
|
||||
key={row.key}
|
||||
type="button"
|
||||
className={css.gitDiffFile}
|
||||
disabled={!expandable}
|
||||
aria-expanded={expandable ? fileExpanded : undefined}
|
||||
onClick={() => {
|
||||
setExpandedFiles(current => {
|
||||
const next = new Set(current)
|
||||
if (next.has(row.fileIndex)) next.delete(row.fileIndex)
|
||||
else next.add(row.fileIndex)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
>
|
||||
{expandable && <span aria-hidden="true" className={clsx(css.gitDiffFileChevron, fileExpanded && css.gitDiffFileChevronExpanded)}>›</span>}
|
||||
<span className={css.gitDiffFilePath}>{to}</span>
|
||||
{from !== to && <span className={css.gitDiffFileOld}>← {from}</span>}
|
||||
{tag !== null && <span className={css.gitDiffFileTag}>{tag}</span>}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
if (row.type === 'hunk') {
|
||||
|
||||
+484
-27
@@ -1,22 +1,46 @@
|
||||
/**
|
||||
* 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, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { createElement } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCheckOutline16, IconFolderOpen16, IconRefreshOutline14 } 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 { createFrameBatcher } from './frame-batcher.ts'
|
||||
import { openSidebarFile } from './intercept.tsx'
|
||||
import { openWithSshActive, openWithUrl, parseOpenWithConfig, resolveOpenWithTargets } from './open-with.ts'
|
||||
import { updatePluginSettings } from './plugin-settings.ts'
|
||||
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 +49,246 @@ 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
|
||||
|
||||
/** Stable empty blob for the editor pluginSettings read (a fresh `?? {}`
|
||||
* would change identity every snapshot and loop useSyncExternalStore). */
|
||||
const EMPTY_PLUGIN_BLOB: Record<string, unknown> = {}
|
||||
|
||||
/** 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.get('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[]
|
||||
revealed: string[]
|
||||
onToggleDir: (path: string) => void
|
||||
onReferenceFile: (path: string) => void
|
||||
}) {
|
||||
const { ctx, store, scope, tab, expanded, revealed, onToggleDir, onReferenceFile } = props
|
||||
const path = tab.path ?? ''
|
||||
const title = tab.title
|
||||
// A folder window: the model's `sidebar_open` (or any caller) opens a
|
||||
// directory as an editor tab carrying `meta.dir: true` with the directory
|
||||
// as its path. It renders the file tree rooted at that folder instead of
|
||||
// the viewer loading flow (a directory is not a file).
|
||||
const isDir = metaOf(tab).dir === true
|
||||
const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
|
||||
// Manual refresh (issue #167): bumping the sequence re-runs the load effect
|
||||
// with the same path/scope — the only reload entry besides open/close.
|
||||
const [reloadSeq, setReloadSeq] = useState(0)
|
||||
|
||||
// Manual refresh (issue #167 + PR #228): a dirty draft is dropped by the
|
||||
// reload (the editor instance remounts), so confirm before discarding it.
|
||||
const refreshFile = (): void => {
|
||||
if (toolbar?.dirty === true) {
|
||||
const confirmed = typeof window.confirm === 'function'
|
||||
? window.confirm(t('refreshUnsavedConfirm'))
|
||||
: false
|
||||
if (!confirmed) return
|
||||
}
|
||||
setReloadSeq(sequence => sequence + 1)
|
||||
}
|
||||
|
||||
// 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]),
|
||||
)
|
||||
// The file tree's "open with" configuration (pluginSettings['editor']): a
|
||||
// blob subscription, so a pin click or a settings-page edit re-renders the
|
||||
// menu immediately. The parsed config also drives which targets are shown
|
||||
// (SSH mode hides the host-local ones).
|
||||
const editorBlob = useSyncExternalStore(
|
||||
useCallback((callback: () => void) => store.subscribe(callback), [store]),
|
||||
useCallback(() => store.getSnapshot().prefs.pluginSettings['editor'] ?? EMPTY_PLUGIN_BLOB, [store]),
|
||||
)
|
||||
const openWithConfig = useMemo(() => parseOpenWithConfig(editorBlob.openWith), [editorBlob])
|
||||
const openWithTargets = useMemo(() => resolveOpenWithTargets(openWithConfig), [openWithConfig])
|
||||
// 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). A
|
||||
// folder tab is a folder window in BOTH modes: the tree rooted at the
|
||||
// folder, no editor chrome.
|
||||
const showEmpty = path === ''
|
||||
const treeOnly = showEmpty && !inPlace
|
||||
const folderRoot = isDir ? path : undefined
|
||||
|
||||
/**
|
||||
* 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.get('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 context menu's "open with" action: reveal the path in the OS file
|
||||
* manager, or hand the target's URL (a local `file` URL, or the SSH-remote
|
||||
* form for VSCode-family editors in remote mode) to the host's external
|
||||
* opener. Failures are logged only — a missing handler is the OS's
|
||||
* dialog, not a sidebar error. */
|
||||
const openWith = (targetId: string, absolute: string): void => {
|
||||
const target = openWithTargets.find(item => item.id === targetId)
|
||||
if (target === undefined) return
|
||||
if (target.kind === 'reveal') {
|
||||
void api.openExternal({ action: 'reveal', path: absolute }).catch(
|
||||
(error: unknown) => { console.error('open external failed', error) },
|
||||
)
|
||||
return
|
||||
}
|
||||
const url = openWithUrl(target, absolute, openWithConfig)
|
||||
if (url === undefined) return
|
||||
void api.openExternal({ action: 'url', url }).catch(
|
||||
(error: unknown) => { console.error('open external failed', error) },
|
||||
)
|
||||
}
|
||||
|
||||
/** Toggle one target's pinned state. The write is serialized (see
|
||||
* plugin-settings.ts) and the menu re-renders when the store prefs land. */
|
||||
const toggleOpenWithPin = (targetId: string): void => {
|
||||
updatePluginSettings(store, 'editor', (blob) => {
|
||||
const config = parseOpenWithConfig(blob.openWith)
|
||||
const pinned = config.pinned.includes(targetId)
|
||||
? config.pinned.filter(id => id !== targetId)
|
||||
: [...config.pinned, targetId]
|
||||
return { ...blob, openWith: { ...config, pinned } }
|
||||
})
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Moves are BATCHED per frame (createFrameBatcher): applying every
|
||||
// pointermove is a setState that re-renders this host AND the editor
|
||||
// viewer below it (CodeMirror re-lays out on the width change) at event
|
||||
// cadence — the visible drag lag on slower CPUs (#315). The batch applies
|
||||
// the latest width once per frame; release flushes it and commits.
|
||||
const [dragWidth, setDragWidth] = useState<number | null>(null)
|
||||
const dragRef = useRef<{ startX: number; startWidth: number } | null>(null)
|
||||
const pendingWidthRef = useRef(0)
|
||||
const dragBatcher = useRef(createFrameBatcher()).current
|
||||
useEffect(() => () => dragBatcher.dispose(), [dragBatcher])
|
||||
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
|
||||
pendingWidthRef.current = clampTreeWidth(drag.startWidth + (drag.startX - event.clientX))
|
||||
dragBatcher.schedule(() => setDragWidth(pendingWidthRef.current))
|
||||
}
|
||||
const onResizeEnd = (event: React.PointerEvent): void => {
|
||||
const drag = dragRef.current
|
||||
if (drag === null) return
|
||||
// Flush the last pending frame (a release can land with the final move
|
||||
// still queued; without the flush a stray frame would re-apply the
|
||||
// drag width AFTER the null below). Both setStates batch into this same
|
||||
// event, so the committed treeWidth wins visually.
|
||||
dragBatcher.flushNow()
|
||||
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. A folder tab never loads a
|
||||
// viewer either — its tree is rooted at the folder.
|
||||
if (showEmpty || isDir) 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 +308,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) => {
|
||||
@@ -67,7 +325,7 @@ export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: Se
|
||||
content: result.kind === 'text' ? result.content : '',
|
||||
truncated: result.truncated,
|
||||
head: result.kind === 'binary' ? result.head : undefined,
|
||||
}, (head) => ctx.betterSidebar?.matchFileViewer(path, head), mediaUrlOf)
|
||||
}, (head) => ctx.get('betterSidebar')?.matchFileViewer(path, head), mediaUrlOf)
|
||||
apply(outcome)
|
||||
}).catch((error: unknown) => {
|
||||
if (cancelled) return
|
||||
@@ -76,26 +334,225 @@ export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: Se
|
||||
return
|
||||
}
|
||||
}
|
||||
apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf))
|
||||
return () => { cancelled = true }
|
||||
}, [scope.sessionId, scope.cwd, path, ctx])
|
||||
apply(planFirstMatch(ctx.get('betterSidebar')?.matchFileViewer(path), mediaUrlOf))
|
||||
return () => { cancelled = true; controller.abort() }
|
||||
}, [scope.sessionId, scope.cwd, path, ctx, showEmpty, isDir, reloadSeq])
|
||||
|
||||
// Save-then-refresh in preview mode (issue #167 part C): the edge into
|
||||
// 'saved' (never a lingering 'saved' state) triggers exactly one reload, so
|
||||
// a preview-mode Ctrl+S shows the fresh content immediately. Edit mode is
|
||||
// left alone — reloading would remount the editor and drop the caret.
|
||||
const prevSaveState = useRef<EditorToolbarState['saveState'] | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
const current = toolbar?.saveState
|
||||
if (prevSaveState.current !== 'saved' && current === 'saved' && toolbar?.mode === 'preview') {
|
||||
setReloadSeq(sequence => sequence + 1)
|
||||
}
|
||||
prevSaveState.current = current
|
||||
}, [toolbar?.saveState, toolbar?.mode])
|
||||
|
||||
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.
|
||||
// A folder window (meta.dir, any mode) renders the SAME surface rooted
|
||||
// at the folder instead of the session cwd.
|
||||
if (treeOnly || folderRoot !== undefined) {
|
||||
return (
|
||||
<div className={css.editor}>
|
||||
<TreePanel
|
||||
full
|
||||
sessionId={scope.sessionId}
|
||||
cwd={folderRoot ?? scope.cwd}
|
||||
expanded={expanded}
|
||||
revealed={revealed}
|
||||
onToggle={onToggleDir}
|
||||
onOpenFile={openFile}
|
||||
onOpenFileNewTab={openFileNewTab}
|
||||
onOpenFileSide={openFileSide}
|
||||
openWithTargets={openWithTargets}
|
||||
openWithPinned={openWithConfig.pinned}
|
||||
openWithSsh={openWithSshActive(openWithConfig)}
|
||||
onOpenWith={openWith}
|
||||
onToggleOpenWithPin={toggleOpenWithPin}
|
||||
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={() => {
|
||||
// Issue #167 part B: returning from edit to preview reloads so
|
||||
// the preview renders the just-saved content. A dirty draft
|
||||
// (or a failed save) suppresses the reload — the draft only
|
||||
// lives in the editor instance and a remount would drop it.
|
||||
if (toolbar.mode === 'edit' && toolbar.dirty !== true && toolbar.saveState !== 'failed') {
|
||||
setReloadSeq(sequence => sequence + 1)
|
||||
}
|
||||
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>
|
||||
)}
|
||||
{toolbar !== null && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('refresh')}
|
||||
title={t('refresh')}
|
||||
onClick={refreshFile}
|
||||
>
|
||||
<IconRefreshOutline14 size={14} />
|
||||
</button>
|
||||
)}
|
||||
<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}
|
||||
revealed={revealed}
|
||||
onToggle={onToggleDir}
|
||||
onOpenFile={openFile}
|
||||
onOpenFileNewTab={openFileNewTab}
|
||||
onOpenFileSide={openFileSide}
|
||||
openWithTargets={openWithTargets}
|
||||
openWithPinned={openWithConfig.pinned}
|
||||
openWithSsh={openWithSshActive(openWithConfig)}
|
||||
onOpenWith={openWith}
|
||||
onToggleOpenWithPin={toggleOpenWithPin}
|
||||
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,669 @@
|
||||
/**
|
||||
* 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 {
|
||||
IconChevronRightOutline14, IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16,
|
||||
IconLinkOutline16, Menu, type MenuEntry, type MenuItem, writeClipboard,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { SiCursor, SiZedindustries } from 'react-icons/si'
|
||||
import { VscFile, VscFolder, VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc'
|
||||
import { api, downloadUrl, type FsEntry } from './api.ts'
|
||||
import { IconUploadOutline16, IconVscode16 } from './icons.tsx'
|
||||
import type { OpenWithTarget } from './open-with.ts'
|
||||
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[]
|
||||
/** Files highlighted by a "Show in folder" reveal (absolute paths). */
|
||||
revealed: 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
|
||||
/**
|
||||
* The "open with" menu: resolved external targets (already SSH-filtered
|
||||
* and in menu order). Absent → the whole section is hidden.
|
||||
*/
|
||||
openWithTargets?: OpenWithTarget[]
|
||||
/** Ids of targets pinned to the menu's top level (subset of the ids). */
|
||||
openWithPinned?: string[]
|
||||
/** Whether the workspace is remote (appends the SSH hint to target labels). */
|
||||
openWithSsh?: boolean
|
||||
/** Open one target externally (reveal or URL — the caller decides). */
|
||||
onOpenWith?: (targetId: string, path: string) => void
|
||||
/** Toggle one target's pinned state (the submenu row's pushpin). */
|
||||
onToggleOpenWithPin?: (targetId: 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, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, 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])
|
||||
|
||||
// Bring a "Show in folder" reveal into view: the ancestors expand above
|
||||
// (revealPaths), but the row may not be scrolled into sight — a reveal on
|
||||
// a long tree should surface the highlighted file. Re-runs when the tree
|
||||
// data or reveal set changes (the row appears after its level loads).
|
||||
useEffect(() => {
|
||||
if (revealed.length === 0) return
|
||||
const row = bodyRef.current?.querySelector('[data-dsh-revealed]')
|
||||
row?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
}, [revealed, data])
|
||||
|
||||
/** 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()
|
||||
}
|
||||
|
||||
/** The menu label of one open target: a locale key for the built-ins, the
|
||||
* user's own name for custom editors, plus the SSH hint in remote mode. */
|
||||
const openWithLabelOf = (target: OpenWithTarget): string => {
|
||||
const name = target.nameKey !== undefined ? t(target.nameKey) : target.name
|
||||
return openWithSsh === true && !target.localOnly ? `${name}${t('openWithSshSuffix')}` : name
|
||||
}
|
||||
|
||||
/**
|
||||
* The "open with" menu entries: the pinned targets as DIRECT rows, then
|
||||
* the parent row with every target as a nested submenu. Both only render
|
||||
* when the caller wired the feature and at least one target is visible.
|
||||
*/
|
||||
const openWithEntries = (): MenuEntry[] => {
|
||||
if (openWithTargets === undefined || onOpenWith === undefined || openWithTargets.length === 0) return []
|
||||
const pinnedIds = openWithPinned ?? []
|
||||
/** Brand marks for the built-ins (monochrome silhouettes, currentColor);
|
||||
* reveal gets the folder glyph, custom editors a generic code mark. */
|
||||
const itemIcon = (target: OpenWithTarget): ReactNode => {
|
||||
if (target.kind === 'reveal') return <VscFolderOpened size={16} />
|
||||
if (target.id === 'vscode') return <IconVscode16 size={16} />
|
||||
if (target.id === 'cursor') return <SiCursor size={16} />
|
||||
if (target.id === 'zed') return <SiZedindustries size={16} />
|
||||
return <IconCodeOutline16 size={16} />
|
||||
}
|
||||
const pinned = openWithTargets
|
||||
.filter(target => pinnedIds.includes(target.id))
|
||||
.map<MenuItem>(target => ({
|
||||
id: `open-with:${target.id}`,
|
||||
label: openWithLabelOf(target),
|
||||
icon: itemIcon(target),
|
||||
}))
|
||||
const submenu = openWithTargets.map<MenuItem>(target => {
|
||||
const pinnedNow = pinnedIds.includes(target.id)
|
||||
return {
|
||||
id: `open-with:${target.id}`,
|
||||
label: (
|
||||
<span className={css.openWithLabel}>
|
||||
<span className={css.openWithName}>{openWithLabelOf(target)}</span>
|
||||
{/* The pushpin: a span (never a button — the Menu row itself is
|
||||
a button, so a nested interactive element would be invalid).
|
||||
Clicking it pins/unpins the target at the menu's top level
|
||||
WITHOUT selecting the row: the pin stops propagation, so the
|
||||
menu stays open and the icon flips on the next render. */}
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
className={clsx(css.openWithPin, pinnedNow && css.openWithPinActive)}
|
||||
aria-label={pinnedNow ? t('unpinOpenWith') : t('pinOpenWith')}
|
||||
title={pinnedNow ? t('unpinOpenWith') : t('pinOpenWith')}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onToggleOpenWithPin?.(target.id)
|
||||
}}
|
||||
>
|
||||
{pinnedNow ? <VscPinned size={14} /> : <VscPin size={14} />}
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
icon: itemIcon(target),
|
||||
}
|
||||
})
|
||||
return [
|
||||
...pinned,
|
||||
...(pinned.length > 0 ? [{ id: 'open-with-sep', type: 'separator' } as MenuEntry] : []),
|
||||
{
|
||||
id: 'open-with-menu',
|
||||
// The primitives Menu renders no chevron for submenu parents — the
|
||||
// trailing arrow is supplied inside the label (full-width flex row,
|
||||
// right-aligned), matching how the submenu rows right-align the pin.
|
||||
label: (
|
||||
<span className={css.openWithLabel}>
|
||||
<span className={css.openWithName}>{t('openWithMenu')}</span>
|
||||
<IconChevronRightOutline14 size={14} className={css.openWithChevron} aria-hidden />
|
||||
</span>
|
||||
),
|
||||
icon: <VscLinkExternal size={16} />,
|
||||
submenu,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
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,
|
||||
revealed.includes(entry.path) && css.explorerRowRevealed,
|
||||
)}
|
||||
data-dsh-revealed={revealed.includes(entry.path) ? 'true' : undefined}
|
||||
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 ? <VscFolderOpened size={14} /> : <VscFolder 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,
|
||||
revealed.includes(entry.path) && css.explorerRowRevealed,
|
||||
)}
|
||||
data-dsh-revealed={revealed.includes(entry.path) ? 'true' : undefined}
|
||||
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) }}
|
||||
>
|
||||
<VscFile 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) }}
|
||||
>
|
||||
<VscFolderOpened 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={16} /> }]
|
||||
: []),
|
||||
...(rowMenu?.isDir === false && onOpenFileSide !== undefined
|
||||
? [{ id: 'open-side', label: t('openFileSide'), icon: <VscFolderOpened size={16} /> }]
|
||||
: []),
|
||||
...openWithEntries(),
|
||||
// Download applies to files only (the host route refuses directories).
|
||||
...(rowMenu?.isDir === false
|
||||
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={16} /> }]
|
||||
: []),
|
||||
// Upload into a directory (incl. the workspace root row).
|
||||
...(rowMenu?.isDir === true
|
||||
? [{ id: 'upload-here', label: t('uploadHere'), icon: <IconUploadOutline16 size={16} /> }]
|
||||
: []),
|
||||
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={16} /> },
|
||||
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={16} /> },
|
||||
]}
|
||||
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.startsWith('open-with:')) {
|
||||
onOpenWith?.(id.slice('open-with:'.length), 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* One free window: a tab dragged out of the workbench floating over the
|
||||
* conversation area at viewport coordinates (rendered inside the panel host,
|
||||
* so desktop-shell transforms can never hijack its fixed containing block).
|
||||
*
|
||||
* The header drags the window with the panel-resize pattern — pointer
|
||||
* capture + per-frame direct DOM writes + a store commit on release — and
|
||||
* doubling as the DOCK-BACK gesture: while the pointer is over a workbench
|
||||
* pane ([data-dsh-pane], either panel), that pane highlights live and
|
||||
* releasing docks the tab into it (center merge); releasing anywhere else
|
||||
* just moves the window. The SE corner resizes, any press raises (the
|
||||
* floats array's order is the stacking order), the header right-click menu
|
||||
* and the X button dock / close. The tab content reuses the regular tab
|
||||
* renderer, so every tab type (terminal, editor, plugin tabs) floats
|
||||
* unchanged.
|
||||
*/
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCloseFill14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { FloatWindow, SidebarTab } from './state.ts'
|
||||
import { FLOAT_MIN_H, FLOAT_MIN_W } from './state.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** A drag's mutable bookkeeping (start geometry + the last applied values). */
|
||||
interface DragState {
|
||||
/** Which handle owns the drag (the abort path reads it from this ref —
|
||||
* the React `dragging` state lags a render behind pointerdown). */
|
||||
mode: 'move' | 'resize'
|
||||
pointerX: number
|
||||
pointerY: number
|
||||
startX: number
|
||||
startY: number
|
||||
startW: number
|
||||
startH: number
|
||||
/** Last geometry actually written to the DOM (the cancel-path fallback). */
|
||||
applied: { x: number; y: number; w: number; h: number }
|
||||
/** Settled after the final pointer event (the up position wins over the
|
||||
* rAF-pending value — a fast flick coalesces its tail into the up event). */
|
||||
committed: boolean
|
||||
}
|
||||
|
||||
/** The pane under a viewport point, if any (rect hit-test; the dragged
|
||||
* window itself is not a pane, so it cannot shadow the targets). */
|
||||
function paneAt(x: number, y: number): HTMLElement | null {
|
||||
for (const pane of document.querySelectorAll<HTMLElement>('[data-dsh-pane]')) {
|
||||
const rect = pane.getBoundingClientRect()
|
||||
if (rect.width === 0 || rect.height === 0) continue
|
||||
if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) return pane
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Pointer-capture helpers tolerant of environments without the API (jsdom
|
||||
* lacks setPointerCapture — component tests dispatch plain MouseEvents, so
|
||||
* the optional calls keep them driving the drag; real browsers always have
|
||||
* it and a missing pointerId can never occur there). */
|
||||
const capturePointer = (element: HTMLElement, pointerId: number): void => {
|
||||
element.setPointerCapture?.(pointerId)
|
||||
}
|
||||
|
||||
const releasePointer = (element: HTMLElement, pointerId: number): void => {
|
||||
element.releasePointerCapture?.(pointerId)
|
||||
}
|
||||
|
||||
/** Whether the element holds the pointer (assumed true without the API). */
|
||||
const holdsPointer = (element: HTMLElement, pointerId: number): boolean => {
|
||||
return element.hasPointerCapture?.(pointerId) !== false
|
||||
}
|
||||
|
||||
export function FreeWindow(props: {
|
||||
float: FloatWindow
|
||||
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
|
||||
getTabIcon?: (tab: SidebarTab) => ReactNode
|
||||
onRaise: () => void
|
||||
onMove: (x: number, y: number) => void
|
||||
onResize: (w: number, h: number) => void
|
||||
/** Dock the tab back; the pane id, or null for the active pane. */
|
||||
onDock: (paneId: string | null) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { float, renderTab, getTabIcon, onRaise, onMove, onResize, onDock, onClose } = props
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const dragRef = useRef<DragState | null>(null)
|
||||
// The pane currently highlighted as the dock target (a direct DOM write:
|
||||
// per-frame React state would re-render every mounted tab at drag cadence).
|
||||
const dockTargetRef = useRef<HTMLElement | null>(null)
|
||||
// rAF coalescing: pointermove outruns the display, one write per frame.
|
||||
const frameRef = useRef<number | null>(null)
|
||||
const pendingRef = useRef<{ x: number; y: number; w: number; h: number } | null>(null)
|
||||
const [dragging, setDragging] = useState<'move' | 'resize' | null>(null)
|
||||
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null)
|
||||
|
||||
useEffect(() => () => {
|
||||
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current)
|
||||
dockTargetRef.current?.removeAttribute('data-dsh-float-dock-over')
|
||||
}, [])
|
||||
|
||||
/** Apply the pending geometry to the DOM (one rAF per frame at most). */
|
||||
const scheduleApply = (geo: { x: number; y: number; w: number; h: number }): void => {
|
||||
pendingRef.current = geo
|
||||
if (frameRef.current !== null) return
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
frameRef.current = null
|
||||
const pending = pendingRef.current
|
||||
const drag = dragRef.current
|
||||
const root = rootRef.current
|
||||
if (pending === null || drag === null || root === null) return
|
||||
drag.applied = pending
|
||||
root.style.left = `${pending.x}px`
|
||||
root.style.top = `${pending.y}px`
|
||||
root.style.width = `${pending.w}px`
|
||||
root.style.height = `${pending.h}px`
|
||||
})
|
||||
}
|
||||
|
||||
/** Flush the pending frame synchronously (the release path's last write). */
|
||||
const flushNow = (): void => {
|
||||
const pending = pendingRef.current
|
||||
const drag = dragRef.current
|
||||
const root = rootRef.current
|
||||
if (pending === null || drag === null || root === null) return
|
||||
if (frameRef.current !== null) {
|
||||
cancelAnimationFrame(frameRef.current)
|
||||
frameRef.current = null
|
||||
}
|
||||
pendingRef.current = null
|
||||
drag.applied = pending
|
||||
root.style.left = `${pending.x}px`
|
||||
root.style.top = `${pending.y}px`
|
||||
root.style.width = `${pending.w}px`
|
||||
root.style.height = `${pending.h}px`
|
||||
}
|
||||
|
||||
const clearDockHighlight = (): void => {
|
||||
dockTargetRef.current?.removeAttribute('data-dsh-float-dock-over')
|
||||
dockTargetRef.current = null
|
||||
}
|
||||
|
||||
/** Release a drag: `pane` (when set) docks instead of moving. */
|
||||
const finishDrag = (mode: 'move' | 'resize', geo: { x: number; y: number; w: number; h: number }, pane: HTMLElement | null): void => {
|
||||
const drag = dragRef.current
|
||||
if (drag === null || drag.committed) return
|
||||
drag.committed = true
|
||||
dragRef.current = null
|
||||
setDragging(null)
|
||||
const target = pane ?? dockTargetRef.current
|
||||
clearDockHighlight()
|
||||
if (mode === 'move' && target !== null) {
|
||||
onDock(target.getAttribute('data-dsh-pane'))
|
||||
} else if (mode === 'move') {
|
||||
onMove(geo.x, geo.y)
|
||||
} else {
|
||||
onResize(geo.w, geo.h)
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancel-path settle (pointercancel / lostpointercapture): the last
|
||||
* APPLIED geometry is the user-visible truth — commit it, never roll back
|
||||
* (the panel drags' issue-#247 semantics). The mode comes from the ref —
|
||||
* the React `dragging` state can still be null when the cancel lands
|
||||
* before the pointerdown re-render commits. */
|
||||
const abortDrag = (): void => {
|
||||
const drag = dragRef.current
|
||||
if (drag === null || drag.committed) return
|
||||
flushNow()
|
||||
finishDrag(drag.mode, drag.applied, null)
|
||||
}
|
||||
|
||||
const clampMove = (x: number, y: number): { x: number; y: number; w: number; h: number } => {
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
return {
|
||||
x: Math.min(Math.max(x, 0), Math.max(0, vw - float.w)),
|
||||
y: Math.min(Math.max(y, 0), Math.max(0, vh - float.h)),
|
||||
w: float.w,
|
||||
h: float.h,
|
||||
}
|
||||
}
|
||||
|
||||
const clampResize = (w: number, h: number): { x: number; y: number; w: number; h: number } => {
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
return {
|
||||
x: float.x,
|
||||
y: float.y,
|
||||
w: Math.round(Math.min(Math.max(w, FLOAT_MIN_W), Math.max(FLOAT_MIN_W, vw - float.x))),
|
||||
h: Math.round(Math.min(Math.max(h, FLOAT_MIN_H), Math.max(FLOAT_MIN_H, vh - float.y))),
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={clsx(css.floatWindow, dragging !== null && css.floatWindowDragging)}
|
||||
data-dsh-float-window
|
||||
data-dsh-float-id={float.id}
|
||||
style={{ left: float.x, top: float.y, width: float.w, height: float.h }}
|
||||
onPointerDown={() => { onRaise() }}
|
||||
>
|
||||
<div
|
||||
className={css.floatHeader}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0) return
|
||||
// Portaled overlays (the header Menu) live at document.body in the
|
||||
// DOM, yet their events still bubble through the React tree into
|
||||
// this handler — without the containment guard a menu-row click
|
||||
// would start a drag, preventDefault (killing the row's compat
|
||||
// mousedown) and capture the pointer (retargeting the release at
|
||||
// the header, so the row's click never fires). Buttons inside the
|
||||
// header (the close X) need the same exclusion: a capture here
|
||||
// swallows their click.
|
||||
if (!(event.target instanceof Node) || !event.currentTarget.contains(event.target)) return
|
||||
if (event.target instanceof Element && event.target.closest('button') !== null) return
|
||||
event.preventDefault()
|
||||
capturePointer(event.currentTarget, event.pointerId)
|
||||
dragRef.current = {
|
||||
mode: 'move',
|
||||
pointerX: event.clientX,
|
||||
pointerY: event.clientY,
|
||||
startX: float.x,
|
||||
startY: float.y,
|
||||
startW: float.w,
|
||||
startH: float.h,
|
||||
applied: { x: float.x, y: float.y, w: float.w, h: float.h },
|
||||
committed: false,
|
||||
}
|
||||
setDragging('move')
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
const drag = dragRef.current
|
||||
if (drag === null || !holdsPointer(event.currentTarget, event.pointerId)) return
|
||||
const geo = clampMove(drag.startX + (event.clientX - drag.pointerX), drag.startY + (event.clientY - drag.pointerY))
|
||||
scheduleApply(geo)
|
||||
const target = paneAt(event.clientX, event.clientY)
|
||||
if (target !== dockTargetRef.current) {
|
||||
clearDockHighlight()
|
||||
if (target !== null) target.setAttribute('data-dsh-float-dock-over', '')
|
||||
dockTargetRef.current = target
|
||||
}
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (dragRef.current === null || !holdsPointer(event.currentTarget, event.pointerId)) return
|
||||
releasePointer(event.currentTarget, event.pointerId)
|
||||
flushNow()
|
||||
const geo = clampMove(
|
||||
dragRef.current.startX + (event.clientX - dragRef.current.pointerX),
|
||||
dragRef.current.startY + (event.clientY - dragRef.current.pointerY),
|
||||
)
|
||||
finishDrag('move', geo, paneAt(event.clientX, event.clientY))
|
||||
}}
|
||||
onPointerCancel={() => { abortDrag() }}
|
||||
onLostPointerCapture={() => { abortDrag() }}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
setMenu({ x: event.clientX, y: event.clientY })
|
||||
}}
|
||||
>
|
||||
{getTabIcon?.(float.tab) ?? null}
|
||||
<span className={css.floatTitle} title={float.tab.title}>{float.tab.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.floatClose}
|
||||
aria-label={t('close')}
|
||||
onClick={(event) => { event.stopPropagation(); onClose() }}
|
||||
>
|
||||
<IconCloseFill14 />
|
||||
</button>
|
||||
<Menu
|
||||
open={menu !== null}
|
||||
onClose={() => { setMenu(null) }}
|
||||
items={[
|
||||
{ id: 'dock', label: t('dockToSidebar') },
|
||||
{ id: 'close', label: t('close') },
|
||||
]}
|
||||
onSelect={(id) => {
|
||||
setMenu(null)
|
||||
if (id === 'dock') onDock(null)
|
||||
else if (id === 'close') onClose()
|
||||
}}
|
||||
portal
|
||||
align="start"
|
||||
getAnchorRect={() => (menu === null ? null : new DOMRect(menu.x, menu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
/>
|
||||
</div>
|
||||
{/* The tab cell reuses .paneTab (the pane's per-tab wrapper): the
|
||||
column-flex chain (window → .floatContent → .paneTab) is what
|
||||
gives the component a definite width via align-stretch — the exact
|
||||
sizing contract a tab gets inside a pane. */}
|
||||
<div className={css.floatContent}>
|
||||
<div className={css.paneTab}>{renderTab(float.tab, true, float.id)}</div>
|
||||
</div>
|
||||
<div
|
||||
className={css.floatResize}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0) return
|
||||
if (!(event.target instanceof Node) || !event.currentTarget.contains(event.target)) return
|
||||
event.preventDefault()
|
||||
capturePointer(event.currentTarget, event.pointerId)
|
||||
dragRef.current = {
|
||||
mode: 'resize',
|
||||
pointerX: event.clientX,
|
||||
pointerY: event.clientY,
|
||||
startX: float.x,
|
||||
startY: float.y,
|
||||
startW: float.w,
|
||||
startH: float.h,
|
||||
applied: { x: float.x, y: float.y, w: float.w, h: float.h },
|
||||
committed: false,
|
||||
}
|
||||
setDragging('resize')
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
const drag = dragRef.current
|
||||
if (drag === null || !holdsPointer(event.currentTarget, event.pointerId)) return
|
||||
scheduleApply(clampResize(
|
||||
drag.startW + (event.clientX - drag.pointerX),
|
||||
drag.startH + (event.clientY - drag.pointerY),
|
||||
))
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (dragRef.current === null || !holdsPointer(event.currentTarget, event.pointerId)) return
|
||||
releasePointer(event.currentTarget, event.pointerId)
|
||||
flushNow()
|
||||
const geo = clampResize(
|
||||
dragRef.current.startW + (event.clientX - dragRef.current.pointerX),
|
||||
dragRef.current.startH + (event.clientY - dragRef.current.pointerY),
|
||||
)
|
||||
finishDrag('resize', geo, null)
|
||||
}}
|
||||
onPointerCancel={() => { abortDrag() }}
|
||||
onLostPointerCapture={() => { abortDrag() }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+212
-35
@@ -6,16 +6,18 @@
|
||||
* placed below the git pane on first use. File rows and history rows open a
|
||||
* right-click context menu with advanced operations (open in editor, discard,
|
||||
* revert, cherry-pick, copy paths/hashes). Refresh is manual + on mount/
|
||||
* focus (no file watcher — KISS).
|
||||
* focus. While visible it polls lightweight porcelain state so model-authored
|
||||
* file changes appear without a manual refresh.
|
||||
*/
|
||||
import { useCallback, useEffect, useState, type MouseEvent, type ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'
|
||||
import {
|
||||
Button, IconBranchOutline16, IconCodeOutline16, IconCopyOutline16, IconRefreshOutline16,
|
||||
IconTrashOutline16, Input, Menu, Modal, writeClipboard,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { GitLogEntry, GitStatusEntry, GitStatusResult, SessionScope } from './api.ts'
|
||||
import type { GitLogEntry, GitStatusEntry, GitStatusResult, GitWorktree, SessionScope } from './api.ts'
|
||||
import { api } from './api.ts'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { isWithinWorkspace, relativeTo } from './paths.ts'
|
||||
import { resolveSidebarPath } from './produced-files.ts'
|
||||
import { relativeTime, t } from './locales.ts'
|
||||
import type { SidebarTab } from './state.ts'
|
||||
import css from './sidebar.module.css'
|
||||
@@ -84,9 +86,14 @@ export function GitView(props: {
|
||||
onOpenFile: (path: string) => void
|
||||
/** Open a diff tab (the shell places it below the git pane on first use). */
|
||||
onOpenDiff: (tab: SidebarTab) => void
|
||||
/** Poll only while the tab is actually visible. */
|
||||
visible: boolean
|
||||
}) {
|
||||
const { scope, onOpenFile, onOpenDiff } = props
|
||||
const { scope, onOpenFile, onOpenDiff, visible } = props
|
||||
const [status, setStatus] = useState<GitStatusResult | null>(null)
|
||||
const [worktrees, setWorktrees] = useState<GitWorktree[]>([])
|
||||
const [selectedWorktree, setSelectedWorktree] = useState<string | undefined>()
|
||||
const [repoRoot, setRepoRoot] = useState<string | undefined>(undefined)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [branchNames, setBranchNames] = useState<string[]>([])
|
||||
@@ -104,70 +111,196 @@ export function GitView(props: {
|
||||
const [historyMenu, setHistoryMenu] = useState<{ entry: GitLogEntry; x: number; y: number } | null>(null)
|
||||
/** The pending destructive action awaiting confirmation. */
|
||||
const [confirm, setConfirm] = useState<ConfirmState | null>(null)
|
||||
const refreshInFlight = useRef(false)
|
||||
/** Monotonic request id: a manual worktree switch invalidates any older poll
|
||||
* before it can publish state from the previous checkout. */
|
||||
const refreshGeneration = useRef(0)
|
||||
const worktreeChosenByUser = useRef(false)
|
||||
/** selectedWorktree read inside refresh without re-creating the callback:
|
||||
* avoids a spurious full refresh on every auto-select (the very state
|
||||
* change refresh writes back via setSelectedWorktree would recreate the
|
||||
* callback and re-trigger the mount effect — an N→N+1 fetch loop). */
|
||||
const selectedRef = useRef<string | undefined>(undefined)
|
||||
useEffect(() => { selectedRef.current = selectedWorktree }, [selectedWorktree])
|
||||
|
||||
const refresh = useCallback(async (): Promise<void> => {
|
||||
setLoading(true)
|
||||
const gitScope: SessionScope = repoRoot === undefined ? scope : { ...scope, repoRoot }
|
||||
|
||||
/** Publish a complete checkout-derived view. Status, branch choices and
|
||||
* history are one consistency unit: never mix rows from two worktrees. */
|
||||
const refreshTarget = useCallback(async (
|
||||
target: string | undefined,
|
||||
options: { loading: boolean; generation: number },
|
||||
): Promise<void> => {
|
||||
if (options.loading) setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [statusResult, branchResult, logResult] = await Promise.all([
|
||||
api.gitStatus(scope),
|
||||
api.gitBranch(scope).catch(() => ({ current: '', names: [] as string[] })),
|
||||
// The first history page only; the rest arrives via "load more".
|
||||
api.gitLog(scope, LOG_BATCH, 0).catch(() => [] as GitLogEntry[]),
|
||||
api.gitStatus(gitScope, target),
|
||||
api.gitBranch(gitScope, target).catch(() => ({ current: '', names: [] as string[] })),
|
||||
api.gitLog(gitScope, LOG_BATCH, 0, target).catch(() => [] as GitLogEntry[]),
|
||||
])
|
||||
if (options.generation !== refreshGeneration.current) return
|
||||
setStatus(statusResult)
|
||||
if (statusResult.root !== undefined && statusResult.root !== repoRoot) setRepoRoot(statusResult.root)
|
||||
setBranchNames(branchResult.names)
|
||||
setLogEntries(logResult)
|
||||
setLogEnded(logResult.length < LOG_BATCH)
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : String(reason))
|
||||
if (options.generation === refreshGeneration.current) {
|
||||
setError(reason instanceof Error ? reason.message : String(reason))
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (options.loading && options.generation === refreshGeneration.current) setLoading(false)
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd])
|
||||
}, [scope.sessionId, scope.cwd, repoRoot])
|
||||
|
||||
const refresh = useCallback(async (silent = false): Promise<void> => {
|
||||
if (refreshInFlight.current) return
|
||||
refreshInFlight.current = true
|
||||
let generation = refreshGeneration.current
|
||||
try {
|
||||
const listed = await api.gitWorktrees(scope)
|
||||
if (generation !== refreshGeneration.current) return
|
||||
setWorktrees(listed)
|
||||
const selectedStillExists = listed.some(entry => entry.path === selectedRef.current)
|
||||
let target = selectedStillExists ? selectedRef.current : listed.find(entry => entry.current)?.path
|
||||
// DSH and other coding agents commonly create one linked checkout while
|
||||
// the session remains rooted at the clean primary checkout. Select that
|
||||
// checkout automatically only when the choice is unambiguous.
|
||||
const current = listed.find(entry => entry.current)
|
||||
const dirtyLinked = listed.filter(entry => !entry.current && entry.changes > 0)
|
||||
if (!worktreeChosenByUser.current) {
|
||||
target = (current?.changes ?? 0) === 0 && dirtyLinked.length === 1
|
||||
? dirtyLinked[0]!.path
|
||||
: current?.path
|
||||
}
|
||||
const targetChanged = target !== selectedRef.current
|
||||
if (targetChanged) {
|
||||
// Changing the automatically selected checkout invalidates any direct
|
||||
// target refresh that may still be resolving for the previous one.
|
||||
generation = refreshGeneration.current += 1
|
||||
selectedRef.current = target
|
||||
setSelectedWorktree(target)
|
||||
// Remove rows owned by the previous checkout immediately: keeping them
|
||||
// interactive while the target changes could apply a destructive action
|
||||
// to the new checkout with stale history from the old one.
|
||||
setStatus(null)
|
||||
setBranchNames([])
|
||||
setLogEntries([])
|
||||
setLogEnded(false)
|
||||
setLogLoadingMore(false)
|
||||
}
|
||||
// A poll may update status alone only while staying on the same checkout.
|
||||
// Any automatic selection change refreshes the complete derived view.
|
||||
if (silent && !targetChanged) {
|
||||
const statusResult = await api.gitStatus(gitScope, target)
|
||||
if (generation === refreshGeneration.current) setStatus(statusResult)
|
||||
return
|
||||
}
|
||||
await refreshTarget(target, { loading: !silent, generation })
|
||||
} catch (reason) {
|
||||
if (generation === refreshGeneration.current) {
|
||||
setError(reason instanceof Error ? reason.message : String(reason))
|
||||
if (!silent) setLoading(false)
|
||||
}
|
||||
} finally {
|
||||
refreshInFlight.current = false
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd, refreshTarget])
|
||||
|
||||
useEffect(() => {
|
||||
refreshGeneration.current += 1
|
||||
refreshInFlight.current = false
|
||||
worktreeChosenByUser.current = false
|
||||
selectedRef.current = undefined
|
||||
setSelectedWorktree(undefined)
|
||||
}, [scope.sessionId, scope.cwd])
|
||||
useEffect(() => { void refresh() }, [refresh])
|
||||
|
||||
/** A user choice invalidates any older poll and atomically refreshes every
|
||||
* checkout-derived surface before destructive history actions can run. */
|
||||
const chooseWorktree = (target: string): void => {
|
||||
worktreeChosenByUser.current = true
|
||||
selectedRef.current = target
|
||||
setSelectedWorktree(target)
|
||||
setStatus(null)
|
||||
setBranchNames([])
|
||||
setLogEntries([])
|
||||
setLogEnded(false)
|
||||
setLogLoadingMore(false)
|
||||
const generation = refreshGeneration.current += 1
|
||||
void refreshTarget(target, { loading: true, generation })
|
||||
}
|
||||
/** Switching the selected child repository must invalidate every
|
||||
* target-derived surface (status/history/log) before the asynchronous
|
||||
* refresh resolves; otherwise stale rows remain actionable while their
|
||||
* handlers already address the new repository. Mirrors chooseWorktree. */
|
||||
const chooseRepo = (target: string): void => {
|
||||
setRepoRoot(target)
|
||||
setStatus(null)
|
||||
setBranchNames([])
|
||||
setLogEntries([])
|
||||
setLogEnded(false)
|
||||
setLogLoadingMore(false)
|
||||
// Re-list worktrees for the selected child (a workspace container's
|
||||
// own worktree list is empty); keep the current linked-checkout choice
|
||||
// unless it does not belong to the new repository.
|
||||
const generation = refreshGeneration.current += 1
|
||||
void refreshTarget(selectedRef.current ?? '', { loading: true, generation })
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
const timer = window.setInterval(() => { void refresh(true) }, 2_000)
|
||||
return () => { window.clearInterval(timer) }
|
||||
}, [visible, refresh])
|
||||
|
||||
/** Append the next history page (lazy: only when the user asks for more). */
|
||||
const loadMoreLog = async (): Promise<void> => {
|
||||
if (logLoadingMore || logEnded) return
|
||||
const generation = refreshGeneration.current
|
||||
const target = selectedRef.current
|
||||
setLogLoadingMore(true)
|
||||
try {
|
||||
const next = await api.gitLog(scope, LOG_BATCH, logEntries.length)
|
||||
const next = await api.gitLog(gitScope, LOG_BATCH, logEntries.length, target)
|
||||
// A worktree switch clears the old history and increments generation.
|
||||
// Never append a late page from that checkout into the new one.
|
||||
if (generation !== refreshGeneration.current || target !== selectedRef.current) return
|
||||
setLogEntries(entries => [...entries, ...next])
|
||||
if (next.length < LOG_BATCH) setLogEnded(true)
|
||||
} catch (reason) {
|
||||
setCommitError(`${t('historyLoadError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
|
||||
if (generation === refreshGeneration.current && target === selectedRef.current) {
|
||||
setCommitError(`${t('historyLoadError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
|
||||
}
|
||||
} finally {
|
||||
setLogLoadingMore(false)
|
||||
if (generation === refreshGeneration.current && target === selectedRef.current) setLogLoadingMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** The diff tab for one changed file (one tab per path+side; same id = focused). */
|
||||
const openWorktreeDiff = (entry: GitStatusEntry, staged: boolean): void => {
|
||||
onOpenDiff({
|
||||
id: `diff:w:${staged ? 's' : 'u'}:${entry.path}`,
|
||||
id: `diff:w:${encodeURIComponent(selectedWorktree ?? '')}:${staged ? 's' : 'u'}:${entry.path}`,
|
||||
type: 'diff',
|
||||
title: baseName(entry.path),
|
||||
diff: { kind: 'worktree', path: entry.path, staged, untracked: isUntracked(entry) },
|
||||
diff: { kind: 'worktree', path: entry.path, staged, untracked: isUntracked(entry), worktree: selectedWorktree, repoRoot },
|
||||
})
|
||||
}
|
||||
|
||||
/** The diff tab for one commit (one tab per commit). */
|
||||
const openCommitDiff = (entry: GitLogEntry): void => {
|
||||
onOpenDiff({
|
||||
id: `diff:c:${entry.hashFull}`,
|
||||
id: `diff:c:${encodeURIComponent(selectedWorktree ?? '')}:${entry.hashFull}`,
|
||||
type: 'diff',
|
||||
title: `${entry.hash} ${entry.subject}`,
|
||||
diff: { kind: 'commit', hash: entry.hash, hashFull: entry.hashFull, subject: entry.subject },
|
||||
diff: { kind: 'commit', hash: entry.hash, hashFull: entry.hashFull, subject: entry.subject, worktree: selectedWorktree, repoRoot },
|
||||
})
|
||||
}
|
||||
|
||||
const stageEntry = async (entry: GitStatusEntry, staged: boolean): Promise<void> => {
|
||||
setBusy(true)
|
||||
try {
|
||||
if (staged) await api.gitUnstage(scope, entry.path)
|
||||
else await api.gitStage(scope, entry.path)
|
||||
if (staged) await api.gitUnstage(gitScope, entry.path, selectedWorktree)
|
||||
else await api.gitStage(gitScope, entry.path, selectedWorktree)
|
||||
await refresh()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
@@ -177,8 +310,8 @@ export function GitView(props: {
|
||||
const stageAll = async (staged: boolean): Promise<void> => {
|
||||
setBusy(true)
|
||||
try {
|
||||
if (staged) await api.gitUnstage(scope)
|
||||
else await api.gitStage(scope)
|
||||
if (staged) await api.gitUnstage(gitScope, undefined, selectedWorktree)
|
||||
else await api.gitStage(gitScope, undefined, selectedWorktree)
|
||||
await refresh()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
@@ -191,7 +324,7 @@ export function GitView(props: {
|
||||
setBusy(true)
|
||||
setCommitError(null)
|
||||
try {
|
||||
await api.gitCommit(scope, message)
|
||||
await api.gitCommit(gitScope, message, selectedWorktree)
|
||||
setCommitMsg('')
|
||||
await refresh()
|
||||
} catch (reason) {
|
||||
@@ -206,7 +339,7 @@ export function GitView(props: {
|
||||
setBusy(true)
|
||||
setCommitError(null)
|
||||
try {
|
||||
await api.gitCheckout(scope, branch)
|
||||
await api.gitCheckout(gitScope, branch, selectedWorktree)
|
||||
await refresh()
|
||||
} catch (reason) {
|
||||
setCommitError(`${t('checkoutError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
|
||||
@@ -280,7 +413,36 @@ export function GitView(props: {
|
||||
|
||||
return (
|
||||
<div className={css.git}>
|
||||
{worktrees.length > 1 && (
|
||||
<div className={css.gitWorktreeRow}>
|
||||
<span className={css.gitWorktreeLabel}>{t('worktree')}</span>
|
||||
<select
|
||||
className={css.gitBranchSelect}
|
||||
value={selectedWorktree ?? ''}
|
||||
title={selectedWorktree}
|
||||
disabled={busy}
|
||||
onChange={(event) => { chooseWorktree(event.target.value) }}
|
||||
>
|
||||
{worktrees.map(entry => (
|
||||
<option key={entry.path} value={entry.path}>
|
||||
{entry.branch} · {baseName(entry.path)} ({entry.changes})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className={css.gitHeader}>
|
||||
{(status?.repositories?.length ?? 0) > 1 && (
|
||||
<select
|
||||
className={css.gitBranchSelect}
|
||||
value={repoRoot ?? ''}
|
||||
title={repoRoot}
|
||||
onChange={(event) => { chooseRepo(event.target.value) }}
|
||||
disabled={busy}
|
||||
>
|
||||
{status!.repositories!.map(root => <option key={root} value={root}>{baseName(root)}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<select
|
||||
className={css.gitBranchSelect}
|
||||
value={status?.branch ?? ''}
|
||||
@@ -297,7 +459,7 @@ export function GitView(props: {
|
||||
title={t('refresh')}
|
||||
onClick={() => { void refresh() }}
|
||||
>
|
||||
<IconRefreshOutline16 />
|
||||
<IconRefreshOutline16 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -309,6 +471,9 @@ export function GitView(props: {
|
||||
|
||||
{status !== null && status.isRepo && (
|
||||
<>
|
||||
{status.truncated === true && (
|
||||
<div className={css.gitEmpty}>{t('statusTruncated')}</div>
|
||||
)}
|
||||
<div className={css.gitSection}>
|
||||
<div className={css.gitSectionHeader}>
|
||||
<span>{t('staged')} ({stagedEntries.length})</span>
|
||||
@@ -406,7 +571,13 @@ export function GitView(props: {
|
||||
open={fileMenu !== null}
|
||||
onClose={() => { setFileMenu(null) }}
|
||||
items={[
|
||||
{ id: 'open', label: t('openEditor'), icon: <IconCodeOutline16 size={14} /> },
|
||||
// A linked worktree outside the session workspace cannot be
|
||||
// opened in the editor: the host's workspace fence rejects
|
||||
// every path under it. Hide the action for that checkout so
|
||||
// the menu does not offer a no-op that confuses the user.
|
||||
...(fileMenu !== null && isWithinWorkspace(scope.cwd ?? '', resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, fileMenu.entry.path))
|
||||
? [{ id: 'open', label: t('openEditor'), icon: <IconCodeOutline16 size={14} /> }]
|
||||
: []),
|
||||
fileMenu?.staged === true
|
||||
? { id: 'stage', label: t('unstage'), icon: <IconTrashOutline16 size={14} /> }
|
||||
: { id: 'stage', label: t('stage'), icon: <IconBranchOutline16 size={14} /> },
|
||||
@@ -422,7 +593,13 @@ export function GitView(props: {
|
||||
if (target === null) return
|
||||
setFileMenu(null)
|
||||
if (id === 'open') {
|
||||
onOpenFile(target.entry.path)
|
||||
const resolved = resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, target.entry.path)
|
||||
// Defense-in-depth: the menu hides this action when the
|
||||
// resolved path escapes the session workspace, but a
|
||||
// racing repo switch could still reach here with a path
|
||||
// the host would reject. No-op in that case.
|
||||
if (!isWithinWorkspace(scope.cwd ?? '', resolved)) return
|
||||
onOpenFile(resolved)
|
||||
return
|
||||
}
|
||||
if (id === 'stage') {
|
||||
@@ -434,15 +611,15 @@ export function GitView(props: {
|
||||
title: t('discardTitle'),
|
||||
description: t('discardDesc', { path: target.entry.path }),
|
||||
confirmLabel: t('discard'),
|
||||
onConfirm: () => api.gitDiscard(scope, target.entry.path),
|
||||
onConfirm: () => api.gitDiscard(gitScope, target.entry.path, selectedWorktree),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (id === 'relative') {
|
||||
copy(relativeTo(scope.cwd ?? '', target.entry.path))
|
||||
copy(relativeTo(repoRoot ?? selectedWorktree ?? scope.cwd ?? '', target.entry.path))
|
||||
return
|
||||
}
|
||||
if (id === 'absolute') copy(target.entry.path)
|
||||
if (id === 'absolute') copy(resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, target.entry.path))
|
||||
}}
|
||||
portal
|
||||
align="start"
|
||||
@@ -488,7 +665,7 @@ export function GitView(props: {
|
||||
title: t('revertTitle'),
|
||||
description: t('revertDesc', { subject: target.entry.subject }),
|
||||
confirmLabel: t('revertCommit'),
|
||||
onConfirm: () => api.gitRevert(scope, target.entry.hashFull),
|
||||
onConfirm: () => api.gitRevert(gitScope, target.entry.hashFull, selectedWorktree),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -497,7 +674,7 @@ export function GitView(props: {
|
||||
title: t('cherryPickTitle'),
|
||||
description: t('cherryPickDesc', { subject: target.entry.subject }),
|
||||
confirmLabel: t('cherryPickCommit'),
|
||||
onConfirm: () => api.gitCherryPick(scope, target.entry.hashFull),
|
||||
onConfirm: () => api.gitCherryPick(gitScope, target.entry.hashFull, selectedWorktree),
|
||||
})
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* The markdown preview's raw-HTML renderer. `markdown-html.ts` lifts HTML
|
||||
* runs out of the markdown stream; this module renders them as sanitized DOM
|
||||
* alongside the markdown runs (which keep flowing through the shared
|
||||
* `MarkdownText`), nests markdown into unclosed block elements the way
|
||||
* GitHub's linear HTML output does (`<details>` … fence … `</details>`), and
|
||||
* runs an inline pass that turns literal tag text inside rendered markdown
|
||||
* (table cells with `<br/>`, `<sub>`, `<img>`) back into elements.
|
||||
*
|
||||
* Security posture: every HTML string (block leaves, inline text, wrapper
|
||||
* open-tag attributes) goes through DOMPurify with an explicit denylist on
|
||||
* top of its defaults (no script/style/iframe/forms), anchors are forced to
|
||||
* open in a new tab with noopener, and local media `src` attributes are
|
||||
* rewritten through the session-scoped `/sidebar/file` media route — the same
|
||||
* trust fence the markdown image rewriter (`markdown-images.ts`) uses.
|
||||
*/
|
||||
import { useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import { createElement, type ReactNode } from 'react'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ComponentType } from 'react'
|
||||
import { markdownTextProps } from './markdown-labels.tsx'
|
||||
import { lazyChunkComponent } from './lazy-chunk.tsx'
|
||||
import { resolveLocalMediaDest } from './markdown-images.ts'
|
||||
import {
|
||||
analyzeHtmlSegment,
|
||||
type AnalyzedMarkdownHtml,
|
||||
} from './markdown-html.ts'
|
||||
import { splitMermaidBlocks, type MermaidMarkdownProps } from './mermaid-blocks.ts'
|
||||
import type { SessionScope } from './api.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** The chunk-resident markdown renderer (mermaid lazy chunk), shared with the
|
||||
* legacy no-HTML preview path in TextEditor. */
|
||||
export const LazyMermaidMarkdown = lazyChunkComponent<MermaidMarkdownProps>(
|
||||
'mermaid',
|
||||
(mod) => mod.MermaidMarkdown as ComponentType<MermaidMarkdownProps> | undefined,
|
||||
)
|
||||
|
||||
/** Everything the sanitizers need to resolve local media + scope the route. */
|
||||
export interface MarkdownHtmlMedia {
|
||||
scope: SessionScope
|
||||
path: string
|
||||
origin: string
|
||||
}
|
||||
|
||||
/** Tag-like text in a rendered text node — the inline pass gate. */
|
||||
const TAGLIKE_TEXT_RE = /<\/?[a-zA-Z][a-zA-Z0-9-]*[\s/>]/
|
||||
|
||||
/** Explicit denylist on top of DOMPurify's defaults: no active content, no
|
||||
* form chrome, no document-level elements inside a preview. */
|
||||
const PURIFY_FORBID_TAGS = [
|
||||
'script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button',
|
||||
'select', 'textarea', 'meta', 'link', 'base', 'frame', 'frameset', 'applet',
|
||||
]
|
||||
const PURIFY_FORBID_ATTR = ['srcdoc', 'formaction']
|
||||
|
||||
/**
|
||||
* Post-sanitize hardening on a detached element tree: anchors open in a new
|
||||
* tab (never navigate the GUI), and local media sources go through the media
|
||||
* route so they render instead of being dropped by protocol allowlists.
|
||||
*/
|
||||
function postProcessSanitized(root: Element, media: MarkdownHtmlMedia): void {
|
||||
for (const anchor of root.querySelectorAll('a[href]')) {
|
||||
anchor.setAttribute('target', '_blank')
|
||||
anchor.setAttribute('rel', 'noopener noreferrer')
|
||||
}
|
||||
for (const element of root.querySelectorAll('img, video, audio, source')) {
|
||||
const src = element.getAttribute('src')
|
||||
if (src === null) continue
|
||||
element.setAttribute('src', resolveLocalMediaDest(src, media.scope, media.path, media.origin))
|
||||
}
|
||||
}
|
||||
|
||||
/** Sanitize one balanced HTML span into markup for dangerouslySetInnerHTML. */
|
||||
function sanitizeHtmlBlock(source: string, media: MarkdownHtmlMedia): string {
|
||||
const holder = document.createElement('div')
|
||||
holder.innerHTML = DOMPurify.sanitize(source, {
|
||||
FORBID_TAGS: PURIFY_FORBID_TAGS,
|
||||
FORBID_ATTR: PURIFY_FORBID_ATTR,
|
||||
})
|
||||
postProcessSanitized(holder, media)
|
||||
return holder.innerHTML
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize literal tag text from a rendered markdown text node. Returns null
|
||||
* when nothing real survived (pure prose like `a < b` — the DOMPurify output
|
||||
* has no element children), so the caller leaves the text node untouched.
|
||||
*/
|
||||
function sanitizeInlineHtml(text: string, media: MarkdownHtmlMedia): string | null {
|
||||
const holder = document.createElement('span')
|
||||
holder.innerHTML = DOMPurify.sanitize(text, {
|
||||
FORBID_TAGS: PURIFY_FORBID_TAGS,
|
||||
FORBID_ATTR: PURIFY_FORBID_ATTR,
|
||||
})
|
||||
if (holder.firstElementChild === null) return null
|
||||
postProcessSanitized(holder, media)
|
||||
return holder.innerHTML
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a wrapper open tag (`<details open>`) into React props. Returns
|
||||
* null when DOMPurify dropped the whole tag (denied element) — the renderer
|
||||
* then treats the wrapper as transparent. `class`/`for` map to their React
|
||||
* names; `style` is dropped (React needs an object; wrappers with inline
|
||||
* styles are vanishingly rare and not worth a CSS parser).
|
||||
*/
|
||||
function sanitizeTagProps(tag: string, attrs: string): Record<string, string> | null {
|
||||
const probe = DOMPurify.sanitize(`<${tag}${attrs}></${tag}>`, {
|
||||
FORBID_TAGS: PURIFY_FORBID_TAGS,
|
||||
FORBID_ATTR: PURIFY_FORBID_ATTR,
|
||||
})
|
||||
const holder = document.createElement('div')
|
||||
holder.innerHTML = probe
|
||||
const element = holder.firstElementChild
|
||||
if (element === null || element.tagName.toLowerCase() !== tag) return null
|
||||
const props: Record<string, string> = {}
|
||||
for (const attr of element.attributes) {
|
||||
if (/^on/i.test(attr.name) || !/^[a-zA-Z][a-zA-Z0-9:._-]*$/.test(attr.name)) continue
|
||||
if (attr.name === 'style') continue
|
||||
props[attr.name === 'class' ? 'className' : attr.name === 'for' ? 'htmlFor' : attr.name] = attr.value
|
||||
}
|
||||
return props
|
||||
}
|
||||
|
||||
/**
|
||||
* The inline pass: walk the rendered markdown's text nodes and swap any that
|
||||
* contain tag-like text for a sanitized `<span data-html-inline>`. Rendered
|
||||
* code (inline `code`, `pre`, the host `.md-code-block`, mermaid mounts, and
|
||||
* spans this pass already produced) is skipped. The same commit-then-operate
|
||||
* pattern the mermaid swap uses: React keeps owning the host tree, only leaf
|
||||
* text nodes are replaced, and a text change re-renders the subtree fresh.
|
||||
*/
|
||||
function runInlineHtmlPass(container: HTMLElement, media: MarkdownHtmlMedia): void {
|
||||
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: (node) => {
|
||||
const parent = (node as Text).parentElement
|
||||
if (parent === null) return NodeFilter.FILTER_REJECT
|
||||
if (parent.closest('code, pre, .md-code-block, [data-mermaid-processed], [data-html-inline]')) {
|
||||
return NodeFilter.FILTER_REJECT
|
||||
}
|
||||
return TAGLIKE_TEXT_RE.test((node as Text).data)
|
||||
? NodeFilter.FILTER_ACCEPT
|
||||
: NodeFilter.FILTER_REJECT
|
||||
},
|
||||
})
|
||||
const targets: Text[] = []
|
||||
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) targets.push(node as Text)
|
||||
for (const node of targets) {
|
||||
const html = sanitizeInlineHtml(node.data, media)
|
||||
if (html === null) continue
|
||||
const span = document.createElement('span')
|
||||
span.setAttribute('data-html-inline', '')
|
||||
span.innerHTML = html
|
||||
node.replaceWith(span)
|
||||
}
|
||||
}
|
||||
|
||||
interface MarkdownSegmentProps {
|
||||
text: string
|
||||
hasMermaid: boolean
|
||||
media: MarkdownHtmlMedia
|
||||
codeLabels: { copyLabel: string; copiedLabel: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* One markdown run of a split document: the shared MarkdownText pass (or the
|
||||
* mermaid chunk renderer when the run contains a mermaid fence), plus the
|
||||
* inline HTML pass. The pass runs after every text change and is re-armed by
|
||||
* a MutationObserver so it also catches content that appears late (the lazy
|
||||
* mermaid chunk mounting, shiki highlighting settling) — it is idempotent and
|
||||
* skips its own output, so mutation feedback settles after one extra pass.
|
||||
*/
|
||||
function MarkdownSegment({ text, hasMermaid, media, codeLabels }: MarkdownSegmentProps): ReactNode {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (container === null) return
|
||||
runInlineHtmlPass(container, media)
|
||||
let scheduled = false
|
||||
const observer = new MutationObserver(() => {
|
||||
if (scheduled) return
|
||||
scheduled = true
|
||||
queueMicrotask(() => {
|
||||
scheduled = false
|
||||
runInlineHtmlPass(container, media)
|
||||
})
|
||||
})
|
||||
observer.observe(container, { childList: true, subtree: true })
|
||||
return () => { observer.disconnect() }
|
||||
}, [text, media])
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
{hasMermaid
|
||||
? <LazyMermaidMarkdown text={text} codeLabels={codeLabels} />
|
||||
: <MarkdownText {...markdownTextProps(text, codeLabels)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A sanitized, balanced HTML span rendered as its own block. */
|
||||
function HtmlLeaf({ html }: { html: string }): ReactNode {
|
||||
return (
|
||||
<div className={css.editorHtmlBlock} data-dsh-html-segment dangerouslySetInnerHTML={{ __html: html }} />
|
||||
)
|
||||
}
|
||||
|
||||
/** One fully-prepared HTML run: sanitized leaves + wrapper opens/closes. */
|
||||
type PreparedHtmlPart =
|
||||
| { kind: 'html'; html: string }
|
||||
| { kind: 'open'; tag: string; props: Record<string, string> | null }
|
||||
| { kind: 'close' }
|
||||
|
||||
type PreparedSegment =
|
||||
| { kind: 'markdown'; text: string; hasMermaid: boolean }
|
||||
| { kind: 'html'; parts: PreparedHtmlPart[] }
|
||||
|
||||
interface MarkdownDocumentProps {
|
||||
info: AnalyzedMarkdownHtml
|
||||
media: MarkdownHtmlMedia
|
||||
codeLabels: { copyLabel: string; copiedLabel: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* The split-document renderer: markdown runs render through MarkdownSegment,
|
||||
* HTML runs render as sanitized leaves, and unclosed block elements lower the
|
||||
* following runs into themselves until their close part pops the frame (the
|
||||
* renderer's frame stack persists across segments). Stray closes at the top
|
||||
* level render nothing (the sanitizer/parser would drop them anyway), and
|
||||
* frames still open at the end of the document are closed like a browser
|
||||
* parser would. Sanitization runs once per prepared change, in a memo.
|
||||
*/
|
||||
export function MarkdownDocument({ info, media, codeLabels }: MarkdownDocumentProps): ReactNode {
|
||||
const prepared = useMemo<PreparedSegment[]>(() => info.segments.map((segment): PreparedSegment => {
|
||||
if (segment.kind === 'markdown') {
|
||||
const defs = info.referenceDefinitions
|
||||
const text = defs === '' ? segment.text : `${segment.text}\n\n${defs}`
|
||||
return {
|
||||
kind: 'markdown',
|
||||
text,
|
||||
hasMermaid: splitMermaidBlocks(text).some((block) => block.kind === 'mermaid'),
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: 'html',
|
||||
parts: analyzeHtmlSegment(segment.text).parts.map((part): PreparedHtmlPart => {
|
||||
if (part.kind === 'html') return { kind: 'html', html: sanitizeHtmlBlock(part.html, media) }
|
||||
if (part.kind === 'open') return { kind: 'open', tag: part.tag, props: sanitizeTagProps(part.tag, part.attrs) }
|
||||
return { kind: 'close' }
|
||||
}),
|
||||
}
|
||||
// `media` is a memoized object in the host (TextEditor); identity tracks
|
||||
// scope/path/origin changes so sanitization re-runs exactly when needed.
|
||||
}), [info, media])
|
||||
|
||||
const nodes: ReactNode[] = []
|
||||
const frames: { tag: string; props: Record<string, string> | null; children: ReactNode[] }[] = []
|
||||
const emit = (node: ReactNode): void => {
|
||||
const frame = frames[frames.length - 1]
|
||||
if (frame !== undefined) frame.children.push(node)
|
||||
else nodes.push(node)
|
||||
}
|
||||
let key = 0
|
||||
for (const segment of prepared) {
|
||||
if (segment.kind === 'markdown') {
|
||||
emit(<MarkdownSegment key={`md-${key += 1}`} text={segment.text} hasMermaid={segment.hasMermaid} media={media} codeLabels={codeLabels} />)
|
||||
continue
|
||||
}
|
||||
for (const part of segment.parts) {
|
||||
if (part.kind === 'html') {
|
||||
if (part.html.trim() === '') continue
|
||||
emit(<HtmlLeaf key={`html-${key += 1}`} html={part.html} />)
|
||||
} else if (part.kind === 'open') {
|
||||
frames.push({ tag: part.tag, props: part.props, children: [] })
|
||||
} else {
|
||||
const frame = frames.pop()
|
||||
if (frame === undefined) continue
|
||||
if (frame.props === null) {
|
||||
for (const child of frame.children) emit(child)
|
||||
} else {
|
||||
emit(createElement(frame.tag, { ...frame.props, key: `wrap-${key += 1}` }, ...frame.children))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
while (frames.length > 0) {
|
||||
const frame = frames.pop()!
|
||||
if (frame.props === null) {
|
||||
for (const child of frame.children) emit(child)
|
||||
} else {
|
||||
emit(createElement(frame.tag, { ...frame.props, key: `wrap-${key += 1}` }, ...frame.children))
|
||||
}
|
||||
}
|
||||
return <>{nodes}</>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+867
-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,645 @@
|
||||
/**
|
||||
* 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 { markdownTextProps } from './markdown-labels.tsx'
|
||||
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 {...markdownTextProps(row.text, labels)} />
|
||||
</div>
|
||||
)
|
||||
case 'assistant':
|
||||
return (
|
||||
<div key={`${row.kind}:${row.seq}`} className={css.sidechatAssistant}>
|
||||
<MarkdownText {...markdownTextProps(row.text, 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.get('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.get('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.get('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.get('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>
|
||||
)
|
||||
}
|
||||
+1200
-209
File diff suppressed because it is too large
Load Diff
@@ -274,15 +274,15 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* ── Background-task section (below the topology tree) ────────────────── */
|
||||
/* ── Background-job section (below the topology tree) ────────────────── */
|
||||
|
||||
.tasks {
|
||||
.jobs {
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid var(--dsw-alias-border-l2);
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.tasksHeader {
|
||||
.jobsHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -290,7 +290,7 @@
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.tasksTitle {
|
||||
.jobsTitle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font: var(--dsw-font-xxxs-strong-11);
|
||||
@@ -300,13 +300,13 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tasksCount {
|
||||
.jobsCount {
|
||||
flex: none;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.tasksList {
|
||||
.jobsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
@@ -315,30 +315,30 @@
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* One task row; the whole row lights up on hover, the expandable target
|
||||
/* 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). */
|
||||
.tasksRow {
|
||||
.jobsRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tasksRow:hover {
|
||||
.jobsRow:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.tasksRowSettled {
|
||||
.jobsRowSettled {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* The row whose output is shown in the bottom dock. */
|
||||
.tasksRowSelected,
|
||||
.tasksRowSelected:hover {
|
||||
.jobsRowSelected,
|
||||
.jobsRowSelected:hover {
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
}
|
||||
|
||||
.tasksRowMain {
|
||||
.jobsRowMain {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -355,15 +355,15 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tasksRowMain:focus-visible {
|
||||
.jobsRowMain:focus-visible {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.tasksDot {
|
||||
.jobsDot {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.tasksContent {
|
||||
.jobsContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
@@ -372,14 +372,14 @@
|
||||
|
||||
/* The command line: a kind badge followed by the command itself in the code
|
||||
face, like the terminal's live lines. */
|
||||
.tasksLabelLine {
|
||||
.jobsLabelLine {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tasksKind {
|
||||
.jobsKind {
|
||||
flex: none;
|
||||
max-width: 90px;
|
||||
overflow: hidden;
|
||||
@@ -393,7 +393,7 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.tasksLabel {
|
||||
.jobsLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -405,7 +405,7 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.tasksSecondary {
|
||||
.jobsSecondary {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -413,7 +413,7 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.tasksKill {
|
||||
.jobsKill {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -428,14 +428,14 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tasksKill:hover {
|
||||
.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. */
|
||||
.tasksKillArmed,
|
||||
.tasksKillArmed:hover {
|
||||
.jobsKillArmed,
|
||||
.jobsKillArmed:hover {
|
||||
width: auto;
|
||||
height: 20px;
|
||||
padding: 0 8px;
|
||||
@@ -445,12 +445,12 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tasksKill:disabled {
|
||||
.jobsKill:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tasksKillError {
|
||||
.jobsKillError {
|
||||
flex: none;
|
||||
margin-right: 4px;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
@@ -460,9 +460,9 @@
|
||||
/* ── 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 task list scrolls above it; a solid background keeps the
|
||||
edge while the job list scrolls above it; a solid background keeps the
|
||||
tree from showing through. */
|
||||
.tasksPane {
|
||||
.jobsPane {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
@@ -474,7 +474,7 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tasksPaneHeader {
|
||||
.jobsPaneHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
@@ -483,11 +483,11 @@
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
.tasksPaneDot {
|
||||
.jobsPaneDot {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.tasksPaneLabel {
|
||||
.jobsPaneLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -499,13 +499,13 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.tasksPaneStatus {
|
||||
.jobsPaneStatus {
|
||||
flex: none;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.tasksPaneClose {
|
||||
.jobsPaneClose {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -519,11 +519,11 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tasksPaneClose:hover {
|
||||
.jobsPaneClose:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.tasksPanePre {
|
||||
.jobsPanePre {
|
||||
margin: 0;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
@@ -536,12 +536,12 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tasksPaneHint {
|
||||
.jobsPaneHint {
|
||||
padding: 8px 10px;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.tasksPaneError {
|
||||
.jobsPaneError {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
+189
-172
@@ -33,24 +33,26 @@ import type {
|
||||
SidebarSubagentCatalog,
|
||||
SidebarSubagentChildEntry,
|
||||
SidebarSubagentDiagnosticEntry,
|
||||
SidebarTaskView,
|
||||
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 {
|
||||
collectTreeTasks,
|
||||
formatTaskDuration,
|
||||
isTaskLive,
|
||||
orderTasks,
|
||||
taskDotState,
|
||||
taskStatusLabel,
|
||||
type TreeTask,
|
||||
} from './subagent-tasks.ts'
|
||||
import { api, type TaskOutputResult } from './api.ts'
|
||||
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'
|
||||
@@ -59,18 +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 task-output panel while its task runs. */
|
||||
const TASK_POLL_MS = 2000
|
||||
/** 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 TASK_KILL_ARM_MS = 3000
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -145,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 (
|
||||
@@ -214,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
|
||||
@@ -222,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 && (
|
||||
@@ -253,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}>
|
||||
@@ -314,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 && (
|
||||
@@ -342,8 +360,7 @@ function CatalogRows({
|
||||
byId={byId}
|
||||
level={level + 1}
|
||||
currentSessionId={currentSessionId}
|
||||
active={active}
|
||||
ctx={ctx}
|
||||
live={live}
|
||||
openChild={openChild}
|
||||
refresh={refresh}
|
||||
/>
|
||||
@@ -358,24 +375,24 @@ function CatalogRows({
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared output dock of the tasks section: ONE pane at the bottom of the
|
||||
* sidebar body (sticky, terminal-like) shows the SELECTED task's output as
|
||||
* 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 TASK_POLL_MS} while the task runs and the
|
||||
* page is visible. The model's `task_output` cursor is never touched — the
|
||||
* 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 task. A single dock — not a panel per row — keeps the
|
||||
* task list compact and stable when many tasks are running.
|
||||
* 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 TaskOutputPane(props: {
|
||||
function JobOutputPane(props: {
|
||||
ownerSessionId: string
|
||||
task: SidebarTaskView
|
||||
job: SidebarJobView
|
||||
/** The page is visible (active tab + open panel): skip polling otherwise. */
|
||||
active: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { ownerSessionId, task, active, onClose } = props
|
||||
const [state, setState] = useState<'loading' | TaskOutputResult | 'error'>('loading')
|
||||
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)
|
||||
|
||||
@@ -384,44 +401,44 @@ function TaskOutputPane(props: {
|
||||
const controller = new AbortController()
|
||||
controllerRef.current = controller
|
||||
try {
|
||||
const result = await api.taskOutput({ sessionId: ownerSessionId }, task.id, controller.signal)
|
||||
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, task.id])
|
||||
}, [ownerSessionId, job.id])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
if (!active || !isTaskLive(task)) return
|
||||
const timer = window.setInterval(() => { void load() }, TASK_POLL_MS)
|
||||
if (!active || !isJobLive(job)) return
|
||||
const timer = window.setInterval(() => { void load() }, JOB_POLL_MS)
|
||||
return () => { window.clearInterval(timer) }
|
||||
}, [load, active, task.status])
|
||||
}, [load, active, job.status])
|
||||
|
||||
useEffect(() => () => { controllerRef.current?.abort() }, [])
|
||||
|
||||
// Terminal-tail behavior: while the task runs, each refresh pins the view
|
||||
// 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 (!isTaskLive(task) || typeof state !== 'object' || state.text.length === 0) return
|
||||
if (!isJobLive(job) || typeof state !== 'object' || state.text.length === 0) return
|
||||
const pre = preRef.current
|
||||
if (pre !== null) pre.scrollTop = pre.scrollHeight
|
||||
}, [state, task.status])
|
||||
}, [state, job.status])
|
||||
|
||||
return (
|
||||
<div className={css.tasksPane} role="region" aria-label={`${task.label} ${t('tasks')}`}>
|
||||
<div className={css.tasksPaneHeader}>
|
||||
<StateDot state={taskDotState(task.status)} className={css.tasksPaneDot} />
|
||||
<span className={css.tasksPaneLabel} title={task.label}>{task.label}</span>
|
||||
<span className={css.tasksPaneStatus}>
|
||||
{taskStatusLabel(task.status, t)}
|
||||
{task.detail !== undefined && task.detail !== '' ? ` · ${task.detail}` : ''}
|
||||
<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.tasksPaneClose}
|
||||
className={css.jobsPaneClose}
|
||||
aria-label={t('close')}
|
||||
title={t('close')}
|
||||
onClick={onClose}
|
||||
@@ -429,18 +446,18 @@ function TaskOutputPane(props: {
|
||||
<IconStopOutline16 size={10} />
|
||||
</button>
|
||||
</div>
|
||||
{state === 'loading' && <div className={css.tasksPaneHint}>{t('loading')}</div>}
|
||||
{state === 'loading' && <div className={css.jobsPaneHint}>{t('loading')}</div>}
|
||||
{state === 'error' && (
|
||||
<div className={`${css.tasksPaneHint} ${css.tasksPaneError}`}>{t('taskOutputError')}</div>
|
||||
<div className={`${css.jobsPaneHint} ${css.jobsPaneError}`}>{t('jobOutputError')}</div>
|
||||
)}
|
||||
{typeof state === 'object' && (
|
||||
<>
|
||||
{state.text.length > 0
|
||||
? <pre ref={preRef} className={css.tasksPanePre}>{state.text}</pre>
|
||||
? <pre ref={preRef} className={css.jobsPanePre}>{state.text}</pre>
|
||||
: state.read
|
||||
? <div className={css.tasksPaneHint}>{t('taskNoOutput')}</div>
|
||||
: <div className={css.tasksPaneHint}>{t('taskNotReadYet')}</div>}
|
||||
{state.truncated && <div className={css.tasksPaneHint}>{t('taskOutputTruncated')}</div>}
|
||||
? <div className={css.jobsPaneHint}>{t('jobNoOutput')}</div>
|
||||
: <div className={css.jobsPaneHint}>{t('jobNotReadYet')}</div>}
|
||||
{state.truncated && <div className={css.jobsPaneHint}>{t('jobOutputTruncated')}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -448,24 +465,24 @@ function TaskOutputPane(props: {
|
||||
}
|
||||
|
||||
/**
|
||||
* The background-task section of the Subagent page: every task of the whole
|
||||
* 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/tasks` push mirror. Clicking a row feeds its model-read output to
|
||||
* `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 tasks.
|
||||
* tree has no jobs.
|
||||
*/
|
||||
function TasksSection(props: {
|
||||
function JobsSection(props: {
|
||||
byId: SidebarSessionList['byId']
|
||||
tasksBySession: SidebarSessionList['tasksBySession']
|
||||
jobsBySession: SidebarSessionList['jobsBySession']
|
||||
rootId: string | undefined
|
||||
/** The page is visible (active tab + open panel): skip polling otherwise. */
|
||||
active: boolean
|
||||
}) {
|
||||
const { byId, tasksBySession, rootId, active } = props
|
||||
const { byId, jobsBySession, rootId, active } = props
|
||||
const rows = useMemo(
|
||||
() => orderTasks(collectTreeTasks(byId, tasksBySession, rootId)),
|
||||
[byId, tasksBySession, rootId],
|
||||
() => orderJobs(collectTreeJobs(byId, jobsBySession, rootId)),
|
||||
[byId, jobsBySession, rootId],
|
||||
)
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>(undefined)
|
||||
const [armedId, setArmedId] = useState<string | undefined>(undefined)
|
||||
@@ -475,12 +492,12 @@ function TasksSection(props: {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
const selectedRow = useMemo(
|
||||
() => (selectedId === undefined ? undefined : rows.find(row => row.task.id === selectedId)),
|
||||
() => (selectedId === undefined ? undefined : rows.find(row => row.job.id === selectedId)),
|
||||
[rows, selectedId],
|
||||
)
|
||||
|
||||
const liveCount = useMemo(
|
||||
() => rows.reduce((count, row) => count + (isTaskLive(row.task) ? 1 : 0), 0),
|
||||
() => rows.reduce((count, row) => count + (isJobLive(row.job) ? 1 : 0), 0),
|
||||
[rows],
|
||||
)
|
||||
const multiOwner = useMemo(
|
||||
@@ -491,7 +508,7 @@ function TasksSection(props: {
|
||||
// The kill button stays armed only briefly; a stray click must never kill.
|
||||
useEffect(() => {
|
||||
if (armedId === undefined) return
|
||||
const timer = window.setTimeout(() => { setArmedId(undefined) }, TASK_KILL_ARM_MS)
|
||||
const timer = window.setTimeout(() => { setArmedId(undefined) }, JOB_KILL_ARM_MS)
|
||||
return () => { window.clearTimeout(timer) }
|
||||
}, [armedId])
|
||||
|
||||
@@ -502,7 +519,7 @@ function TasksSection(props: {
|
||||
return () => { window.clearInterval(timer) }
|
||||
}, [liveCount])
|
||||
|
||||
// The docked output pane follows its task: when the selected task leaves
|
||||
// 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)
|
||||
@@ -511,13 +528,13 @@ function TasksSection(props: {
|
||||
// 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: TreeTask): Promise<void> => {
|
||||
setKillingId(row.task.id)
|
||||
const kill = useCallback(async (row: TreeJob): Promise<void> => {
|
||||
setKillingId(row.job.id)
|
||||
setKillErrorId(undefined)
|
||||
try {
|
||||
await api.taskKill({ sessionId: row.ownerSessionId }, row.task.id)
|
||||
await api.jobKill({ sessionId: row.ownerSessionId }, row.job.id)
|
||||
} catch {
|
||||
setKillErrorId(row.task.id)
|
||||
setKillErrorId(row.job.id)
|
||||
} finally {
|
||||
setKillingId(undefined)
|
||||
setArmedId(undefined)
|
||||
@@ -527,84 +544,84 @@ function TasksSection(props: {
|
||||
if (rows.length === 0) return null
|
||||
|
||||
const countLabel = liveCount > 0
|
||||
? t('tasksCountRunning', { count: rows.length, running: liveCount })
|
||||
: t('tasksCount', { count: rows.length })
|
||||
? t('jobsCountRunning', { count: rows.length, running: liveCount })
|
||||
: t('jobsCount', { count: rows.length })
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className={css.tasks} aria-label={t('tasks')}>
|
||||
<div className={css.tasksHeader}>
|
||||
<span className={css.tasksTitle}>{t('tasks')}</span>
|
||||
<span className={css.tasksCount}>{countLabel}</span>
|
||||
<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.tasksList} aria-label={t('tasks')}>
|
||||
<ul className={css.jobsList} aria-label={t('jobs')}>
|
||||
{rows.map((row) => {
|
||||
const { task } = row
|
||||
const live = isTaskLive(task)
|
||||
const selected = selectedId === task.id
|
||||
const armed = armedId === task.id
|
||||
const killing = killingId === task.id
|
||||
const killFailed = killErrorId === task.id
|
||||
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 - task.startedAt
|
||||
: (task.finishedAt ?? task.startedAt) - task.startedAt
|
||||
? now - job.startedAt
|
||||
: (job.finishedAt ?? job.startedAt) - job.startedAt
|
||||
const secondary = [
|
||||
...(multiOwner ? [row.ownerTitle] : []),
|
||||
taskStatusLabel(task.status, t),
|
||||
...(task.detail !== undefined && task.detail !== '' ? [task.detail] : []),
|
||||
formatTaskDuration(elapsed, t),
|
||||
jobStatusLabel(job.status, t),
|
||||
...(job.detail !== undefined && job.detail !== '' ? [job.detail] : []),
|
||||
formatJobDuration(elapsed, t),
|
||||
].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<li
|
||||
key={task.id}
|
||||
key={job.id}
|
||||
className={clsx(
|
||||
css.tasksRow,
|
||||
!live && css.tasksRowSettled,
|
||||
selected && css.tasksRowSelected,
|
||||
css.jobsRow,
|
||||
!live && css.jobsRowSettled,
|
||||
selected && css.jobsRowSelected,
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={css.tasksRowMain}
|
||||
className={css.jobsRowMain}
|
||||
aria-pressed={selected}
|
||||
aria-label={`${task.label} ${secondary}`}
|
||||
onClick={() => { setSelectedId(selected ? undefined : task.id) }}
|
||||
aria-label={`${job.label} ${secondary}`}
|
||||
onClick={() => { setSelectedId(selected ? undefined : job.id) }}
|
||||
>
|
||||
<StateDot state={taskDotState(task.status)} className={css.tasksDot} />
|
||||
<span className={css.tasksContent}>
|
||||
<span className={css.tasksLabelLine}>
|
||||
<span className={css.tasksKind}>{task.kind}</span>
|
||||
<span className={css.tasksLabel} title={task.label}>{task.label}</span>
|
||||
<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.tasksSecondary}>{secondary}</span>
|
||||
<span className={css.jobsSecondary}>{secondary}</span>
|
||||
</span>
|
||||
</button>
|
||||
{task.status === 'running' && (
|
||||
{job.status === 'running' && (
|
||||
<button
|
||||
type="button"
|
||||
className={armed ? `${css.tasksKill} ${css.tasksKillArmed}` : css.tasksKill}
|
||||
aria-label={armed ? t('taskKillConfirm') : t('taskKill')}
|
||||
title={armed ? t('taskKillConfirm') : t('taskKill')}
|
||||
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(task.id)
|
||||
else setArmedId(job.id)
|
||||
}}
|
||||
>
|
||||
{armed ? t('taskKillConfirm') : <IconStopOutline16 size={12} />}
|
||||
{armed ? t('jobKillConfirm') : <IconStopOutline16 size={12} />}
|
||||
</button>
|
||||
)}
|
||||
{killFailed && <span className={css.tasksKillError}>{t('taskKillError')}</span>}
|
||||
{killFailed && <span className={css.jobsKillError}>{t('jobKillError')}</span>}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
{selectedRow !== undefined && (
|
||||
<TaskOutputPane
|
||||
<JobOutputPane
|
||||
ownerSessionId={selectedRow.ownerSessionId}
|
||||
task={selectedRow.task}
|
||||
job={selectedRow.job}
|
||||
active={active}
|
||||
onClose={() => { setSelectedId(undefined) }}
|
||||
/>
|
||||
@@ -645,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>())
|
||||
@@ -839,8 +857,7 @@ export function SubagentView(props: {
|
||||
byId={byId}
|
||||
level={1}
|
||||
currentSessionId={sessionId}
|
||||
active={active}
|
||||
ctx={ctx}
|
||||
live={live}
|
||||
openChild={openChild}
|
||||
refresh={refresh}
|
||||
/>
|
||||
@@ -854,9 +871,9 @@ export function SubagentView(props: {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TasksSection
|
||||
<JobsSection
|
||||
byId={byId}
|
||||
tasksBySession={list.tasksBySession}
|
||||
jobsBySession={list.jobsBySession}
|
||||
rootId={rootId}
|
||||
active={active}
|
||||
/>
|
||||
|
||||
+190
-13
@@ -3,14 +3,20 @@
|
||||
* overflow scrolls horizontally, a close button per tab, a four-way split
|
||||
* button cluster, and the + menu that opens new tabs (explorer / git /
|
||||
* terminal). Tabs are draggable; dropping onto another tab inserts before it,
|
||||
* dropping on the strip background appends to this pane.
|
||||
* dropping on the strip background appends to this pane. Right-clicking a
|
||||
* tab opens the tab context menu (float as a free window / close / close
|
||||
* others / close to the left / close to the right, the close ones scoped 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,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SidebarTab } from './state.ts'
|
||||
import { isAgentTabId } from './state.ts'
|
||||
import { isPinnedVirtualTab } from './pinned.ts'
|
||||
import { IconPinOutline16 } from './icons.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
@@ -61,14 +67,86 @@ export function TabBar(props: {
|
||||
newTabOptions: NewTabOption[]
|
||||
/** Drop of a tab from any pane: (payload, insertBeforeTabId | null). */
|
||||
onDropTab: (payload: TabDragPayload, before: string | null) => void
|
||||
/** Float a tab out as a free window (the tab context menu's entry; the
|
||||
* drag-to-conversation gesture is handled at the Sidebar shell level). */
|
||||
onFloatTab: (tabId: string) => void
|
||||
/**
|
||||
* Pin/unpin a terminal tab (v0.17.0+). Called with `'workspace'` or
|
||||
* `'global'` to pin (the shell snapshots the home cwd), or `null` to
|
||||
* unpin. Non-terminal tabs never trigger this callback. Optional: the
|
||||
* menu hides the pin entry when unset (legacy callers).
|
||||
*/
|
||||
onPinTab?: (tabId: string, scope: 'workspace' | 'global' | 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, onFloatTab, onPinTab, getTabIcon, getTabBadge,
|
||||
} = props
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
// The tab right-click context menu: the target tab plus the cursor
|
||||
// position (the portaled Menu anchors there, following GitView/FileTree).
|
||||
const [tabMenu, setTabMenu] = useState<{ tabId: string; x: number; y: number } | null>(null)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
// The context target's index in the render-time tab snapshot; -1 when the
|
||||
// tab disappeared since the menu opened (the menu hides then).
|
||||
const tabMenuIndex = tabMenu === null ? -1 : tabs.findIndex(tab => tab.id === tabMenu.tabId)
|
||||
|
||||
// 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,14 +182,16 @@ export function TabBar(props: {
|
||||
if (payload !== null) onDropTab(payload, null)
|
||||
}}
|
||||
>
|
||||
<div className={css.tabList}>
|
||||
{tabs.map(tab => (
|
||||
<div ref={listRef} className={css.tabList}>
|
||||
{tabs.map(tab => {
|
||||
const pinned = isPinnedVirtualTab(tab) || tab.pin !== undefined
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={clsx(css.tab, active === tab.id && css.tabActive)}
|
||||
className={clsx(css.tab, active === tab.id && css.tabActive, pinned && css.pinnedTab)}
|
||||
title={tab.title}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
draggable={!pinned}
|
||||
onDragStart={pinned ? undefined : (event) => {
|
||||
setTabDragging(true)
|
||||
event.dataTransfer.setData(TAB_DRAG_TYPE, serializeDrag({ tabId: tab.id, paneId }))
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
@@ -119,6 +199,7 @@ export function TabBar(props: {
|
||||
onDragEnd={() => { setTabDragging(false); setDragOver(false) }}
|
||||
onDragOver={(event) => { event.preventDefault(); event.stopPropagation() }}
|
||||
onDrop={(event) => {
|
||||
if (pinned) { event.stopPropagation(); return }
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setTabDragging(false)
|
||||
@@ -127,15 +208,29 @@ 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 }
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) => {
|
||||
// Take over the browser menu: the tab context menu offers the
|
||||
// close operations for this pane. Opening it also dismisses
|
||||
// the + menu (only one menu at a time).
|
||||
event.preventDefault()
|
||||
setMenuOpen(false)
|
||||
setTabMenu({ tabId: tab.id, x: event.clientX, y: event.clientY })
|
||||
}}
|
||||
>
|
||||
{pinned && <IconPinOutline16 size={16} />}
|
||||
{getTabIcon?.(tab) ?? null}
|
||||
{getTabBadge?.(tab) ?? null}
|
||||
<span className={css.tabTitle}>{tab.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -149,7 +244,8 @@ export function TabBar(props: {
|
||||
<IconCloseFill14 />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
{/*
|
||||
The + sits immediately after the rightmost tab (sticky at the
|
||||
right edge of the scrollport when the tabs overflow, so it stays
|
||||
@@ -176,12 +272,93 @@ export function TabBar(props: {
|
||||
className={css.tabBarPlus}
|
||||
aria-label={t('newTab')}
|
||||
title={t('newTab')}
|
||||
onClick={() => { setMenuOpen(v => !v) }}
|
||||
onClick={() => { setMenuOpen(v => !v); setTabMenu(null) }}
|
||||
>
|
||||
<IconPlusOutline16 />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
{/*
|
||||
The tab context menu, positioned at the right-click cursor (portal
|
||||
so the panel's overflow clip cannot crop it). Close operations are
|
||||
scoped to THIS pane: "close others/left/right" walk the render-time
|
||||
tab snapshot and reuse the per-tab onClose path (which routes
|
||||
through the service and releases terminals), so the target tab is
|
||||
never closed and the pane never empties mid-loop.
|
||||
*/}
|
||||
<Menu
|
||||
open={tabMenu !== null && tabMenuIndex >= 0}
|
||||
onClose={() => { setTabMenu(null) }}
|
||||
items={(() => {
|
||||
// The target tab drives the pin entry's shape: terminal tabs
|
||||
// get either a "Pin ▸" submenu (unpinned) or a single "Unpin"
|
||||
// row (pinned). Non-terminal tabs and missing onPinTab get no
|
||||
// pin entry at all — the menu stays exactly the legacy 5-item
|
||||
// shape. Pinned VIRTUAL tabs (injected from other sessions)
|
||||
// get a stripped menu: only Unpin + Close (no float, no
|
||||
// close-others/left/right — those are pane-scoped operations
|
||||
// that don't apply to cross-session virtual tabs).
|
||||
const targetTab = tabMenuIndex >= 0 ? tabs[tabMenuIndex] : undefined
|
||||
const isTerminal = targetTab?.type === 'terminal'
|
||||
const isPinnedVirtual = targetTab !== undefined && isPinnedVirtualTab(targetTab)
|
||||
const pinEntries = isTerminal && onPinTab !== undefined
|
||||
? targetTab!.pin !== undefined
|
||||
? [{ id: 'unpin', label: t('unpinTerminal') }]
|
||||
: [{
|
||||
id: 'pin',
|
||||
label: isAgentTabId(targetTab!.id) ? t('pinAgentTerminal') : t('pinTerminal'),
|
||||
submenu: [
|
||||
{ id: 'pinWorkspace', label: t('pinToWorkspace') },
|
||||
{ id: 'pinGlobal', label: t('pinToGlobal') },
|
||||
],
|
||||
}]
|
||||
: []
|
||||
if (isPinnedVirtual) {
|
||||
return [
|
||||
...pinEntries,
|
||||
{ id: 'close', label: t('close') },
|
||||
]
|
||||
}
|
||||
return [
|
||||
{ id: 'float', label: t('moveToFreeWindow') },
|
||||
...pinEntries,
|
||||
{ id: 'close', label: t('close') },
|
||||
{ id: 'closeOthers', label: t('closeOtherTabs'), ...(tabs.length <= 1 ? { disabled: true } : {}) },
|
||||
{ id: 'closeLeft', label: t('closeLeftTabs'), ...(tabMenuIndex <= 0 ? { disabled: true } : {}) },
|
||||
{ id: 'closeRight', label: t('closeRightTabs'), ...(tabMenuIndex >= tabs.length - 1 ? { disabled: true } : {}) },
|
||||
]
|
||||
})()}
|
||||
onSelect={(id) => {
|
||||
const target = tabMenu
|
||||
if (target === null) return
|
||||
setTabMenu(null)
|
||||
const index = tabs.findIndex(tab => tab.id === target.tabId)
|
||||
if (index < 0) return
|
||||
if (id === 'float') {
|
||||
onFloatTab(target.tabId)
|
||||
} else if (id === 'pinWorkspace') {
|
||||
onPinTab?.(target.tabId, 'workspace')
|
||||
} else if (id === 'pinGlobal') {
|
||||
onPinTab?.(target.tabId, 'global')
|
||||
} else if (id === 'unpin') {
|
||||
onPinTab?.(target.tabId, null)
|
||||
} else if (id === 'close') {
|
||||
onClose(target.tabId)
|
||||
} else if (id === 'closeOthers') {
|
||||
for (const tab of tabs) {
|
||||
if (tab.id !== target.tabId) onClose(tab.id)
|
||||
}
|
||||
} else if (id === 'closeLeft') {
|
||||
for (const tab of tabs.slice(0, index)) onClose(tab.id)
|
||||
} else if (id === 'closeRight') {
|
||||
for (const tab of tabs.slice(index + 1)) onClose(tab.id)
|
||||
}
|
||||
}}
|
||||
portal
|
||||
align="start"
|
||||
getAnchorRect={() => (tabMenu === null ? null : new DOMRect(tabMenu.x, tabMenu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+215
-24
@@ -8,30 +8,61 @@
|
||||
* 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 {
|
||||
buildTerminalLinks,
|
||||
shouldActivateTerminalLink,
|
||||
openTerminalUrl,
|
||||
} from './terminal-links.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 +90,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 +113,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 +134,41 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
||||
})
|
||||
const fit = new FitAddon()
|
||||
term.loadAddon(fit)
|
||||
term.open(host)
|
||||
fit.fit()
|
||||
// Ctrl+Click (Cmd+Click on mac) opens http(s) URLs printed in the
|
||||
// pty stream — a plain click is left for xterm's text-selection
|
||||
// gesture. Only http(s) is dispatched; file:// / mailto: / etc. are
|
||||
// underlined for visibility but rejected at activation. See
|
||||
// terminal-links.ts for the line scanner, modifier gate and scheme
|
||||
// guard.
|
||||
const linkProvider = term.registerLinkProvider({
|
||||
provideLinks: (lineNumber, callback) => {
|
||||
// xterm's `provideLinks` hands us a 1-based buffer line number
|
||||
// (its own built-in ILinkProvider does `buffer.lines.get(e - 1)`,
|
||||
// i.e. the public `bufferLineNumber` is 1-based while `getLine`
|
||||
// takes a 0-based index). Passing `lineNumber` straight through
|
||||
// would fetch the row *below* the one xterm asked us to scan, so
|
||||
// the URL text would come from the wrong row while `range.y` still
|
||||
// pointed at the requested row — links landed one line too high.
|
||||
const line = term.buffer.active.getLine(lineNumber - 1)
|
||||
if (line === undefined) {
|
||||
callback(undefined)
|
||||
return
|
||||
}
|
||||
const descriptors = buildTerminalLinks(line.translateToString(true), lineNumber)
|
||||
if (descriptors.length === 0) {
|
||||
callback(undefined)
|
||||
return
|
||||
}
|
||||
callback(descriptors.map(descriptor => ({
|
||||
range: descriptor.range,
|
||||
text: descriptor.text,
|
||||
activate: (event) => {
|
||||
if (!shouldActivateTerminalLink(event)) return
|
||||
openTerminalUrl(descriptor.text)
|
||||
},
|
||||
})))
|
||||
},
|
||||
})
|
||||
// Re-theme in place when the app's scheme flips (tokens + palette).
|
||||
const applyTheme = (): void => {
|
||||
term.options.theme = xtermTheme()
|
||||
@@ -148,6 +222,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,27 +278,82 @@ 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()
|
||||
linkProvider.dispose()
|
||||
term.dispose()
|
||||
connectRef.current = null
|
||||
}
|
||||
@@ -213,6 +361,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 +377,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>
|
||||
)
|
||||
}
|
||||
|
||||
+239
-7
@@ -7,26 +7,47 @@
|
||||
* 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 { createPortal } from 'react-dom'
|
||||
import clsx from 'clsx'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view'
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { markdownTextProps } from './markdown-labels.tsx'
|
||||
import { api, htmlUrl } from './api.ts'
|
||||
import { rewriteLocalImageUrls } from './markdown-images.ts'
|
||||
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 { analyzeMarkdownHtml } from './markdown-html.ts'
|
||||
import { LazyMermaidMarkdown, MarkdownDocument, type MarkdownHtmlMedia } from './MarkdownHtml.tsx'
|
||||
import { MdToc } from './md-toc.tsx'
|
||||
import { splitMermaidBlocks } 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 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 +58,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 +71,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 +110,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 +152,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 +219,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 +244,78 @@ 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 ?? ''
|
||||
/** The preview source: `mdText` with local image destinations rewritten to
|
||||
* absolute media URLs (see {@link rewriteLocalImageUrls}); the raw
|
||||
* `mdText` stays untouched for selection/line lookup and for mermaid-block
|
||||
* detection, which are unaffected by image syntax. */
|
||||
const previewText = markdown
|
||||
? rewriteLocalImageUrls(mdText, scope, path, window.location.origin)
|
||||
: mdText
|
||||
/** 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],
|
||||
)
|
||||
/** Raw-HTML analysis (block runs lifted out + inline gate). Non-null only
|
||||
* for documents that actually contain HTML — plain markdown keeps the
|
||||
* legacy single-pass render path below, byte-for-byte. */
|
||||
const htmlInfo = useMemo(
|
||||
() => (markdown && mode === 'preview' ? analyzeMarkdownHtml(mdText) : null),
|
||||
[markdown, mode, mdText],
|
||||
)
|
||||
const hasMermaid = useMemo(
|
||||
() => htmlInfo !== null
|
||||
? htmlInfo.segments.some((segment) => segment.kind === 'markdown'
|
||||
&& splitMermaidBlocks(segment.text).some((block) => block.kind === 'mermaid'))
|
||||
: mdBlocks.some((block) => block.kind === 'mermaid'),
|
||||
[htmlInfo, mdBlocks],
|
||||
)
|
||||
/** The media context for the split renderer (local-src rewriting inside
|
||||
* sanitized HTML). Memoized on primitives: MarkdownDocument sanitizes per
|
||||
* `media` identity, so a fresh object per render would re-sanitize every
|
||||
* keystroke. */
|
||||
const htmlMedia = useMemo<MarkdownHtmlMedia>(
|
||||
() => ({ scope, path, origin: window.location.origin }),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[scope.sessionId, scope.cwd, path],
|
||||
)
|
||||
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 +328,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 +386,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 +397,33 @@ 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. Plain markdown (no HTML)
|
||||
renders exactly as before — one MarkdownText pass for the whole
|
||||
document, or the mermaid lazy chunk (single markdown parse;
|
||||
cross-fence references/footnotes stay intact) when a mermaid
|
||||
fence exists. Documents containing HTML (block runs or inline
|
||||
tags) render through the split document renderer: markdown runs
|
||||
keep the MarkdownText/mermaid path while raw-HTML runs render
|
||||
as sanitized DOM (see markdown-html.tsx). */}
|
||||
{/* The outline button rides on top of the preview scroll container
|
||||
(sticky, zero-height — first child so it pins from the very
|
||||
top) once the document has enough headings. */}
|
||||
<MdToc />
|
||||
{htmlInfo !== null
|
||||
? <MarkdownDocument info={htmlInfo} media={htmlMedia} codeLabels={codeLabels} />
|
||||
: hasMermaid
|
||||
? <LazyMermaidMarkdown text={previewText} codeLabels={codeLabels} />
|
||||
: <MarkdownText {...markdownTextProps(previewText, codeLabels)} />}
|
||||
</div>
|
||||
)}
|
||||
{html && mode === 'preview' && (
|
||||
@@ -231,6 +449,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,275 @@
|
||||
/**
|
||||
* 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 type { OpenWithTarget } from './open-with.ts'
|
||||
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[]
|
||||
revealed: 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
|
||||
/** The "open with" menu surface (passed through to FileTree; absent →
|
||||
* the whole section is hidden). */
|
||||
openWithTargets?: OpenWithTarget[]
|
||||
openWithPinned?: string[]
|
||||
openWithSsh?: boolean
|
||||
onOpenWith?: (targetId: string, path: string) => void
|
||||
onToggleOpenWithPin?: (targetId: 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, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, 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}
|
||||
revealed={revealed}
|
||||
onToggle={onToggle}
|
||||
onOpenFile={onOpenFile}
|
||||
onOpenFileNewTab={onOpenFileNewTab}
|
||||
onOpenFileSide={onOpenFileSide}
|
||||
openWithTargets={openWithTargets}
|
||||
openWithPinned={openWithPinned}
|
||||
openWithSsh={openWithSsh}
|
||||
onOpenWith={onOpenWith}
|
||||
onToggleOpenWithPin={onToggleOpenWithPin}
|
||||
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>
|
||||
)
|
||||
}
|
||||
+185
-43
@@ -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). */
|
||||
@@ -38,6 +44,19 @@ export interface GitStatusResult {
|
||||
isRepo: boolean
|
||||
branch?: string
|
||||
entries: GitStatusEntry[]
|
||||
/** True when the host capped `entries` (huge untracked set); the panel
|
||||
* shows a truncation notice instead of freezing (#369). */
|
||||
truncated?: boolean
|
||||
root?: string
|
||||
repositories?: string[]
|
||||
}
|
||||
|
||||
/** One linked Git checkout. */
|
||||
export interface GitWorktree {
|
||||
path: string
|
||||
branch: string
|
||||
current: boolean
|
||||
changes: number
|
||||
}
|
||||
|
||||
/** One git log row. */
|
||||
@@ -61,20 +80,38 @@ export interface FsTextResult { kind: 'text'; content: string; truncated: boolea
|
||||
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string }
|
||||
|
||||
/**
|
||||
* One tasks.output response: the output the MODEL has read so far for the
|
||||
* task (replayed from the owner session's event log — the model's
|
||||
* task_output cursor is never touched, so the pane can never steal the
|
||||
* 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
|
||||
* task_output for the task.
|
||||
* job_output for the job.
|
||||
*/
|
||||
export interface TaskOutputResult {
|
||||
export interface JobOutputResult {
|
||||
text: string
|
||||
/** True when the host capped the text at its output limit. */
|
||||
truncated: boolean
|
||||
/** Whether the model has read the task at least once. */
|
||||
/** 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 {
|
||||
@@ -98,16 +135,68 @@ 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
|
||||
/** The session's working directory from the client list summary (optional). */
|
||||
cwd?: string
|
||||
/** Selected Git repository when cwd is a workspace container. */
|
||||
repoRoot?: string
|
||||
}
|
||||
|
||||
/** Fold a scope into a JSON payload ({cwd} only when present). */
|
||||
function scopePayload(scope: SessionScope, extra: Record<string, unknown>): Record<string, unknown> {
|
||||
return { sessionId: scope.sessionId, ...(scope.cwd !== undefined && scope.cwd !== '' ? { cwd: scope.cwd } : {}), ...extra }
|
||||
return {
|
||||
sessionId: scope.sessionId,
|
||||
...(scope.cwd !== undefined && scope.cwd !== '' ? { cwd: scope.cwd } : {}),
|
||||
...(scope.repoRoot !== undefined && scope.repoRoot !== '' ? { repoRoot: scope.repoRoot } : {}),
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
/** Add a linked-worktree selection to a scoped Git request. The host validates
|
||||
* membership before using it as a command cwd. */
|
||||
function gitPayload(scope: SessionScope, worktree: string | undefined, extra: Record<string, unknown>): Record<string, unknown> {
|
||||
return scopePayload(scope, { ...(worktree !== undefined && worktree !== '' ? { worktree } : {}), ...extra })
|
||||
}
|
||||
|
||||
/** The sidebar API surface (session scope threaded through every call). */
|
||||
@@ -116,42 +205,52 @@ 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 })),
|
||||
gitStatus: (scope: SessionScope, signal?: AbortSignal) =>
|
||||
call<GitStatusResult>('git.status', scopePayload(scope, {}), signal),
|
||||
gitDiff: (scope: SessionScope, path: string | undefined, staged: boolean, signal?: AbortSignal) =>
|
||||
call<{ diff: string }>('git.diff', scopePayload(scope, { ...(path !== undefined ? { path } : {}), staged }), signal),
|
||||
gitStage: (scope: SessionScope, path?: string) =>
|
||||
call<{ ok: true }>('git.stage', scopePayload(scope, { ...(path !== undefined ? { path } : {}) })),
|
||||
gitUnstage: (scope: SessionScope, path?: string) =>
|
||||
call<{ ok: true }>('git.unstage', scopePayload(scope, { ...(path !== undefined ? { path } : {}) })),
|
||||
gitCommit: (scope: SessionScope, message: string) =>
|
||||
call<{ ok: true }>('git.commit', scopePayload(scope, { message })),
|
||||
gitBranch: (scope: SessionScope, signal?: AbortSignal) =>
|
||||
call<{ current: string; names: string[] }>('git.branch', scopePayload(scope, {}), signal),
|
||||
gitCheckout: (scope: SessionScope, branch: string) =>
|
||||
call<{ ok: true }>('git.checkout', scopePayload(scope, { branch })),
|
||||
/** 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),
|
||||
gitWorktrees: (scope: SessionScope, signal?: AbortSignal) =>
|
||||
call<GitWorktree[]>('git.worktrees', scopePayload(scope, {}), signal),
|
||||
gitStatus: (scope: SessionScope, worktree?: string, signal?: AbortSignal) =>
|
||||
call<GitStatusResult>('git.status', gitPayload(scope, worktree, {}), signal),
|
||||
gitDiff: (scope: SessionScope, path: string | undefined, staged: boolean, worktree?: string, signal?: AbortSignal) =>
|
||||
call<{ diff: string }>('git.diff', gitPayload(scope, worktree, { ...(path !== undefined ? { path } : {}), staged }), signal),
|
||||
gitStage: (scope: SessionScope, path?: string, worktree?: string) =>
|
||||
call<{ ok: true }>('git.stage', gitPayload(scope, worktree, { ...(path !== undefined ? { path } : {}) })),
|
||||
gitUnstage: (scope: SessionScope, path?: string, worktree?: string) =>
|
||||
call<{ ok: true }>('git.unstage', gitPayload(scope, worktree, { ...(path !== undefined ? { path } : {}) })),
|
||||
gitCommit: (scope: SessionScope, message: string, worktree?: string) =>
|
||||
call<{ ok: true }>('git.commit', gitPayload(scope, worktree, { message })),
|
||||
gitBranch: (scope: SessionScope, worktree?: string, signal?: AbortSignal) =>
|
||||
call<{ current: string; names: string[] }>('git.branch', gitPayload(scope, worktree, {}), signal),
|
||||
gitCheckout: (scope: SessionScope, branch: string, worktree?: string) =>
|
||||
call<{ ok: true }>('git.checkout', gitPayload(scope, worktree, { branch })),
|
||||
/** Recent commit history, lazily pageable (skip/count; defaults 0/30). */
|
||||
gitLog: (scope: SessionScope, count?: number, skip?: number, signal?: AbortSignal) =>
|
||||
call<GitLogEntry[]>('git.log', scopePayload(scope, {
|
||||
gitLog: (scope: SessionScope, count?: number, skip?: number, worktree?: string, signal?: AbortSignal) =>
|
||||
call<GitLogEntry[]>('git.log', gitPayload(scope, worktree, {
|
||||
...(count !== undefined ? { count } : {}),
|
||||
...(skip !== undefined ? { skip } : {}),
|
||||
}), signal),
|
||||
/** Full patch text of one commit (diff display for the history rows). */
|
||||
gitCommitDiff: (scope: SessionScope, hash: string, signal?: AbortSignal) =>
|
||||
call<{ diff: string }>('git.commit-diff', scopePayload(scope, { hash }), signal),
|
||||
gitCommitDiff: (scope: SessionScope, hash: string, worktree?: string, signal?: AbortSignal) =>
|
||||
call<{ diff: string }>('git.commit-diff', gitPayload(scope, worktree, { hash }), signal),
|
||||
/** Discard the worktree changes of one file (the index is untouched). */
|
||||
gitDiscard: (scope: SessionScope, path: string) =>
|
||||
call<{ ok: true }>('git.discard', scopePayload(scope, { path })),
|
||||
gitDiscard: (scope: SessionScope, path: string, worktree?: string) =>
|
||||
call<{ ok: true }>('git.discard', gitPayload(scope, worktree, { path })),
|
||||
/** Revert one commit onto the current branch. */
|
||||
gitRevert: (scope: SessionScope, hash: string) =>
|
||||
call<{ ok: true }>('git.revert', scopePayload(scope, { hash })),
|
||||
gitRevert: (scope: SessionScope, hash: string, worktree?: string) =>
|
||||
call<{ ok: true }>('git.revert', gitPayload(scope, worktree, { hash })),
|
||||
/** Cherry-pick one commit onto the current branch. */
|
||||
gitCherryPick: (scope: SessionScope, hash: string) =>
|
||||
call<{ ok: true }>('git.cherry-pick', scopePayload(scope, { hash })),
|
||||
gitCherryPick: (scope: SessionScope, hash: string, worktree?: string) =>
|
||||
call<{ ok: true }>('git.cherry-pick', gitPayload(scope, worktree, { hash })),
|
||||
/** Release a terminal's process immediately (tab closed; the WS close frame
|
||||
* may be unreachable while the socket is down, so the host also accepts
|
||||
* this explicit route). */
|
||||
@@ -160,22 +259,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 task (replayed
|
||||
* from the owner session's event log — never the model's task_output
|
||||
* cursor). The scope MUST be the task's OWNER session.
|
||||
* 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.
|
||||
*/
|
||||
taskOutput: (scope: SessionScope, id: string, signal?: AbortSignal) =>
|
||||
call<TaskOutputResult>('tasks.output', scopePayload(scope, { id }), signal),
|
||||
/** Request cancellation of one background task (live tasks flip to stopping). */
|
||||
taskKill: (scope: SessionScope, id: string, reason?: string) =>
|
||||
call<{ ok: true; outcome: 'requested' | 'already-finished' }>('tasks.kill', scopePayload(scope, {
|
||||
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', {
|
||||
@@ -186,6 +317,12 @@ export const api = {
|
||||
* check; see the host's browser.probe route). */
|
||||
browserProbe: (url: string, signal?: AbortSignal) =>
|
||||
call<BrowserProbeResult>('browser.probe', { url }, signal),
|
||||
/** External open for the file tree's "open with" menu: reveal a path in
|
||||
* the OS file manager, or hand a custom-scheme URL (vscode://, cursor://,
|
||||
* zed://, custom editors) to its registered handler. The host launches
|
||||
* the platform opener (argv, no shell). */
|
||||
openExternal: (payload: { action: 'reveal'; path: string } | { action: 'url'; url: string }) =>
|
||||
call<{ started: boolean }>('open.external', payload),
|
||||
}
|
||||
|
||||
/** Absolute URL of the media route for one path (images only). */
|
||||
@@ -207,9 +344,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)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,11 @@ export function isNarrowWidth(width: number): boolean {
|
||||
return width < NARROW_MAX_WIDTH
|
||||
}
|
||||
|
||||
export interface ViewportSize {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Live narrow-viewport flag for components. Reads `window.innerWidth` and
|
||||
* re-measures on resize (rAF-throttled, the repo's existing drag pattern).
|
||||
@@ -27,16 +32,17 @@ export function isNarrowWidth(width: number): boolean {
|
||||
* 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),
|
||||
)
|
||||
export function useViewportSize(): ViewportSize {
|
||||
const [size, setSize] = useState<ViewportSize>(() => ({
|
||||
width: typeof window === 'undefined' ? 0 : window.innerWidth,
|
||||
height: typeof window === 'undefined' ? 0 : window.innerHeight,
|
||||
}))
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
let frame: number | null = null
|
||||
const measure = (): void => {
|
||||
frame = null
|
||||
setNarrow(isNarrowWidth(window.innerWidth))
|
||||
setSize({ width: window.innerWidth, height: window.innerHeight })
|
||||
}
|
||||
const onResize = (): void => {
|
||||
if (frame === null) frame = requestAnimationFrame(measure)
|
||||
@@ -47,5 +53,9 @@ export function useNarrowViewport(): boolean {
|
||||
if (frame !== null) cancelAnimationFrame(frame)
|
||||
}
|
||||
}, [])
|
||||
return narrow
|
||||
return size
|
||||
}
|
||||
|
||||
export function useNarrowViewport(): boolean {
|
||||
return isNarrowWidth(useViewportSize().width)
|
||||
}
|
||||
|
||||
+50
-2
@@ -68,6 +68,11 @@ export function isLoopbackHostname(hostname: string): boolean {
|
||||
* @param selfOrigin - the GUI's own origin (window.location.origin). The GUI
|
||||
* itself may be browsed in the sidebar (the sandbox keeps it opaque), so it
|
||||
* is let through BEFORE the loopback check — its host is normally loopback.
|
||||
* @param allowedLoopback - comma-separated loopback allowlist from the side
|
||||
* card prefs (`browserAllowedLoopback`): bare hosts (`localhost`,
|
||||
* `127.0.0.1`) allow every port, `host:port` entries allow exactly that
|
||||
* authority. Entries are matched case-insensitively; portless entries match
|
||||
* the host on any port. Empty allowlist keeps the default loopback block.
|
||||
*/
|
||||
/** Schemes that must never reach the iframe, even without `//` (javascript:,
|
||||
* data:, file:, ...). Host:port lookalikes (example.com:8080) are NOT here —
|
||||
@@ -78,7 +83,41 @@ const FORBIDDEN_SCHEMES = new Set([
|
||||
'chrome', 'chrome-extension', 'moz-extension', 'edge', 'opera', 'resource', 'view-source',
|
||||
])
|
||||
|
||||
export function normalizeBrowserUrl(input: string, selfOrigin: string): BrowserNavigateResult {
|
||||
/** Parse the loopback allowlist into a matcher predicate over host:port. */
|
||||
export function parseLoopbackAllowlist(allowlist: string): (host: string, port: string) => boolean {
|
||||
const entries = allowlist.split(',').map(entry => entry.trim().toLowerCase()).filter(entry => entry !== '')
|
||||
const exact = new Set(entries)
|
||||
const hosts = new Set<string>()
|
||||
for (const entry of entries) {
|
||||
if (!entry.includes(':')) hosts.add(entry.replace(/^\[|\]$/g, ''))
|
||||
}
|
||||
return (host, port) => {
|
||||
const key = `${host}:${port}`
|
||||
if (exact.has(key) || exact.has(host)) return true
|
||||
return port !== '' && hosts.has(host)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a loopback URL is explicitly allowlisted by the side card prefs
|
||||
* (`browserAllowedLoopback`). Only allowlisted local addresses may run with
|
||||
* `allow-same-origin` in the sidebar iframe — needed for local dev servers
|
||||
* (Vite etc.) whose module/HMR/fetch pipeline requires a real origin, while
|
||||
* the page stays cross-origin to the GUI and to every other site.
|
||||
*/
|
||||
export function isAllowedLoopbackUrl(url: string, allowlist: string): boolean {
|
||||
if (allowlist.trim() === '') return false
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(url)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (!isLoopbackHostname(parsed.hostname)) return false
|
||||
return parseLoopbackAllowlist(allowlist)(parsed.hostname, parsed.port)
|
||||
}
|
||||
|
||||
export function normalizeBrowserUrl(input: string, selfOrigin: string, allowedLoopback = ''): BrowserNavigateResult {
|
||||
const trimmed = input.trim()
|
||||
if (trimmed === '') return { kind: 'invalid' }
|
||||
// Distinguish an explicit scheme from a bare host:port. "example.com:8080"
|
||||
@@ -114,6 +153,15 @@ export function normalizeBrowserUrl(input: string, selfOrigin: string): BrowserN
|
||||
} catch {
|
||||
// Unparsable selfOrigin (never in practice): fall through to the loopback gate.
|
||||
}
|
||||
if (isLoopbackHostname(url.hostname)) return { kind: 'blocked', reason: 'loopback' }
|
||||
if (isLoopbackHostname(url.hostname)) {
|
||||
// An explicit user allowlist (browserAllowedLoopback) can lift the
|
||||
// loopback block for trusted local dev servers. The sandbox still
|
||||
// renders them in an opaque origin — no GUI access, exactly like any
|
||||
// other browsed site.
|
||||
if (allowedLoopback.trim() !== '' && parseLoopbackAllowlist(allowedLoopback)(url.hostname, url.port)) {
|
||||
return { kind: 'ok', url: url.href }
|
||||
}
|
||||
return { kind: 'blocked', reason: 'loopback' }
|
||||
}
|
||||
return { kind: 'ok', url: url.href }
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
+173
-40
@@ -1,24 +1,28 @@
|
||||
/**
|
||||
* 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 { OpenWithSettings } from '../open-with-settings.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'
|
||||
@@ -51,6 +55,20 @@ interface TerminalViewProps {
|
||||
/** How many UI-owned terminals may be open at once (agent-owned ones are uncapped). */
|
||||
export const TERMINAL_LIMIT = 3
|
||||
|
||||
/** Optional per-registration builtin behavior (currently terminal title). */
|
||||
export interface BuiltinTabOptions {
|
||||
/** Returns the display title for newly opened terminal tabs. */
|
||||
terminalTitle?: () => string
|
||||
}
|
||||
|
||||
/** A client-side uuid for terminal tab identity (not shown in the UI). */
|
||||
function terminalUuid(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return `t${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
|
||||
/** Count UI-owned terminals (agent:` tabs excluded — they are the model's). */
|
||||
function uiTerminalCount(state: SidebarState): number {
|
||||
return allLeaves(state.splits)
|
||||
@@ -58,33 +76,58 @@ 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; the "open with"
|
||||
// configuration (SSH host + custom editors) is the custom panel BELOW
|
||||
// those rows — the settings seam renders rows first, custom panel after.
|
||||
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'),
|
||||
},
|
||||
],
|
||||
}],
|
||||
render: ({ pluginSettings, updatePluginSetting }) => (
|
||||
<OpenWithSettings pluginSettings={pluginSettings} updatePluginSetting={updatePluginSetting} />
|
||||
),
|
||||
},
|
||||
component: ({ ctx, store, scope, tab, expanded, revealed, 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) }}
|
||||
revealed={revealed ?? []}
|
||||
onToggleDir={onToggleDir ?? (() => { /* no-op */ })}
|
||||
onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })}
|
||||
/>
|
||||
),
|
||||
@@ -95,9 +138,10 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
icon: (size: number) => <IconBranchOutline16 size={size} />,
|
||||
order: 20,
|
||||
single: true,
|
||||
component: ({ ctx, store, scope, onOpenDiff }) => (
|
||||
component: ({ ctx, store, scope, visible, onOpenDiff }) => (
|
||||
<GitView
|
||||
scope={scope}
|
||||
visible={visible}
|
||||
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
|
||||
onOpenDiff={onOpenDiff ?? (() => { /* no-op */ })}
|
||||
/>
|
||||
@@ -110,16 +154,16 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
order: 30,
|
||||
single: true,
|
||||
// Declarative settings: the auto-open switches render under this row in
|
||||
// the Side card settings page (the Tasks page's own related settings).
|
||||
// the Side card settings page (the Jobs page's own related settings).
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'autoOpenSubagent',
|
||||
title: () => t('settingsSubagentTitle'),
|
||||
desc: () => t('settingsSubagentDesc'),
|
||||
}, {
|
||||
key: 'autoOpenTasks',
|
||||
title: () => t('settingsTasksTitle'),
|
||||
desc: () => t('settingsTasksDesc'),
|
||||
key: 'autoOpenJobs',
|
||||
title: () => t('settingsJobsTitle'),
|
||||
desc: () => t('settingsJobsDesc'),
|
||||
}],
|
||||
},
|
||||
component: ({ ctx, scope, visible, onSubagentJump }) => (
|
||||
@@ -131,16 +175,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',
|
||||
@@ -150,6 +240,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) => {
|
||||
@@ -157,10 +273,12 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
if (count >= TERMINAL_LIMIT) return null
|
||||
return {
|
||||
tab: {
|
||||
id: `terminal:${state.nextTerminal}`,
|
||||
id: `terminal:${terminalUuid()}`,
|
||||
type: 'terminal',
|
||||
title: `${t('terminal')} ${state.nextTerminal}`,
|
||||
title: options.terminalTitle?.() ?? t('terminal'),
|
||||
},
|
||||
// Keep the legacy counter advancing for compatibility with older
|
||||
// persisted states; new ids no longer use it.
|
||||
patch: { nextTerminal: state.nextTerminal + 1 },
|
||||
}
|
||||
},
|
||||
@@ -171,9 +289,10 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
title: () => t('browser'),
|
||||
icon: (size: number) => <IconGlobeOutline16 size={size} />,
|
||||
order: 50,
|
||||
// Declarative settings: the sandbox escape hatch and the link
|
||||
// takeover render under this tab's row in the Side card settings
|
||||
// page (the sandbox one is warned on).
|
||||
// Declarative settings: the sandbox escape hatch, the link-takeover
|
||||
// MASTER switch, and the per-protocol takeover switches (http on /
|
||||
// https off by default) render under this tab's row in the Side card
|
||||
// settings page (the sandbox one is warned on).
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'browserNoSandbox',
|
||||
@@ -183,6 +302,20 @@ 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'),
|
||||
}, {
|
||||
key: 'browserAllowedLoopback',
|
||||
type: 'text',
|
||||
title: () => t('settingsBrowserLoopbackTitle'),
|
||||
desc: () => t('settingsBrowserLoopbackDesc'),
|
||||
placeholder: t('settingsBrowserLoopbackPlaceholder'),
|
||||
}],
|
||||
},
|
||||
createTab: (state) => ({
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
/**
|
||||
* 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 `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.
|
||||
* 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 heavy viewers (docx/xlsx/pptx and 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`.
|
||||
* The `binary-download` viewer sniffs NUL bytes via `detect` for unknown
|
||||
* 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
|
||||
@@ -26,9 +30,6 @@ import {
|
||||
IconImageOutline16,
|
||||
IconMarkdownOutline16,
|
||||
IconPdfOutline16,
|
||||
IconDocxOutline16,
|
||||
IconXlsxOutline16,
|
||||
IconPptxOutline16,
|
||||
IconHtmlOutline16,
|
||||
} from '../icons.tsx'
|
||||
import type { ComponentType } from 'react'
|
||||
@@ -37,17 +38,14 @@ import { t } from '../locales.ts'
|
||||
import css from '../sidebar.module.css'
|
||||
|
||||
/**
|
||||
* Lazy wrappers over the chunk-resident viewer components. The `pick`
|
||||
* functions are module-level (stable identity — the wrapper effect depends
|
||||
* on them); the cast bridges the chunk exports record to the descriptor
|
||||
* prop shape (the views read only their own subset of FileViewerProps).
|
||||
* 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 LazyDocx = lazyChunkComponent<FileViewerProps>('docx', (mod) => mod.DocxView as ComponentType<FileViewerProps> | undefined)
|
||||
const LazyXlsx = lazyChunkComponent<FileViewerProps>('xlsx', (mod) => mod.XlsxView as ComponentType<FileViewerProps> | undefined)
|
||||
const LazyPptx = lazyChunkComponent<FileViewerProps>('pptx', (mod) => mod.PptxView as ComponentType<FileViewerProps> | undefined)
|
||||
const LazyTextEditor = lazyChunkComponent<FileViewerProps>('editor', (mod) => mod.TextEditor as ComponentType<FileViewerProps> | undefined)
|
||||
|
||||
/** The 9 built-in file viewer descriptors. */
|
||||
/** The 6 built-in file viewer descriptors. */
|
||||
export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
return [
|
||||
{
|
||||
@@ -72,30 +70,6 @@ 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: (props) => <LazyDocx {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'xlsx',
|
||||
title: () => t('viewerXlsx'),
|
||||
icon: (size: number) => <IconXlsxOutline16 size={size} />,
|
||||
exts: ['xlsx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: (props) => <LazyXlsx {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'pptx',
|
||||
title: () => t('viewerPptx'),
|
||||
icon: (size: number) => <IconPptxOutline16 size={size} />,
|
||||
exts: ['pptx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: (props) => <LazyPptx {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'markdown',
|
||||
title: () => t('viewerMarkdown'),
|
||||
|
||||
+185
-23
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* Lazy chunk loader for the client bundle. The heavy preview/terminal
|
||||
* libraries (Univer, docx-preview, pptx-renderer, CodeMirror, xterm — the
|
||||
* office/terminal/editor stacks, tens of 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.
|
||||
* 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):
|
||||
*
|
||||
@@ -27,7 +29,10 @@
|
||||
* Caching contract (three layers, each with a failure path):
|
||||
* - In-memory: one in-flight promise per chunk, memoized until
|
||||
* {@link resetChunks}; a failed load removes its entry so the next call
|
||||
* retries from scratch.
|
||||
* retries from scratch. HMR re-activation keeps the resolved exports of
|
||||
* unchanged chunks (ETag revalidation via
|
||||
* {@link revalidateChunksOnReactivate}) — the next lazy open skips the
|
||||
* re-inject / re-execute.
|
||||
* - Script execution: each re-execution overwrites the global registry slot
|
||||
* (assignment, never registration) — no "duplicate factory registration"
|
||||
* class of errors; a failed materialization clears the cache so the retry
|
||||
@@ -36,13 +41,15 @@
|
||||
* no-cache` + ETag, 304 when unchanged), so page refreshes and HMR
|
||||
* re-activations never re-download a multi-MB chunk that did not change.
|
||||
*
|
||||
* HMR: each plugin activation calls {@link resetChunks}, which drops the
|
||||
* in-memory cache (and any test registry), so a hot-reloaded core bundle
|
||||
* re-fetches and re-executes the current chunk scripts on the next lazy
|
||||
* open. Chunk-only source edits still need a manual page refresh (the HMR
|
||||
* poll watches only client.js).
|
||||
* HMR: each plugin activation calls {@link revalidateChunksOnReactivate},
|
||||
* which HEADs every loaded chunk against the bundle route and keeps the
|
||||
* in-memory cache for the ones whose ETag is unchanged (a hot-reloaded core
|
||||
* bundle therefore does not re-execute multi-MB chunk scripts). Chunk-only
|
||||
* source edits still need a manual page refresh (the HMR poll watches only
|
||||
* client.js); an edit that does land while a core HMR happens is caught by
|
||||
* the ETag comparison on the next activation.
|
||||
*/
|
||||
export type ChunkName = 'docx' | 'xlsx' | 'pptx' | 'terminal' | 'editor'
|
||||
export type ChunkName = 'terminal' | 'editor' | 'mermaid'
|
||||
|
||||
/** The module exports a chunk factory provides (namespace-ish record). */
|
||||
export type ChunkExports = Record<string, unknown>
|
||||
@@ -54,7 +61,19 @@ type ChunkFactory = (require: (spec: string) => unknown) => ChunkExports
|
||||
* The platform externals a chunk bundle may require (mirror of
|
||||
* CLIENT_EXTERNALS in tsdown.config.ts — the chunk builds keep these
|
||||
* external and the loader resolves them here). A superset is safe: the
|
||||
* require only answers what the chunk actually asks for.
|
||||
* require only answers what the chunk actually asks for. The shell's static
|
||||
* module table seeds React, Cordis, and the UI libraries (primitives/slots);
|
||||
* `dsh-client-runtime/client` normalizes onto the runtime package row
|
||||
* (stripClientSuffix). dsh-client-web-react / dsh-client-schema-form were
|
||||
* dropped in DSH 0.1.0-rc.8 (no rc.8 publish, nothing requires them) — the
|
||||
* chunks never asked for them, so they no longer belong here.
|
||||
*
|
||||
* DSH 0.1.2-alpha.1 removed the `dsh-client-runtime` package outright (the
|
||||
* seed table gained bare-name `@deepseek-ai/dsh-client-store` instead); the
|
||||
* runtime/client row below stays for 0.1.1-rc.x hosts — no chunk requires
|
||||
* it, and {@link buildExternalsRequire} keeps an unresolvable spec
|
||||
* undefined until a chunk actually asks (only then is it a loud error), so
|
||||
* the entry is inert on 0.1.2-alpha.1+.
|
||||
*/
|
||||
export const CHUNK_EXTERNALS: readonly string[] = [
|
||||
'react',
|
||||
@@ -63,23 +82,61 @@ export const CHUNK_EXTERNALS: readonly string[] = [
|
||||
'react-dom/client',
|
||||
'cordis',
|
||||
'@deepseek-ai/dsh-client-ui-slots',
|
||||
'@deepseek-ai/dsh-client-web-react',
|
||||
'@deepseek-ai/dsh-client-ui-primitives',
|
||||
'@deepseek-ai/dsh-client-schema-form',
|
||||
'@deepseek-ai/dsh-client-runtime/client',
|
||||
]
|
||||
|
||||
/** Chunk script endpoint served by the plugin host half (src/bundle-route.ts). */
|
||||
const CHUNK_URL = (name: ChunkName): string => `/sidebar/bundle/${name}.js`
|
||||
|
||||
/** The client module system surface this loader needs (window.__DSH_MODULES__). */
|
||||
interface ChunkModuleSystem {
|
||||
/** Bound on the revalidation HEAD round-trip. A timeout fails open (drop +
|
||||
* re-fetch on the next open) so a stuck bundle route can never wedge lazy
|
||||
* chunk loads behind the revalidation barrier. */
|
||||
const CHUNK_REVALIDATE_TIMEOUT_MS = 5_000
|
||||
|
||||
/**
|
||||
* The client module system surface this loader needs to resolve externals.
|
||||
* DSH 0.1.0-rc.8 provides it as the `ctx.modules` service (no page global
|
||||
* anymore); the plugin injects it at activation via
|
||||
* {@link setChunkModuleSystem}. The rc.7-era `window.__DSH_MODULES__` global
|
||||
* remains as a fallback so older hosts and the test harness keep working.
|
||||
*/
|
||||
export interface ChunkModuleSystem {
|
||||
import(specifier: string): Promise<unknown>
|
||||
}
|
||||
|
||||
/** Resolve the shell-installed module system (set before any plugin activates). */
|
||||
/** The module system injected by the client half at activation (rc.8+). */
|
||||
let injectedModuleSystem: ChunkModuleSystem | undefined
|
||||
|
||||
/**
|
||||
* Plugin-owned page global carrying the injected module system across
|
||||
* bundle copies: the lazy chunk bundles (client-editor.js etc.) inline their
|
||||
* own chunk-loader instance, and rc.8 no longer exposes the shell module
|
||||
* system as a page global — so the core bundle's injection must be visible
|
||||
* to the chunk copies through a namespace of our own.
|
||||
*/
|
||||
const MODULE_SYSTEM_GLOBAL = '__dshSidebarModuleSystem__'
|
||||
|
||||
/**
|
||||
* Inject the client module system the chunk externals resolve through.
|
||||
* Called by the client half's apply() with `ctx.modules` (rc.8+); pass
|
||||
* undefined to clear (tests). Survives {@link resetChunks} — the module
|
||||
* system is shell state, not chunk state, and stays live across HMR.
|
||||
*/
|
||||
export function setChunkModuleSystem(system: ChunkModuleSystem | undefined): void {
|
||||
injectedModuleSystem = system
|
||||
const g = globalThis as Record<string, unknown>
|
||||
if (system === undefined) delete g[MODULE_SYSTEM_GLOBAL]
|
||||
else g[MODULE_SYSTEM_GLOBAL] = system
|
||||
}
|
||||
|
||||
/** Resolve the shell-installed module system (injected, then the plugin
|
||||
* global shared with chunk-bundle copies, then the rc.7 page global). */
|
||||
function moduleSystem(): ChunkModuleSystem | undefined {
|
||||
return (globalThis as { __DSH_MODULES__?: ChunkModuleSystem }).__DSH_MODULES__
|
||||
const g = globalThis as Record<string, unknown>
|
||||
return injectedModuleSystem
|
||||
?? g[MODULE_SYSTEM_GLOBAL] as ChunkModuleSystem | undefined
|
||||
?? (g as { __DSH_MODULES__?: ChunkModuleSystem }).__DSH_MODULES__
|
||||
}
|
||||
|
||||
/** The plugin-owned chunk factory registry the chunk scripts populate. */
|
||||
@@ -153,6 +210,35 @@ async function buildExternalsRequire(modules: ChunkModuleSystem): Promise<(spec:
|
||||
/** In-flight/memoized chunk loads; a failure removes its entry so a retry re-fetches. */
|
||||
const cache = new Map<ChunkName, Promise<ChunkExports>>()
|
||||
|
||||
/** Chunk names whose exports are currently cached (loaded successfully). */
|
||||
const loadedChunks = new Set<ChunkName>()
|
||||
|
||||
/** ETags observed for loaded chunks (HEAD revalidation, see
|
||||
* {@link revalidateChunksOnReactivate}). */
|
||||
const chunkEtags = new Map<ChunkName, string>()
|
||||
|
||||
/** Pending revalidation barrier: while set, {@link loadChunk} awaits it
|
||||
* before serving cache (see revalidateChunksOnReactivate). */
|
||||
let revalidation: Promise<void> | null = null
|
||||
|
||||
/** Best-effort ETag capture for revalidation. The script tag itself exposes
|
||||
* no response headers, so after a successful load we HEAD the bundle route
|
||||
* once. Failures (including a stuck route — bounded by the timeout) are
|
||||
* ignored — revalidation then fails open (re-fetch). */
|
||||
async function recordEtag(name: ChunkName): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(CHUNK_URL(name), {
|
||||
method: 'HEAD',
|
||||
cache: 'no-cache',
|
||||
signal: AbortSignal.timeout(CHUNK_REVALIDATE_TIMEOUT_MS),
|
||||
})
|
||||
const etag = res.headers.get('etag')
|
||||
if (etag !== null && etag !== '') chunkEtags.set(name, etag)
|
||||
} catch {
|
||||
chunkEtags.delete(name)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load (once) and materialize a lazy chunk, returning its module exports.
|
||||
* Concurrent callers share one in-flight load; a failure clears the cache
|
||||
@@ -160,10 +246,14 @@ const cache = new Map<ChunkName, Promise<ChunkExports>>()
|
||||
* global registry slot — assignments are idempotent).
|
||||
* @param name - the chunk to load.
|
||||
*/
|
||||
export function loadChunk(name: ChunkName): Promise<ChunkExports> {
|
||||
export async function loadChunk(name: ChunkName): Promise<ChunkExports> {
|
||||
// Barrier: never serve a cache entry that a pending revalidation is about
|
||||
// to inspect — a stale chunk could otherwise render mid-HMR (CR #232 P1).
|
||||
if (revalidation !== null) await revalidation
|
||||
const cached = cache.get(name)
|
||||
if (cached !== undefined) return cached
|
||||
const task = (async (): Promise<ChunkExports> => {
|
||||
let task: Promise<ChunkExports>
|
||||
task = (async (): Promise<ChunkExports> => {
|
||||
const test = testLoaders.get(name)
|
||||
if (test !== undefined) return test()
|
||||
const modules = moduleSystem()
|
||||
@@ -176,10 +266,23 @@ export function loadChunk(name: ChunkName): Promise<ChunkExports> {
|
||||
throw new Error(`[dsh-better-sidebar] chunk "${name}" script did not register its factory`)
|
||||
}
|
||||
const require = await buildExternalsRequire(modules)
|
||||
return factory(require)
|
||||
const exports = factory(require)
|
||||
// Only track production loads whose cache entry survived (a revalidation
|
||||
// sweep may have dropped it mid-flight; the caller still gets these
|
||||
// exports, they just are not memoized for the next open). Test-registry
|
||||
// loads return above and never reach this tracking.
|
||||
if (cache.get(name) !== undefined) {
|
||||
loadedChunks.add(name)
|
||||
void recordEtag(name)
|
||||
}
|
||||
return exports
|
||||
})()
|
||||
cache.set(name, task)
|
||||
void task.catch(() => { cache.delete(name) })
|
||||
void task.catch(() => {
|
||||
cache.delete(name)
|
||||
loadedChunks.delete(name)
|
||||
chunkEtags.delete(name)
|
||||
})
|
||||
return task
|
||||
}
|
||||
|
||||
@@ -187,10 +290,69 @@ export function loadChunk(name: ChunkName): Promise<ChunkExports> {
|
||||
* Drop all chunk state for a fresh plugin activation (HMR-safe): clear the
|
||||
* in-memory cache and any test-registry entries, so the next lazy open
|
||||
* re-fetches and re-executes the current chunk scripts (the registry slots
|
||||
* are overwritten by the re-execution — no cleanup needed).
|
||||
* are overwritten by the re-execution — no cleanup needed). A pending
|
||||
* revalidation barrier is cleared too: it was only guarding the cache reads
|
||||
* of the state being dropped, so the next load must not wait on it (the
|
||||
* orphaned task still settles and its identity-guarded `finally` no-ops).
|
||||
*/
|
||||
export function resetChunks(): void {
|
||||
cache.clear()
|
||||
loadedChunks.clear()
|
||||
chunkEtags.clear()
|
||||
testLoaders.clear()
|
||||
externalsRequire = undefined
|
||||
revalidation = null
|
||||
}
|
||||
|
||||
/**
|
||||
* HMR-safe re-activation hook (index.tsx calls this instead of a full
|
||||
* reset): keep the resolved exports of every loaded chunk and drop only the
|
||||
* ones whose script changed on disk — the bundle route revalidates every
|
||||
* request (cache-control: no-cache + ETag), so an unchanged chunk keeps its
|
||||
* memory cache and the next lazy open skips the re-inject / re-execute.
|
||||
* Fail-open: an unreachable, ETag-less, or timed-out chunk is dropped
|
||||
* (re-fetch on next open). Test-registry entries are always cleared
|
||||
* (per-test fixtures).
|
||||
* A page refresh remains the authoritative reset (the HMR poll watches only
|
||||
* client.js; chunk-only edits surface here on the next core re-activation).
|
||||
*
|
||||
* The returned promise is also a BARRIER for {@link loadChunk}: while a
|
||||
* revalidation is pending, every chunk load awaits it before serving cache,
|
||||
* so a lazy tab opening mid-revalidation can never render stale exports
|
||||
* that the sweep is about to invalidate (CR #232 P1).
|
||||
*/
|
||||
export function revalidateChunksOnReactivate(): Promise<void> {
|
||||
testLoaders.clear()
|
||||
const task = (async (): Promise<void> => {
|
||||
// Entries not tracked as production-loaded (test fixtures, orphans) never
|
||||
// survive a re-activation — their resolved exports came from per-test
|
||||
// stubs, not from the bundle route.
|
||||
for (const name of [...cache.keys()]) {
|
||||
if (!loadedChunks.has(name)) cache.delete(name)
|
||||
}
|
||||
if (loadedChunks.size === 0) return
|
||||
const stale: ChunkName[] = []
|
||||
await Promise.all([...loadedChunks].map(async (name) => {
|
||||
try {
|
||||
const res = await fetch(CHUNK_URL(name), {
|
||||
method: 'HEAD',
|
||||
cache: 'no-cache',
|
||||
signal: AbortSignal.timeout(CHUNK_REVALIDATE_TIMEOUT_MS),
|
||||
})
|
||||
const etag = res.headers.get('etag')
|
||||
if (etag !== null && etag !== '' && chunkEtags.get(name) === etag) return
|
||||
} catch {
|
||||
// Fail open below (network errors and timeouts alike).
|
||||
}
|
||||
stale.push(name)
|
||||
}))
|
||||
for (const name of stale) {
|
||||
cache.delete(name)
|
||||
loadedChunks.delete(name)
|
||||
chunkEtags.delete(name)
|
||||
}
|
||||
})()
|
||||
revalidation = task
|
||||
void task.finally(() => { if (revalidation === task) revalidation = null })
|
||||
return task
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* Lazy chunk entry: the .docx preview (docx-preview + jszip). Built as
|
||||
* `lib/client-docx.js` and registered under `dsh-better-sidebar/docx` —
|
||||
* fetched only when a .docx 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 the office library into the startup path.
|
||||
*/
|
||||
export { DocxView } from '../docx-view.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'
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* Lazy chunk entry: the .pptx preview (pptx-renderer). Built as
|
||||
* `lib/client-pptx.js` and registered under `dsh-better-sidebar/pptx` —
|
||||
* fetched only when a .pptx 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 the renderer into the startup path.
|
||||
*/
|
||||
export { PptxView } from '../PptxView.tsx'
|
||||
@@ -1,9 +0,0 @@
|
||||
/**
|
||||
* Lazy chunk entry: the .xlsx preview (Univer family + SheetJS, several MB).
|
||||
* Built as `lib/client-xlsx.js` and registered under
|
||||
* `dsh-better-sidebar/xlsx` — fetched only when a .xlsx 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 Univer into the
|
||||
* startup path.
|
||||
*/
|
||||
export { XlsxView } from '../xlsx-view.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
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* The .docx preview component: renders via docx-preview (preserved
|
||||
* styles/images/tables). Lives in its own module (split out of the former
|
||||
* office-view.tsx) so it builds as a standalone lazy chunk
|
||||
* (`lib/client-docx.js`) — the library is loaded only when a .docx is first
|
||||
* opened, and never pulls in the Univer/xlsx stack.
|
||||
*
|
||||
* Errors degrade to the shared download-button affordance, so a corrupted /
|
||||
* encrypted / oversized file always leaves the user with a way to get it.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { mediaUrl } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { BinaryFallback, type LoadState, type OfficeViewProps } from './office-shared.tsx'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Per-frame task coalescing for pointer-driven interactions.
|
||||
*
|
||||
* Pointer streams fire several times faster than the display refresh, and
|
||||
* applying every event (a React setState, a store reduce) re-renders whole
|
||||
* trees at event cadence — the visible drag lag on slower CPUs (#315). This
|
||||
* batcher keeps the LATEST scheduled task, runs it at most once per
|
||||
* animation frame, and lets the caller flush synchronously on release (so
|
||||
* the final pointer position is never lost to a cancelled frame).
|
||||
*
|
||||
* Callers compose the pending value in a ref before scheduling; the
|
||||
* scheduled task reads that ref, so replacing the task on every event is
|
||||
* cheap (one ref write + one rAF check).
|
||||
*/
|
||||
export interface FrameBatcher {
|
||||
/** Schedule (or replace) the per-frame task. Safe to call on every event. */
|
||||
schedule(task: () => void): void
|
||||
/** Cancel any pending frame and run the scheduled task synchronously
|
||||
* (no-op when nothing is scheduled). */
|
||||
flushNow(): void
|
||||
/** Cancel any pending frame and drop the task without running it. */
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
export function createFrameBatcher(): FrameBatcher {
|
||||
let frame: number | null = null
|
||||
let task: (() => void) | null = null
|
||||
|
||||
const run = (): void => {
|
||||
frame = null
|
||||
const current = task
|
||||
task = null
|
||||
current?.()
|
||||
}
|
||||
|
||||
return {
|
||||
schedule(next) {
|
||||
task = next
|
||||
if (frame === null) frame = requestAnimationFrame(run)
|
||||
},
|
||||
flushNow() {
|
||||
if (frame !== null) {
|
||||
cancelAnimationFrame(frame)
|
||||
frame = null
|
||||
}
|
||||
run()
|
||||
},
|
||||
dispose() {
|
||||
if (frame !== null) {
|
||||
cancelAnimationFrame(frame)
|
||||
frame = null
|
||||
}
|
||||
task = null
|
||||
},
|
||||
}
|
||||
}
|
||||
+54
-28
@@ -52,7 +52,7 @@ export const IconDiffOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
)
|
||||
|
||||
/**
|
||||
* Stop glyph for the background-task kill button: a filled square in the
|
||||
* 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) => (
|
||||
@@ -61,6 +61,26 @@ export const IconStopOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
</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>
|
||||
)
|
||||
|
||||
/**
|
||||
* Pin glyph in the app's outline style (1.5px stroke, currentColor): a pushpin
|
||||
* tilted to the lower-right. Used by the PinnedRail and the tab context menu's
|
||||
* pin entry (v0.17.0+).
|
||||
*/
|
||||
export const IconPinOutline16 = ({ 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="M9.5 1.5 14.5 6.5 12.5 8.5 10 6 5.5 10.5 6 12 4.5 13.5 2.5 11.5 4 10 5.5 10.5 10 6 7.5 8.5 6.5Z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// ── File-viewer inventory glyphs (Side card settings page) ────────────────
|
||||
|
||||
/** Image viewer glyph: a picture frame with a sun and a mountain. */
|
||||
@@ -83,33 +103,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">
|
||||
@@ -135,3 +128,36 @@ 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>
|
||||
)
|
||||
|
||||
/**
|
||||
* Visual Studio Code brand mark for the file-tree "open with" menu. The
|
||||
* path is the Simple Icons `visualstudiocode` glyph (CC0 1.0,
|
||||
* simple-icons@11.0.0 — later releases dropped it over Microsoft's brand
|
||||
* policy, so it is inlined here rather than pulled from react-icons),
|
||||
* rendered monochrome via currentColor to follow the active skin.
|
||||
*/
|
||||
export const IconVscode16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M23.15 2.587L18.21.21a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12 .326 15.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352zm-5.146 14.861L10.826 12l7.178-5.448v10.896z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
+271
-74
@@ -8,67 +8,109 @@
|
||||
* 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 { resetChunks } from './chunk-loader.ts'
|
||||
import { allLeaves, createSidebarStore, isAgentTabId } from './state.ts'
|
||||
import { createBetterSidebarService, matchUrlTarget } from './service.ts'
|
||||
import { revalidateChunksOnReactivate, setChunkModuleSystem } from './chunk-loader.ts'
|
||||
import { registerBuiltins } from './builtins/index.ts'
|
||||
import { Sidebar } from './Sidebar.tsx'
|
||||
import { RenderBoundary } from './RenderBoundary.tsx'
|
||||
import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx'
|
||||
import { registerLinkInterception } from './link-intercept.ts'
|
||||
import { registerImeGuard } from './ime-guard.ts'
|
||||
import { loadPrefs } from './prefs.ts'
|
||||
import { registerSettingsNavIcon } from './settings-nav-icon.ts'
|
||||
import { loadExternalDisable, loadPrefs } from './prefs.ts'
|
||||
import { SideCardSection } from './SideCardSection.tsx'
|
||||
import { api } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { LOCALE_NS, attachLocale, attachBetterLocale, t, zh, en,
|
||||
ja, de, fr, pt, ko, ar, hi, id, tr, vi, th, ru, it, nl, sv, pl,
|
||||
zhHK, zhTW, zhMO,
|
||||
} 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')
|
||||
|
||||
// Opt-in third-language support through @huanlin/dsh-plugin-better-locale.
|
||||
// When that plugin is installed, it publishes `ctx.betterLocale` (the
|
||||
// override store) and patches LocaleRuntime.prototype.lookup to consult
|
||||
// it. We mirror the same override awareness into the sidebar's own `t()`:
|
||||
// attachBetterLocale() makes t() consult the store's getOverride first,
|
||||
// so the sidebar's chrome (which bypasses ctx.locale and calls t()
|
||||
// directly) also switches to the override language. We also register
|
||||
// the ja dict with the better-locale store so external callers of
|
||||
// ctx.locale.lookup('betterSidebar', key) get the override text too.
|
||||
//
|
||||
// Activation-order-safe: ctx.get('betterLocale') is a non-reactive read
|
||||
// (cordis only re-evaluates declared `inject` deps). If better-locale
|
||||
// activates after better-sidebar, the initial read returns undefined.
|
||||
// We subscribe to the locale revision — better-locale bumps it on
|
||||
// activation (when a persisted override exists) and on every override
|
||||
// switch — and re-check ctx.get on each bump, attaching + registering
|
||||
// the ja dict once the store becomes available.
|
||||
ctx.effect(() => {
|
||||
let dispose: (() => void) | undefined
|
||||
const sync = (): void => {
|
||||
dispose?.()
|
||||
dispose = undefined
|
||||
const store = ctx.get('betterLocale') as
|
||||
| {
|
||||
readonly active: string | undefined
|
||||
getOverride(dshActive: string, ns: string, key: string): string | undefined
|
||||
isOverrideActive(dshActive: string): boolean
|
||||
register(ns: string, dicts: Record<string, Record<string, string>>): () => void
|
||||
subscribe(listener: () => void): () => void
|
||||
}
|
||||
| undefined
|
||||
attachBetterLocale(store)
|
||||
if (store !== undefined) {
|
||||
dispose = store.register(LOCALE_NS, {
|
||||
ja, de, fr, pt, ko, ar, hi, id, tr, vi, th, ru, it, nl, sv, pl,
|
||||
'zh-HK': zhHK, 'zh-TW': zhTW, 'zh-MO': zhMO,
|
||||
})
|
||||
}
|
||||
}
|
||||
// Initial check (picks up the store if better-locale activated first).
|
||||
sync()
|
||||
// Re-check on every locale revision bump (better-locale bumps when it
|
||||
// activates with a persisted override, and when the user switches).
|
||||
const unsubscribe = ctx.locale.subscribe(sync)
|
||||
return () => {
|
||||
unsubscribe()
|
||||
dispose?.()
|
||||
attachBetterLocale(undefined)
|
||||
}
|
||||
}, 'dsh-better-sidebar: better-locale lazy integration')
|
||||
// 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
|
||||
@@ -80,11 +122,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
|
||||
@@ -104,15 +166,113 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
}
|
||||
try {
|
||||
// Fresh chunk state for this activation: invalidate any chunk factories
|
||||
// registered by a previous fiber (HMR) and drop the in-memory load cache
|
||||
// so the next lazy open re-fetches the current chunk scripts.
|
||||
resetChunks()
|
||||
// rc.8+ exposes the client module system as the `ctx.modules` service
|
||||
// (no window.__DSH_MODULES__ page global anymore); the chunk loader needs
|
||||
// it to resolve its externals, so inject it before anything can load a
|
||||
// lazy chunk. The loader falls back to the rc.7 global when absent.
|
||||
setChunkModuleSystem(ctx.modules)
|
||||
// Fresh chunk state for this activation: drop per-test fixtures and
|
||||
// revalidate loaded chunk scripts against the bundle route's ETags —
|
||||
// unchanged chunks keep their resolved exports (no re-inject /
|
||||
// re-execute on HMR), changed ones are dropped for a clean re-fetch.
|
||||
void revalidateChunksOnReactivate()
|
||||
ctx.effect(() => {
|
||||
let disposed = false
|
||||
let root: Root | undefined
|
||||
let host: HTMLDivElement | undefined
|
||||
void (async () => {
|
||||
let mounted = false
|
||||
let bodyObserver: MutationObserver | undefined
|
||||
let hostCheckFrame: number | null = null
|
||||
const unmount = (): void => {
|
||||
if (!mounted) return
|
||||
mounted = false
|
||||
bodyObserver?.disconnect()
|
||||
bodyObserver = undefined
|
||||
if (hostCheckFrame !== null) {
|
||||
cancelAnimationFrame(hostCheckFrame)
|
||||
hostCheckFrame = null
|
||||
}
|
||||
root?.unmount()
|
||||
root = undefined
|
||||
host?.remove()
|
||||
host = undefined
|
||||
}
|
||||
/** Re-attach the host if the page (a desktop shell wrapper, SPA
|
||||
* navigation, …) ever removes it from <body>. Cheap: childList only,
|
||||
* no subtree, no attribute filtering. */
|
||||
const guardAnchor = (): void => {
|
||||
if (bodyObserver !== undefined) return
|
||||
bodyObserver = new MutationObserver(() => {
|
||||
if (host !== undefined && !document.body.contains(host)) {
|
||||
document.body.appendChild(host)
|
||||
}
|
||||
})
|
||||
bodyObserver.observe(document.body, { childList: true })
|
||||
}
|
||||
/** One-shot geometry self-check: if the host page transforms
|
||||
* <html>/<body> itself (exotic shells), a fixed panel host would
|
||||
* track the transformed box instead of the viewport. Flip the
|
||||
* degraded mode and pin the host to the viewport every frame until
|
||||
* the ancestor transform is actually gone. The normal path (no
|
||||
* page-level transform) never runs the sync loop. */
|
||||
const scheduleHostCheck = (): void => {
|
||||
hostCheckFrame ??= requestAnimationFrame(() => {
|
||||
hostCheckFrame = null
|
||||
const layer = host?.querySelector<HTMLElement>('[data-dsh-panel-host]')
|
||||
if (layer === null || layer === undefined) return
|
||||
const rect = layer.getBoundingClientRect()
|
||||
const mismatched = Math.abs(rect.left) > 8 || Math.abs(rect.top) > 8
|
||||
|| Math.abs(rect.width - window.innerWidth) > 8 || Math.abs(rect.height - window.innerHeight) > 8
|
||||
if (!mismatched) {
|
||||
layer.removeAttribute('data-dsh-panel-host-degraded')
|
||||
layer.style.transform = ''
|
||||
return
|
||||
}
|
||||
layer.setAttribute('data-dsh-panel-host-degraded', '')
|
||||
console.warn('[dsh-better-sidebar] panel host geometry mismatch — a page-level transform was detected; using degraded viewport sync')
|
||||
// Track our own compensating translation so the loop judges the
|
||||
// UNCORRECTED geometry: clearing degraded mode must wait for the
|
||||
// ancestor transform to actually disappear — the frame right after
|
||||
// our correction applies would otherwise look "fixed" and the
|
||||
// offset would return immediately (CR #232 P1).
|
||||
let applied = { x: 0, y: 0 }
|
||||
const sync = (): void => {
|
||||
const r = layer.getBoundingClientRect()
|
||||
const rawLeft = r.left - applied.x
|
||||
const rawTop = r.top - applied.y
|
||||
if (Math.abs(rawLeft) <= 1 && Math.abs(rawTop) <= 1
|
||||
&& Math.abs(r.width - window.innerWidth) <= 1 && Math.abs(r.height - window.innerHeight) <= 1) {
|
||||
layer.removeAttribute('data-dsh-panel-host-degraded')
|
||||
layer.style.transform = ''
|
||||
return
|
||||
}
|
||||
const next = { x: -rawLeft, y: -rawTop }
|
||||
if (next.x !== applied.x || next.y !== applied.y) {
|
||||
applied = next
|
||||
layer.style.transform = `translate(${applied.x}px, ${applied.y}px)`
|
||||
}
|
||||
hostCheckFrame = requestAnimationFrame(sync)
|
||||
}
|
||||
hostCheckFrame = requestAnimationFrame(sync)
|
||||
})
|
||||
}
|
||||
const mount = (): void => {
|
||||
if (mounted || disposed) return
|
||||
try {
|
||||
host = document.createElement('div')
|
||||
host.setAttribute('data-dsh-better-sidebar', '')
|
||||
document.body.appendChild(host)
|
||||
root = createRoot(host)
|
||||
root.render(createElement(RenderBoundary, { className: css.boundaryError }, createElement(Sidebar, { ctx, store: sidebarStore })))
|
||||
mounted = true
|
||||
guardAnchor()
|
||||
scheduleHostCheck()
|
||||
} catch (error) {
|
||||
fail('mount', error)
|
||||
}
|
||||
}
|
||||
const sync = async (): Promise<void> => {
|
||||
if (disposed) return
|
||||
// Resolve the user's side card prefs BEFORE the first session seeds,
|
||||
// so a brand-new conversation opens (or stays closed) at the chosen
|
||||
// width from first paint. A settings route failure falls back to the
|
||||
@@ -124,20 +284,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')
|
||||
|
||||
@@ -147,7 +313,7 @@ export function apply(ctx: Context): void {
|
||||
return registerTurnTailInterception(ctx, sidebarStore)
|
||||
} catch (error) {
|
||||
fail('interception', error)
|
||||
return undefined
|
||||
return () => {}
|
||||
}
|
||||
},
|
||||
'dsh-better-sidebar: turn-tail interception',
|
||||
@@ -159,7 +325,7 @@ export function apply(ctx: Context): void {
|
||||
return registerOpenPathInterception(ctx, sidebarStore)
|
||||
} catch (error) {
|
||||
fail('interception', error)
|
||||
return undefined
|
||||
return () => {}
|
||||
}
|
||||
},
|
||||
'dsh-better-sidebar: open-path interception',
|
||||
@@ -168,22 +334,42 @@ 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.get('betterSidebar')?.openTab({ type, url, title })
|
||||
},
|
||||
selfOrigin: window.location.origin,
|
||||
})
|
||||
} catch (error) {
|
||||
fail('interception', error)
|
||||
return undefined
|
||||
return () => {}
|
||||
}
|
||||
},
|
||||
'dsh-better-sidebar: link interception',
|
||||
@@ -191,23 +377,34 @@ 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 {
|
||||
return registerImeGuard()
|
||||
} catch (error) {
|
||||
fail('ime guard', error)
|
||||
return undefined
|
||||
return () => {}
|
||||
}
|
||||
},
|
||||
'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
|
||||
|
||||
+90
-12
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { IconCodeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
import { firstLeaf, revealPaths, togglePanel, type SidebarStore } from './state.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { resolveSidebarPath, selectProducedFiles } from './produced-files.ts'
|
||||
import { wrapOpenPath } from './openpath-intercept.ts'
|
||||
@@ -22,15 +22,61 @@ export function openSidebarFile(ctx: Context, store: SidebarStore, sessionId: st
|
||||
const title = at === -1 ? absolute : absolute.slice(at + 1)
|
||||
// Route through the sidebar service so the editor descriptor's dedupeKey
|
||||
// (per-path) applies; the id is path-derived so multiple editors coexist.
|
||||
ctx.betterSidebar?.openTab({ type: 'editor', title, path: absolute, id: `editor:${absolute}` })
|
||||
ctx.get('betterSidebar')?.openTab({ type: 'editor', title, path: absolute, id: `editor:${absolute}` })
|
||||
}
|
||||
|
||||
/**
|
||||
* The produced files the turn-tail selector last matched for the visible
|
||||
* session. The "Show in folder" gesture carries no file path of its own
|
||||
* (`'.'`), so the reveal highlights exactly these rows when available.
|
||||
*/
|
||||
let lastProduced: readonly string[] = []
|
||||
|
||||
/**
|
||||
* Reveal the produced files in the sidebar explorer: expand their parent
|
||||
* directories, highlight the rows, and focus the explorer tab (expanding the
|
||||
* hosting panel when it is collapsed). Unknown files fall back to revealing
|
||||
* the workspace root itself.
|
||||
*/
|
||||
export function revealInExplorer(
|
||||
ctx: Context,
|
||||
store: SidebarStore,
|
||||
sessionId: string,
|
||||
files: readonly string[],
|
||||
): void {
|
||||
const summary = ctx.sessions.list.getSnapshot().byId[sessionId]
|
||||
const cwd = summary?.cwd
|
||||
// Deliverables report paths as-is (often relative to the session cwd), but
|
||||
// the explorer tree and revealPaths work on absolute paths — resolve every
|
||||
// target so the ancestors expand and the row actually matches.
|
||||
const targets = files.length > 0
|
||||
? files.map(path => resolveSidebarPath(cwd, path))
|
||||
: cwd === undefined ? [] : [cwd]
|
||||
store.reduce(state => revealPaths(state, cwd, targets))
|
||||
// A type-only open never auto-expands the panel (only content opens do,
|
||||
// see service.openTab) — so a reveal opens the panel itself when it is
|
||||
// collapsed, exactly like the subagent auto-open flows, or the highlight
|
||||
// would be set on an invisible panel.
|
||||
store.reduce(s => (s.panelOpen ? s : togglePanel(s)))
|
||||
// Pin the landing to the right panel: the files window must appear where
|
||||
// the panel just expanded, not in a bottom-panel pane the user last
|
||||
// touched.
|
||||
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
|
||||
// Focus the single-instance editor home tab (the files window) where the
|
||||
// reveal highlight renders. Read via ctx.get like every other internal
|
||||
// consumer (#357): the provider is not on this fiber chain, so a direct
|
||||
// ctx.betterSidebar read can throw before optional chaining applies.
|
||||
ctx.get('betterSidebar')?.openTab({ type: 'editor', title: t('files') })
|
||||
}
|
||||
|
||||
/** The intercepted produced-files row (visual twin of the deliverables chips). */
|
||||
export function SidebarProducedFiles(props: {
|
||||
matched: readonly string[]
|
||||
openInSidebar: (path: string) => void
|
||||
/** Reveal the produced files in the explorer ("Show in folder" twin). */
|
||||
onShowInFolder: (files: readonly string[]) => void
|
||||
}) {
|
||||
const { matched, openInSidebar } = props
|
||||
const { matched, openInSidebar, onShowInFolder } = props
|
||||
const shown = matched.slice(0, 6)
|
||||
const hidden = matched.length - shown.length
|
||||
return (
|
||||
@@ -53,42 +99,74 @@ export function SidebarProducedFiles(props: {
|
||||
)
|
||||
})}
|
||||
{hidden > 0 && <span className={css.producedMore}>+{hidden}</span>}
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.producedMore}
|
||||
style={{ cursor: 'pointer', textDecoration: 'underline', textUnderlineOffset: 2 }}
|
||||
onClick={() => { onShowInFolder(matched) }}
|
||||
>
|
||||
{t('showInFolder')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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)
|
||||
const matched = selectProducedFiles(owner)
|
||||
if (matched !== null) lastProduced = matched
|
||||
return matched
|
||||
},
|
||||
priority: -1,
|
||||
registrant: 'dsh-better-sidebar',
|
||||
inject: (sessionId: string) => ({
|
||||
openInSidebar: (path: string) => { openSidebarFile(ctx, store, sessionId, path) },
|
||||
onShowInFolder: (files: readonly string[]) => { revealInExplorer(ctx, store, sessionId, files) },
|
||||
}),
|
||||
}, SidebarProducedFiles)
|
||||
}, SidebarProducedFiles))
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the chat file-open interception: wraps `ctx.workspaces.openPath`
|
||||
* — the single funnel every chat-side file open goes through (tool-row path
|
||||
* links, the produced-files row, prose mentions) — so opens land in the
|
||||
* sidebar editor instead of the Host OS. Gated by BOTH the `interceptOpenPath`
|
||||
* pref and the editor tab's enable switch; declined opens fall through to
|
||||
* the original method. Returns the disposer restoring the original (HMR-safe).
|
||||
* sidebar editor instead of the Host OS. The folder-reveal gesture ("Show in
|
||||
* folder" passes `'.'`) is the one exception: it is routed to the explorer.
|
||||
* Gated by BOTH the `interceptOpenPath` pref and the editor tab's enable
|
||||
* switch; declined opens fall through to the original method. Returns the
|
||||
* disposer restoring the original (HMR-safe).
|
||||
*/
|
||||
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) },
|
||||
revealInExplorer: (_path, sessionId) => { revealInExplorer(ctx, store, sessionId, lastProduced) },
|
||||
})
|
||||
}
|
||||
|
||||
+133
-3
@@ -4,12 +4,12 @@
|
||||
* the CodeMirror language packages (bundled into the client).
|
||||
*/
|
||||
import { Language, LanguageSupport, StreamLanguage } from '@codemirror/language'
|
||||
import { javascript } from '@codemirror/lang-javascript'
|
||||
import { javascript, jsxLanguage, typescriptLanguage, tsxLanguage } from '@codemirror/lang-javascript'
|
||||
import { json } from '@codemirror/lang-json'
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import { python } from '@codemirror/lang-python'
|
||||
import { html } from '@codemirror/lang-html'
|
||||
import { css } from '@codemirror/lang-css'
|
||||
import { css, cssLanguage } from '@codemirror/lang-css'
|
||||
import { xml } from '@codemirror/lang-xml'
|
||||
import { yaml } from '@codemirror/lang-yaml'
|
||||
import { sql } from '@codemirror/lang-sql'
|
||||
@@ -18,11 +18,36 @@ import { cpp } from '@codemirror/lang-cpp'
|
||||
import { rust } from '@codemirror/lang-rust'
|
||||
import { go } from '@codemirror/lang-go'
|
||||
import { php } from '@codemirror/lang-php'
|
||||
import { vue } from '@codemirror/lang-vue'
|
||||
import { shell } from '@codemirror/legacy-modes/mode/shell'
|
||||
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, dart, scala, objectiveCpp } from '@codemirror/legacy-modes/mode/clike'
|
||||
import { swift } from '@codemirror/legacy-modes/mode/swift'
|
||||
import { sCSS, less } from '@codemirror/legacy-modes/mode/css'
|
||||
import { sass } from '@codemirror/legacy-modes/mode/sass'
|
||||
import { stylus } from '@codemirror/legacy-modes/mode/stylus'
|
||||
import { ruby } from '@codemirror/legacy-modes/mode/ruby'
|
||||
import { lua } from '@codemirror/legacy-modes/mode/lua'
|
||||
import { perl } from '@codemirror/legacy-modes/mode/perl'
|
||||
import { r } from '@codemirror/legacy-modes/mode/r'
|
||||
import { groovy } from '@codemirror/legacy-modes/mode/groovy'
|
||||
import { powerShell } from '@codemirror/legacy-modes/mode/powershell'
|
||||
import { diff } from '@codemirror/legacy-modes/mode/diff'
|
||||
import { protobuf } from '@codemirror/legacy-modes/mode/protobuf'
|
||||
import { cmake } from '@codemirror/legacy-modes/mode/cmake'
|
||||
import { pug } from '@codemirror/legacy-modes/mode/pug'
|
||||
import { tcl } from '@codemirror/legacy-modes/mode/tcl'
|
||||
import { haskell } from '@codemirror/legacy-modes/mode/haskell'
|
||||
import { clojure } from '@codemirror/legacy-modes/mode/clojure'
|
||||
import { erlang } from '@codemirror/legacy-modes/mode/erlang'
|
||||
import { julia } from '@codemirror/legacy-modes/mode/julia'
|
||||
import { pascal } from '@codemirror/legacy-modes/mode/pascal'
|
||||
import { vb } from '@codemirror/legacy-modes/mode/vb'
|
||||
import { vhdl } from '@codemirror/legacy-modes/mode/vhdl'
|
||||
import { stex } from '@codemirror/legacy-modes/mode/stex'
|
||||
|
||||
/** The lowercased file extension of a path ('' when none). */
|
||||
export function extOf(path: string): string {
|
||||
@@ -48,6 +73,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'
|
||||
@@ -58,10 +86,47 @@ export function languageKeyForExt(ext: string): string | null {
|
||||
case 'nginx': case 'conf': return 'nginx'
|
||||
case 'dockerfile': case 'docker': return 'dockerfile'
|
||||
case 'properties': case 'env': return 'properties'
|
||||
case 'vue': return 'vue'
|
||||
case 'scss': return 'scss'
|
||||
case 'sass': return 'sass'
|
||||
case 'less': return 'less'
|
||||
case 'styl': return 'stylus'
|
||||
case 'rb': return 'ruby'
|
||||
case 'lua': return 'lua'
|
||||
case 'pl': case 'pm': return 'perl'
|
||||
case 'r': return 'r'
|
||||
case 'dart': return 'dart'
|
||||
case 'scala': case 'sc': return 'scala'
|
||||
case 'groovy': return 'groovy'
|
||||
case 'ps1': case 'psm1': return 'powershell'
|
||||
case 'diff': case 'patch': return 'diff'
|
||||
case 'proto': return 'protobuf'
|
||||
case 'cmake': return 'cmake'
|
||||
case 'pug': return 'pug'
|
||||
case 'tcl': return 'tcl'
|
||||
case 'hs': return 'haskell'
|
||||
case 'clj': case 'cljs': return 'clojure'
|
||||
case 'erl': return 'erlang'
|
||||
case 'jl': return 'julia'
|
||||
case 'pas': return 'pascal'
|
||||
case 'vb': return 'vb'
|
||||
case 'vhd': return 'vhdl'
|
||||
case 'tex': return 'stex'
|
||||
case 'mm': return 'objectivecpp'
|
||||
// '.v' (Verilog/Coq/V) 与 '.m' (Objective-C/MATLAB) 存在跨语言歧义,
|
||||
// 故意不映射——错配高亮比纯文本更误导。
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-lang `<style>` parsers for the Vue SFC factory (built once, shared). */
|
||||
const VUE_STYLE_PARSERS = {
|
||||
scss: StreamLanguage.define(sCSS).parser,
|
||||
sass: StreamLanguage.define(sass).parser,
|
||||
less: StreamLanguage.define(less).parser,
|
||||
stylus: StreamLanguage.define(stylus).parser,
|
||||
}
|
||||
|
||||
const FACTORIES: Record<string, () => Language | LanguageSupport> = {
|
||||
js: () => javascript({ jsx: true }),
|
||||
jsx: () => javascript({ jsx: true }),
|
||||
@@ -76,6 +141,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(),
|
||||
@@ -86,10 +154,72 @@ const FACTORIES: Record<string, () => Language | LanguageSupport> = {
|
||||
nginx: () => StreamLanguage.define(nginx),
|
||||
dockerfile: () => StreamLanguage.define(dockerFile),
|
||||
properties: () => StreamLanguage.define(properties),
|
||||
// Vue SFC: lang-html's default html() base ALREADY parses <script>
|
||||
// (no type → JS, type="module" → JS, lang="ts" → TS, JSON/importmap MIME
|
||||
// types) and <style> → CSS through its built-in defaultNesting. The
|
||||
// explicit entries only ADD what the defaults miss — case-insensitive
|
||||
// lang="ts", lang="tsx"/"jsx" (the defaults only recognize the text/jsx &
|
||||
// text/typescript-jsx MIME types), and per-lang <style> preprocessor
|
||||
// dispatch. Same-tag entries are first-match wins and ours precede the
|
||||
// defaults, so the predicates stay narrow: a constant-true entry would
|
||||
// shadow the default JSON/importmap script handling and force the CSS
|
||||
// parser onto <style lang="stylus">.
|
||||
vue: () => vue({
|
||||
base: html({
|
||||
nestedLanguages: [
|
||||
{ tag: 'script', attrs: a => (a.lang ?? '').toLowerCase() === 'ts', parser: typescriptLanguage.parser },
|
||||
{ tag: 'script', attrs: a => (a.lang ?? '').toLowerCase() === 'tsx', parser: tsxLanguage.parser },
|
||||
{ tag: 'script', attrs: a => (a.lang ?? '').toLowerCase() === 'jsx', parser: jsxLanguage.parser },
|
||||
{ tag: 'style', attrs: a => (a.lang ?? '').toLowerCase() === 'css', parser: cssLanguage.parser },
|
||||
{ tag: 'style', attrs: a => (a.lang ?? '').toLowerCase() === 'scss', parser: VUE_STYLE_PARSERS.scss },
|
||||
{ tag: 'style', attrs: a => (a.lang ?? '').toLowerCase() === 'sass', parser: VUE_STYLE_PARSERS.sass },
|
||||
{ tag: 'style', attrs: a => (a.lang ?? '').toLowerCase() === 'less', parser: VUE_STYLE_PARSERS.less },
|
||||
{ tag: 'style', attrs: a => (a.lang ?? '').toLowerCase() === 'stylus', parser: VUE_STYLE_PARSERS.stylus },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
scss: () => StreamLanguage.define(sCSS),
|
||||
sass: () => StreamLanguage.define(sass),
|
||||
less: () => StreamLanguage.define(less),
|
||||
stylus: () => StreamLanguage.define(stylus),
|
||||
ruby: () => StreamLanguage.define(ruby),
|
||||
lua: () => StreamLanguage.define(lua),
|
||||
perl: () => StreamLanguage.define(perl),
|
||||
r: () => StreamLanguage.define(r),
|
||||
dart: () => StreamLanguage.define(dart),
|
||||
scala: () => StreamLanguage.define(scala),
|
||||
groovy: () => StreamLanguage.define(groovy),
|
||||
powershell: () => StreamLanguage.define(powerShell),
|
||||
diff: () => StreamLanguage.define(diff),
|
||||
protobuf: () => StreamLanguage.define(protobuf),
|
||||
cmake: () => StreamLanguage.define(cmake),
|
||||
pug: () => StreamLanguage.define(pug),
|
||||
tcl: () => StreamLanguage.define(tcl),
|
||||
haskell: () => StreamLanguage.define(haskell),
|
||||
clojure: () => StreamLanguage.define(clojure),
|
||||
erlang: () => StreamLanguage.define(erlang),
|
||||
julia: () => StreamLanguage.define(julia),
|
||||
pascal: () => StreamLanguage.define(pascal),
|
||||
vb: () => StreamLanguage.define(vb),
|
||||
vhdl: () => StreamLanguage.define(vhdl),
|
||||
stex: () => StreamLanguage.define(stex),
|
||||
objectivecpp: () => StreamLanguage.define(objectiveCpp),
|
||||
}
|
||||
|
||||
/** Every language key the extension table can produce (test seam). */
|
||||
export function supportedLanguageKeys(): readonly string[] {
|
||||
return Object.keys(FACTORIES)
|
||||
}
|
||||
|
||||
/** The CodeMirror language support for a path, or null for plain text. */
|
||||
export function languageForPath(path: string): Language | LanguageSupport | null {
|
||||
const key = languageKeyForExt(extOf(path))
|
||||
return key === null ? null : FACTORIES[key]!()
|
||||
if (key === null) return null
|
||||
try {
|
||||
return FACTORIES[key]!()
|
||||
} catch (error) {
|
||||
// A broken factory degrades to plain text, never crashes the editor.
|
||||
console.warn(`[dsh-better-sidebar] language factory "${key}" failed:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Size written to `--dsh-sidebar-width` / `--dsh-sidebar-height`.
|
||||
* The conversation column (output + composer) must keep at least
|
||||
* {@link PANEL_MIN} of the viewport after the bottom panel claims height.
|
||||
*/
|
||||
import { PANEL_MIN } from './state.ts'
|
||||
|
||||
export interface LayoutPushInput {
|
||||
narrow: boolean
|
||||
panelOpen: boolean
|
||||
bottomOpen: boolean
|
||||
width: number
|
||||
bottomHeight: number
|
||||
viewportWidth: number
|
||||
viewportHeight: number
|
||||
}
|
||||
|
||||
export interface LayoutPushSize {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
function finiteNonNegative(value: number): number {
|
||||
return Number.isFinite(value) ? Math.max(0, value) : 0
|
||||
}
|
||||
|
||||
/** Compute the live layout-push size. Narrow drawers float and push 0. */
|
||||
export function layoutPushSize(input: LayoutPushInput): LayoutPushSize {
|
||||
if (input.narrow) return { width: 0, height: 0 }
|
||||
const viewportWidth = finiteNonNegative(input.viewportWidth)
|
||||
const viewportHeight = finiteNonNegative(input.viewportHeight)
|
||||
const maxHeight = Math.max(0, viewportHeight - Math.min(PANEL_MIN, viewportHeight))
|
||||
return {
|
||||
width: input.panelOpen ? Math.min(finiteNonNegative(input.width), viewportWidth) : 0,
|
||||
height: input.bottomOpen ? Math.min(finiteNonNegative(input.bottomHeight), maxHeight) : 0,
|
||||
}
|
||||
}
|
||||
+70
-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,73 @@
|
||||
*/
|
||||
#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);
|
||||
/* Grid/flex items default to min-height:auto. A long unbreakable token
|
||||
(OAuth URL) then grows this column past the viewport and clips the
|
||||
composer + left-rail Settings row. min-height:0 lets the cell shrink;
|
||||
overflow-wrap lets the token wrap instead of forcing its intrinsic
|
||||
size. The host's own descendants remain responsible for scrolling. */
|
||||
min-height: 0;
|
||||
overflow-wrap: anywhere;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* The ar (Arabic) dictionary for the betterSidebar namespace.
|
||||
*
|
||||
* Mirrors the key set of `zh` in `locales.ts`. The sidebar's `t()`
|
||||
* consults this dict when `attachBetterLocale(store)` has been called
|
||||
* with an active better-locale store whose `active` is `'ar'`; absent
|
||||
* that, the existing zh/en chain runs unchanged.
|
||||
*
|
||||
* Translation conventions:
|
||||
* - Modern Standard Arabic (فصحى); UI text is RTL-ready (the framework
|
||||
* handles direction).
|
||||
* - Git vocabulary follows standard Arabic localizations (إدراج، تثبيت، فرع).
|
||||
* - Placeholders keep `{name}` verbatim (interpolation runs after lookup).
|
||||
* - English brand names (VS Code, Cursor, Zed, SSH, Git, HTML, PDF, Markdown)
|
||||
* stay as-is.
|
||||
*/
|
||||
|
||||
/** The ar dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
|
||||
export const ar: Record<string, string> = {
|
||||
files: 'الملفات',
|
||||
explorer: 'المستكشف',
|
||||
git: 'إدارة المصدر',
|
||||
terminal: 'الطرفية',
|
||||
editor: 'المحرّر',
|
||||
editorExplorer: 'سلوك فتح الملفات',
|
||||
editorExplorerDesc: 'يتحكّم في كيفية فتح الملفات',
|
||||
editorExplorerMerged: 'مدمج',
|
||||
editorExplorerMergedDesc: 'الملفات تتبدّل في مكانها داخل نفس النافذة؛ النوافذ الجديدة تبدأ بالشجرة مفتوحة',
|
||||
editorExplorerSplit: 'منفصل',
|
||||
editorExplorerSplitDesc: 'النوافذ بلا مسار هي المستكشف المستقل (شجرة فقط)؛ كل ملف يفتح نافذته الخاصة (الشجرة مرساة، مغلقة افتراضياً)',
|
||||
editorTreeToggle: 'لوحة شجرة الملفات',
|
||||
editorPathPlaceholder: 'مسار الملف (نسبي لدليل الجلسة أو مطلق)، Enter للفتح',
|
||||
editorSearchPlaceholder: 'البحث في الملفات بالاسم…',
|
||||
editorSearchNoResults: 'لا ملفات مطابقة',
|
||||
editorSearchTruncated: 'نتائج كثيرة جداً — عرض قائمة جزئية',
|
||||
editorEmptyHint: 'اختر ملفاً من لوحة الشجرة أو من حقل المسار بالأعلى لبدء المعاينة',
|
||||
openFileNewTab: 'فتح في تبويب جديد',
|
||||
openFileSide: 'فتح إلى الجانب',
|
||||
openWithMenu: 'فتح بواسطة',
|
||||
openWithSshSuffix: ' (SSH)',
|
||||
pinOpenWith: 'تثبيت في القائمة',
|
||||
unpinOpenWith: 'إلغاء التثبيت',
|
||||
openWithExplorer: 'مدير الملفات',
|
||||
openWithVscode: 'VS Code',
|
||||
openWithCursor: 'Cursor',
|
||||
openWithZed: 'Zed',
|
||||
openWithSettingsSshTitle: 'مضيف SSH البعيد',
|
||||
openWithSettingsSshDesc: 'فارغ = مساحة عمل محلية؛ مع user@host أو اسم مستعار SSH، تتحوّل فواتح عائلة VSCode إلى بروتوكول vscode-remote/ssh-remote ويُخفى مدير الملفات وZed والمحرّرات المخصّصة غير التابعة لعائلة VSCode من القائمة',
|
||||
openWithSettingsSshPlaceholder: 'user@host أو اسم مستعار SSH',
|
||||
openWithSettingsCustomTitle: 'محرّرات مخصّصة',
|
||||
openWithSettingsCustomDesc: 'اسم + قالب URL (عنصر نائب {path}) + علامة عائلة VSCode؛ في الوضع البعيد، يمكن فقط لمحرّرات عائلة VSCode فتح مسار بعيد',
|
||||
openWithSettingsAdd: 'إضافة',
|
||||
openWithSettingsName: 'الاسم',
|
||||
openWithSettingsTemplate: 'مثل cursor://file/{path}',
|
||||
openWithSettingsFamily: 'عائلة VSCode',
|
||||
openWithSettingsFamilyDesc: 'يتحدّث هذا المحرّر لهجة URL لـ VSCode (يدعم الفتح عبر SSH البعيد)',
|
||||
openWithSettingsRemove: 'إزالة',
|
||||
openWithSettingsInvalidHint: 'لن تظهر المحرّرات التي تفتقر إلى الاسم أو القالب (يلزم أن يحتوي على {path} ويبدأ بـ scheme://) في القائمة',
|
||||
newTab: 'تبويب جديد',
|
||||
openExplorer: 'المستكشف',
|
||||
brokenSymlink: 'رابط رمزي معطوب',
|
||||
openGit: 'لوحة Git',
|
||||
newTerminal: 'طرفية جديدة',
|
||||
terminalLimit: 'تم بلوغ حد الطرفيات (3)',
|
||||
close: 'إغلاق',
|
||||
closeOtherTabs: 'إغلاق التبويبات الأخرى',
|
||||
closeLeftTabs: 'إغلاق التبويبات على اليسار',
|
||||
closeRightTabs: 'إغلاق التبويبات على اليمين',
|
||||
moveToFreeWindow: 'النقل إلى نافذة حرة',
|
||||
floatDropHint: 'حرّك للفتح في نافذة حرة',
|
||||
dockToSidebar: 'العودة إلى الشريط الجانبي',
|
||||
pinTerminal: 'تثبيت الطرفية',
|
||||
pinAgentTerminal: 'تثبيت طرفية Agent',
|
||||
pinToWorkspace: 'تثبيت إلى مساحة العمل',
|
||||
pinToGlobal: 'تثبيت عام',
|
||||
unpinTerminal: 'إلغاء التثبيت',
|
||||
pinnedTerminalTooltip: '{kind} · {scope} · {cwd}',
|
||||
pinnedTerminalKindUi: 'واجهة طرفية',
|
||||
pinnedTerminalKindAgent: 'طرفية Agent',
|
||||
pinnedTerminalScopeWorkspace: 'مثبتة في مساحة العمل',
|
||||
pinnedTerminalScopeGlobal: 'مثبتة عام',
|
||||
pinnedRailLabel: 'الطرفيات المثبتة',
|
||||
closePinnedTerminal: 'إغلاق الطرفية',
|
||||
collapse: 'طي الشريط الجانبي',
|
||||
expand: 'توسيع الشريط الجانبي',
|
||||
collapseBottomPanel: 'طي اللوحة السفلية',
|
||||
expandBottomPanel: 'توسيع اللوحة السفلية',
|
||||
terminalError: 'فشل اتصال الطرفية',
|
||||
terminalConnectFailed: 'فشل اتصال الطرفية عدة مرات',
|
||||
terminalRetry: 'إعادة المحاولة',
|
||||
terminalDepsFailed: 'فشل تحميل تبعية الطرفية node-pty',
|
||||
terminalDepsHint: 'شغّل الأمر التالي في طرفية أو cmd على جهاز DSH للإصلاح، ثم أعد المحاولة (يبقى node-pty متزامناً مع إصدار نواة DSH):',
|
||||
terminalDepsProfile: ' (الملف الشخصي المكتشف: {profile})',
|
||||
preview: 'معاينة',
|
||||
toc: 'الفهرس',
|
||||
edit: 'تحرير',
|
||||
mermaidError: 'فشل رسم Mermaid',
|
||||
mermaidZoomIn: 'تكبير',
|
||||
mermaidZoomOut: 'تصغير',
|
||||
mermaidZoomReset: 'إعادة تعيين',
|
||||
mermaidZoomHint: 'العجلة للتقريب · السحب للتحريك · Esc للإغلاق',
|
||||
refresh: 'تحديث',
|
||||
showInFolder: 'إظهار في المجلد',
|
||||
refreshUnsavedConfirm: 'تغيّر الملف على القرص. سيؤدي التحديث إلى فقدان التعديلات غير المحفوظة. متابعة؟',
|
||||
save: 'حفظ',
|
||||
saved: 'تم الحفظ',
|
||||
unsaved: 'غير محفوظ',
|
||||
saveFailed: 'فشل الحفظ',
|
||||
truncation: 'الملف كبير جداً — عرض أول 512KB',
|
||||
binary: 'ملف ثنائي، المعاينة غير متاحة',
|
||||
loading: 'جارٍ التحميل…',
|
||||
error: 'فشل التحميل',
|
||||
retry: 'إعادة المحاولة',
|
||||
splitLeft: 'تقسيم يساراً',
|
||||
splitRight: 'تقسيم يميناً',
|
||||
splitUp: 'تقسيم لأعلى',
|
||||
splitDown: 'تقسيم لأسفل',
|
||||
notRepo: 'هذا الدليل ليس مستودع git',
|
||||
noChanges: 'لا تغييرات',
|
||||
statusTruncated: 'التغييرات كثيرة جدًا؛ يتم عرض أول 2000 إدخال فقط',
|
||||
stage: 'إدراج',
|
||||
unstage: 'إلغاء الإدراج',
|
||||
stageAll: 'إدراج الكل',
|
||||
unstageAll: 'إلغاء إدراج الكل',
|
||||
commitPlaceholder: 'رسالة التثبيت (Ctrl+Enter)',
|
||||
commit: 'تثبيت',
|
||||
commitError: 'فشل التثبيت',
|
||||
branch: 'الفرع',
|
||||
worktree: 'شجرة العمل',
|
||||
checkoutError: 'فشل تبديل الفرع',
|
||||
history: 'السجل',
|
||||
changes: 'التغييرات',
|
||||
staged: 'مُدرج',
|
||||
unstaged: 'غير مُدرج',
|
||||
cancel: 'إلغاء',
|
||||
diffEmpty: 'لا تغييرات نصية',
|
||||
diffLoadError: 'فشل تحميل الفرق',
|
||||
diffBinary: 'ثنائي',
|
||||
diffAdded: 'مُضاف',
|
||||
diffDeleted: 'محذوف',
|
||||
diffRenamed: 'إعادة تسمية',
|
||||
diffExpand: 'إظهار {count} صفوف أخرى',
|
||||
diffCollapse: 'طي',
|
||||
discard: 'تجاهل التغييرات',
|
||||
discardTitle: 'تجاهل التغييرات',
|
||||
discardDesc: 'هذا يتجاهل تغييرات مساحة العمل في "{path}" (غير قابل للاستعادة).',
|
||||
viewCommitDiff: 'عرض فرق التثبيت',
|
||||
copyShortHash: 'نسخ الهاش القصير',
|
||||
copyFullHash: 'نسخ الهاش الكامل',
|
||||
copySubject: 'نسخ موضوع التثبيت',
|
||||
revertCommit: 'عكس التثبيت',
|
||||
revertTitle: 'عكس التثبيت',
|
||||
revertDesc: 'ينشئ تثبيتاً جديداً على الفرع الحالي يعكس "{subject}".',
|
||||
cherryPickCommit: 'اقتطاف التثبيت',
|
||||
cherryPickTitle: 'اقتطاف التثبيت',
|
||||
cherryPickDesc: 'يطبّق تغييرات "{subject}" على الفرع الحالي.',
|
||||
timeJustNow: 'الآن',
|
||||
timeMinutesAgo: 'منذ {n} دقيقة',
|
||||
timeHoursAgo: 'منذ {n} ساعة',
|
||||
timeYesterday: 'أمس',
|
||||
loadMore: 'تحميل المزيد',
|
||||
historyLoadError: 'فشل تحميل المزيد من السجل',
|
||||
produced: 'النواتج',
|
||||
producedOpen: 'فتح في الشريط الجانبي',
|
||||
disconnected: 'انقطع اتصال الطرفية، جارٍ إعادة الاتصال…',
|
||||
exited: 'خرجت عملية الطرفية',
|
||||
noSession: 'اختر محادثة لاستخدام الشريط الجانبي',
|
||||
pluginNotLoaded: 'الإضافة غير محمّلة؛ التبويب غير متاح:',
|
||||
hiddenFiles: 'ملفات مخفية',
|
||||
parent: 'الدليل الأصل',
|
||||
copied: 'تم النسخ',
|
||||
copy: 'نسخ',
|
||||
newFile: 'ملف جديد',
|
||||
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: 'الحصة الافتراضية للعرض',
|
||||
settingsWidthDesc: 'الحصة الافتراضية للبطاقة الجانبية من عرض النافذة للمحادثات الجديدة (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'فتح ملفات المحادثة في الشريط الجانبي',
|
||||
settingsOpenPathDesc: 'فتح روابط الملفات في المحادثة (صفوف الأدوات، الملفات الناتجة، الإشارات) في محرّر الشريط الجانبي بدلاً من التطبيق الافتراضي للنظام',
|
||||
settingsOpenToolsTitle: 'حقن أداة فتح الشريط الجانبي للنموذج',
|
||||
settingsOpenToolsDesc: 'عند التفعيل، يمكن للنموذج فتح الملفات والمجلدات وصفحات HTTP(S) في الشريط الجانبي عبر أداة sidebar_open (معطّل افتراضياً)',
|
||||
settingsTitleBarTitle: 'وضع توافق الموضع',
|
||||
settingsTitleBarDesc: 'اختر نظام توافق الشريط العلوي: كشف تلقائي (افتراضي، محافظ) / ويب DSH الرسمي / أغلفة سطح المكتب المعروفة / مخصّص (مسافة الإزاحة + CSS مخصّص)',
|
||||
settingsTitleBarStripTitle: 'مسافة الإزاحة',
|
||||
settingsTitleBarStripDesc: 'ارتفاع شريط الشريط العلوي: كم بكسل تتحرك به أزرار الشريط الجانبي والمحتوى للأسفل (0–120، افتراضي 40؛ يسري تحت النظام المخصّص)',
|
||||
settingsSchemeAutoTitle: 'كشف تلقائي',
|
||||
settingsSchemeAutoDesc: 'محافظ: يساهم فقط معيار Window Controls Overlay API (ارتفاع التسمية التوضيحية الحقيقي)؛ بيئات الويب العادية لا تعدّل شيئاً',
|
||||
settingsSchemeWebTitle: 'ويب DSH الرسمي',
|
||||
settingsSchemeWebDesc: 'صراحة: الإعلان عن تشغيل في واجهة الويب الرسمية: لا أي تكيّف (حتى هندسة WCO القياسية لا تطبّق)',
|
||||
settingsSchemeCustomTitle: 'مخصّص',
|
||||
settingsSchemeCustomDesc: 'تحكّم كامل: حقن CSS مخصّص (يمكن أن يلغي الأنماط المدمجة) وتحديد مسافة إزاحة الشريط العلوي',
|
||||
settingsSchemeDetectedSuffix: 'تم الكشف',
|
||||
settingsCustomCssTitle: 'CSS مخصّص',
|
||||
settingsCustomCssDesc: 'أنماط تُضاف في نهاية الصفحة (المكتوب لاحقاً يكسر التعادل في نفس الأولوية؛ استخدم !important لتلغي المتغيرات المكتوبة بواسطة JS)',
|
||||
settingsCustomCssPlaceholder: '/* مثال: حجز 36px لغلاف له شريط علوي مرسوم ذاتياً */\nhtml[data-dsh-title-bar-height="36"] {\n --dsh-title-bar-strip: 36px !important;\n}',
|
||||
settingsSaveFailed: 'فشل الحفظ',
|
||||
settingsConflict: 'تغيّر الإعداد في نافذة أخرى — يُرجى إعادة المحاولة',
|
||||
binaryNoPreview: 'لا يمكن معاينة هذا النوع من الملفات',
|
||||
downloadToView: 'تنزيل للعرض',
|
||||
settingsSubagentTitle: 'فتح صفحة المهام تلقائياً عند ظهور وكيل فرعي',
|
||||
settingsSubagentDesc: 'توسيع البطاقة الجانبية وفتح صفحة المهام عند توليد المحادثة الحالية لوكيل فرعي جديد؛ أوقفها للفتح يدوياً',
|
||||
settingsJobsTitle: 'فتح صفحة المهام الخلفية تلقائياً عند مهمة خلفية جديدة',
|
||||
settingsJobsDesc: 'توسيع البطاقة الجانبية وفتح صفحة المهام الخلفية عند ظهور مهمة خلفية جديدة للمحادثة الحالية (كل مهمة جديدة تُطلق ذلك)؛ أوقفها للفتح يدوياً',
|
||||
settingsToolsTitle: 'حقن أدوات الطرفية للنموذج',
|
||||
settingsToolsDesc: 'عند التفعيل، يمكن للنموذج إنشاء وتشغيل طرفيات الشريط الجانبي عبر أدوات terminal_* الثمانية (معطّل افتراضياً)',
|
||||
settingsBottomTerminalTitle: 'فتح طرفية تلقائياً عند أول توسيع للوحة السفلية',
|
||||
settingsBottomTerminalDesc: 'عند توسيع اللوحة السفلية لأول مرة في الجلسة، حاول فتح تبويب طرفية جديد هناك (حصة الطرفيات ما زالت تسري؛ مفعّل افتراضياً)',
|
||||
settingsFontFamilyTitle: 'عائلة خط الطرفية',
|
||||
settingsFontFamilyDesc: 'عائلة خط الطرفية المخصّصة (مكدس CSS font-family مثل "JetBrains Mono", monospace؛ اتركه فارغاً لاتّباع خط السمة أحادي المسافة)',
|
||||
settingsFontFamilyPlaceholder: '"JetBrains Mono", monospace',
|
||||
settingsFontSizeTitle: 'حجم خط الطرفية',
|
||||
settingsFontSizeDesc: 'حجم خط الطرفية بالبكسل (9–32، افتراضي 13)',
|
||||
settingsFontSizeSuffix: 'px',
|
||||
settingsShellTitle: 'مسار الصدفة',
|
||||
settingsShellDesc: 'الصدفة التي تُطلق لطرفيات الواجهة والنموذج (مسار مطلق أو اسم تنفيذي مجرّد). الفراغ يحافظ على الترتيب القائم: yaml config.shell ← $SHELL / صدفة الدخول / Windows powershell.exe. يسري على الطرفيات المفتوحة لاحقاً',
|
||||
settingsShellPlaceholder: 'مثل /bin/zsh (فارغ = تلقائي)',
|
||||
settingsShellArgsTitle: 'وسائط الصدفة',
|
||||
settingsShellArgsDesc: 'وسائط صدفة صريحة، مفصولة بمسافات؛ عند عدم الفراغ تستبدل الافتراضيات بالكامل (نفس عقد yaml shellArgs)',
|
||||
settingsShellArgsPlaceholder: 'مثل -l (فارغ = الافتراضيات)',
|
||||
settingsTabsTitle: 'محتوى الشريط الجانبي',
|
||||
settingsViewersTitle: 'عارضات الملفات',
|
||||
settingsGeneralTitle: 'عام',
|
||||
settingsPopup: 'إعدادات الميزة',
|
||||
settingsViewerCatchAll: 'شامل: أي ملف',
|
||||
viewerImage: 'صورة',
|
||||
viewerPdf: 'PDF',
|
||||
viewerMarkdown: 'Markdown',
|
||||
viewerCode: 'كود',
|
||||
viewerBinary: 'تنزيل ثنائي',
|
||||
viewerHtml: 'HTML',
|
||||
browser: 'المتصفح',
|
||||
browserPlaceholder: 'أدخل عنوان URL، مثل example.com',
|
||||
browserGo: 'اذهب',
|
||||
browserBack: 'للخلف',
|
||||
browserForward: 'للأمام',
|
||||
browserStart: 'أدخل عنوان URL لبدء التصفح (وضع الحماية)',
|
||||
browserBlockedScheme: 'محظور: يُسمح فقط بعناوين URL من نوع http/https',
|
||||
browserBlockedLoopback: 'محظور: لا يمكن تصفّح العناوين المحلية والداخلية هنا',
|
||||
browserInvalid: 'عنوان URL غير صالح',
|
||||
browserNoSandboxWarning: 'الحماية معطّلة: الصفحة الحالية تعمل بكامل صلاحيات الواجهة (يمكن إعادة تفعيلها في الإعدادات)',
|
||||
htmlNoSandboxWarning: 'الحماية معطّلة: يعمل هذا HTML بنفس أصل الواجهة — يمكنه قراءة ملفات الجلسة وواجهات API الداخلية (يمكن إعادة تفعيلها في الإعدادات)',
|
||||
sandboxStatusOn: 'وضع الحماية: مفعّل · لا يمكن للصفحات الوصول إلى بيانات الواجهة أو الملفات المحلية؛ قد لا تعمل تسجيلات الدخول وملفات الارتباط التابعة لجهات خارجية',
|
||||
sandboxUnlock: 'تعطيل مؤقت (غير آمن)',
|
||||
sandboxRestore: 'استعادة الحماية',
|
||||
settingsHtmlDefaultUnsafeTitle: 'فتح معاينات HTML بدون حماية افتراضياً (غير آمن)',
|
||||
settingsHtmlDefaultUnsafeDesc: 'عند التفعيل، تبدأ كل معاينة HTML مفتوحة حديثاً في الحالة غير المحمية (نفس أصل الواجهة — يمكنها قراءة ملفات الجلسة وواجهات API الداخلية)؛ يوفر صف الحالة استعادة بلمسة واحدة',
|
||||
settingsHtmlSandboxTitle: 'تعطيل حماية معاينة HTML (غير آمن)',
|
||||
settingsHtmlSandboxDesc: 'مع تعطيل الحماية، يعمل HTML المعاين بنفس أصل الواجهة: يمكنه قراءة ملفات الجلسة والتخزين المحلي واستدعاء واجهات API الداخلية. فعّله فقط للملفات الموثوقة بالكامل',
|
||||
settingsBrowserSandboxTitle: 'تعطيل حماية المتصفح (غير آمن)',
|
||||
settingsBrowserSandboxDesc: 'مع تعطيل الحماية، يعمل أي موقع تزوره بنفس أصل الواجهة: يمكنه قراءة بيانات الجلسة وانتحال جلسة تسجيل الدخول الخاصة بك. فعّله فقط للمواقع الموثوقة بالكامل',
|
||||
settingsBrowserLinksTitle: 'فتح الروابط الخارجية للمحادثة في الشريط الجانبي',
|
||||
settingsBrowserLinksDesc: 'عند التفعيل، النقر على رابط خارجي في المحادثة أو الواجهة يفتح الشريط الجانبي بدلاً من نافذة جديدة؛ يتحكّم في HTTP وHTTPS بشكل منفصل بالمبدلات بالأسفل؛ Ctrl/Cmd+نقر يتجاوز دائماً',
|
||||
settingsBrowserHttpTitle: 'فتح صفحات HTTP في الشريط الجانبي',
|
||||
settingsBrowserHttpDesc: 'عند التفعيل، النقر على رابط HTTP خارجي في المحادثة أو الواجهة يفتح الشريط الجانبي (صفحات الإضافات المعرّفة عن urlTarget تفوز)؛ Ctrl/Cmd+نقر يتجاوز دائماً',
|
||||
settingsBrowserHttpsTitle: 'فتح صفحات HTTPS في الشريط الجانبي',
|
||||
settingsBrowserHttpsDesc: 'عند التفعيل، النقر على رابط HTTPS خارجي في المحادثة أو الواجهة يفتح الشريط الجانبي. معطّل افتراضياً: معظم مواقع HTTPS ترفض التضمين، لذا فالمتصفح الافتراضي للنظام هو الخيار الأنسب',
|
||||
settingsBrowserLoopbackTitle: 'العناوين المحلية المسموح بها',
|
||||
settingsBrowserLoopbackDesc: 'قائمة بيضاء مفصولة بفواصل لعناوين الاسترجاع المحلي (مثل localhost:5174 أو 127.0.0.1:8080) يمكن للمتصفح الجانبي زيارتها؛ فارغة افتراضيًا تمنع جميع العناوين المحلية. تظل صندوق الحماية سارية — لا يمكن للصفحات قراءة بيانات الواجهة',
|
||||
settingsBrowserLoopbackPlaceholder: 'مثال localhost:5174, 127.0.0.1:8080',
|
||||
browserOpenExternal: 'فتح في المتصفح',
|
||||
browserEmbedBlocked: 'رفض {host} التضمين',
|
||||
browserEmbedBlockedDesc: 'يحظر الموقع عرضه داخل صفحات أخرى (X-Frame-Options / frame-ancestors)، لذا لا يمكن تحميله في الشريط الجانبي. افتحه مباشرة في متصفحك بدلاً من ذلك.',
|
||||
browserEmbedAnyway: 'تحميل على أي حال',
|
||||
subagent: 'المهام',
|
||||
openSubagent: 'المهام',
|
||||
subagentMainAgent: 'الوكيل الرئيسي',
|
||||
subagentEmpty: 'لا وكلاء فرعيون',
|
||||
subagentEmptyDesc: 'الوكلاء الفرعيون المُنتَجون تحت الوكيل الرئيسي سيظهرون هنا',
|
||||
subagentRunning: 'قيد التشغيل',
|
||||
subagentInactive: 'خامل',
|
||||
subagentModeOneShot: 'مرة واحدة',
|
||||
subagentModeContinuable: 'قابل للاستئناف',
|
||||
subagentCount: '{count} وكلاء فرعيون',
|
||||
subagentCountRunning: '{count} وكلاء فرعيون · {running} قيد التشغيل',
|
||||
subagentDiagCorrupt: 'تالف',
|
||||
subagentDiagUnsupported: 'غير مدعوم',
|
||||
subagentDiagUnavailable: 'غير متاح',
|
||||
subagentThinking: 'جارٍ التفكير…',
|
||||
sideChat: 'محادثة جانبية (تجريبية)',
|
||||
sideChatNew: 'خيط جديد',
|
||||
sideChatUntitled: 'خيط جديد',
|
||||
sideChatEmpty: 'لا محادثات جانبية',
|
||||
sideChatEmptyDesc: 'كل محادثة جانبية هي تبويب مستقل في شريط التبويبات — ترث سياق الجلسة الحالية ولا تدخل المحادثة الرئيسية',
|
||||
sideChatCreating: 'جارٍ إنشاء محادثة جانبية…',
|
||||
sideChatRetry: 'إعادة المحاولة',
|
||||
sideChatThreads: 'تبديل خيط / جديد',
|
||||
sideChatSave: 'حفظ كجلسة جديدة',
|
||||
sideChatSaveTitle: 'ترقية هذا الخيط إلى جلسة عالية المستوى في قائمة الجلسات الرئيسية',
|
||||
sideChatSaved: 'تم الحفظ كجلسة جديدة',
|
||||
sideChatNoTurn: 'الحفظ متاح بعد أول دور مكتمل',
|
||||
sideChatPendingDrop: 'لن يُضمَّ آخر متابعة لم يُجَب عنها في الجلسة المحفوظة',
|
||||
sideChatFirstPlaceholder: 'اطرح السؤال الأول — السياق موروث…',
|
||||
sideChatComposerPlaceholder: 'متابعة…',
|
||||
sideChatThinking: 'جارٍ التعمّق…',
|
||||
sideChatThink: 'التفكير',
|
||||
sideChatInjection: 'تم حقن السياق',
|
||||
sideChatSend: 'إرسال',
|
||||
sideChatCancel: 'إيقاف',
|
||||
sideChatCancelTitle: 'إلغاء الدور الجاري (يُحتفظ بالعمل في قائمة الانتظار)',
|
||||
sideChatClose: 'إغلاق الخيط',
|
||||
sideChatCloseTitle: 'تحرير وكيل الخيط (يُحتفظ بالسجل)',
|
||||
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: 'إضافة إضافات تبويب',
|
||||
addPluginsTabCardDesc: 'تسجيل صفحة شريط جانبي جديدة',
|
||||
addPluginsViewerCard: 'إضافة إضافات معاينة',
|
||||
addPluginsViewerCardDesc: 'تسجيل معاينة نوع ملف',
|
||||
addPluginsTabDesc: 'يمكن توسيع صفحات الشريط الجانبي (التبويبات) بواسطة الإضافات. تُسجَّل الإضافات عبر خدمة ctx.betterSidebar؛ النقر على «تثبيت» ينسخ أمر التثبيت — الصقه في طرفية حيث يقيم ملف DSH الشخصي وشغّله.',
|
||||
addPluginsViewerDesc: 'يمكن توسيع عارضات الملفات بواسطة الإضافات. تُسجَّل الإضافات عبر خدمة ctx.betterSidebar؛ النقر على «تثبيت» ينسخ أمر التثبيت — الصقه في طرفية حيث يقيم ملف DSH الشخصي وشغّله.',
|
||||
addPluginsBrowseMore: 'تصفّح المزيد من الإضافات على GitHub (topic: dsh-better-sidebar)',
|
||||
addPluginsSearch: 'البحث باسم الإضافة / الوصف…',
|
||||
addPluginsNoMatch: 'لا إضافات مطابقة',
|
||||
addPluginsRecommended: 'إضافات موصى بها',
|
||||
addPluginsEmpty: 'لا إضافات منسّقة بعد — انشر إضافتك تحت موضوع GitHub',
|
||||
openPlugin: 'فتح',
|
||||
copyInstall: 'نسخ أمر التثبيت',
|
||||
pluginOfficeDesc: 'معاينة حزمة Office (.docx / .xlsx / .pptx) لمحرّر better-sidebar، مع إبقاء مكتبات عرض Office الثقيلة خارج الحزمة الأساسية، تُثبَّت عند الطلب',
|
||||
pluginFlowglassDesc: 'مخطط تدفق جلسة حيّ بثلاثة مسارات للمستخدم والمساعد واستدعاءات الأدوات، مع مجموعات متوازية وفروع وكلاء فرعيين وتعمّق تدريجي وحالة حيّة؛ يُسجِّل تبويب Flowglass الأصلي عند تثبيت better-sidebar ويحتفظ بدرجه المستقل كاحتياط',
|
||||
pluginGitForgeDesc: 'تبويب Git Forge: مكتبة حسابات GitHub/Gitea (وغيرها من Forges) + منح لكل مشروع + سياسة دفع صارمة؛ تبقى الرموز في الأسرار المحلية (لا تدخل سياق النموذج)؛ أداة GitForge للقراءة فقط ومساعد بيانات اعتماد HTTPS للوكيل',
|
||||
pluginGitRemotesDesc: 'تبويب Git Remotes: فرع/أصل/أمام-خلف، fetch (مع prune اختياري)، ff-only pull، والدفع فقط بعد تأكيد داخل التبويب. لا يستبدل تبويب Git المدمج للإدراج/التثبيت، ولا يقدّم force-push أو أداة دفع آلي للنموذج',
|
||||
pluginSentinelDesc: 'نظام إيقاظ وكيل يحرّكه تحقّق الشروط: مستشعرات ملف/عملية/منفذ/HTTP/أمر/webhook توقظ الجلسات الخاملة عند تحقّق الشرط؛ يُسجِّل تبويب «Sentinel» مع جدول مراقبة شامل للخادم',
|
||||
pluginSidebarQaDesc: 'حدِّد واسأل: تحديد نص المحادثة ← السؤال في اللوحة اليمنى ← جلسة متابعات مخصّصة في نفس مساحة العمل (❓ متابعة · موضوع): نموذج سريع بلا تفكير يضغط سياق المحادثة الرئيسي ويحقنه مع الاقتباس دون مقاطعة المحادثة الرئيسية؛ المتابعات تتداخل وتستمر وتُؤرشف',
|
||||
pluginSshTunnelDesc: 'تبويب «SSH Tunnel»: قائمة مضيفين متعددين + منح لكل مشروع + أسرار محلية؛ أداة SSHManager للنموذج (exec/SFTP/استراتيجيات الجلسة)؛ طرفية تفاعلية مركزية وSFTP ذو عمودين',
|
||||
pluginTurnReviewDesc: 'بوابة بشرية على فرق «الدور المنتهي للتو»: Approve / Request changes لكل مسار مع تعليق اختياري؛ المسارات مجمّعة حسب الجلسة الرئيسية / الوكيل الفرعي / غير منسوب؛ فرق snapshot-vs-now مضمّن قبل القرار. لا تفريع، لا /rewind',
|
||||
pluginVideoPreviewDesc: 'معاينة فيديو مضمّنة (.mp4/.webm/.mov/.mkv/.avi إلخ) في محرّر better-sidebar، مدعومة بمسار /video مخصّص يدعم HTTP Range (206) — التحكم بالتشغيل يعمل والملفات غير مقيدة بحد mediaLimit البالغ 20MB',
|
||||
pluginDocsPanelDesc: '«وثائق عامة» في شريط DSH الجانبي: ملاحظات Markdown عامة، قابلة للقراءة من أي مساحة عمل — قائمة ملفات، مخطط تفصيلي، فتح في Chrome / VS Code، وأزرار نسخ؛ دليل الوثائق قابل للتكوين (الافتراضي ~/.dsh/docs)',
|
||||
pluginEgoBrowserDesc: 'متصفح الوكيل لـ DeepSeek Harness: 32 أداة ego_* تقود متصفح Chromium حقيقي، مع تبويب «متصفح ego» أصلي في الشريط الجانبي يعرض مباشرة كل صفحة يزورها الوكيل — يمكنك النقر والسحب والكتابة لتولي التحكم. يسجّل التبويب تلقائيًا عند وجود better-sidebar، وإلا يظهر كفقاعة عائمة',
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* German dictionary for better-sidebar.
|
||||
*/
|
||||
export const de: Record<string, string> = {
|
||||
files: 'Dateien',
|
||||
explorer: 'Explorer',
|
||||
git: 'Quellcodeverwaltung',
|
||||
terminal: 'Terminal',
|
||||
editor: 'Editor',
|
||||
editorExplorer: 'Dateiöffnungsverhalten',
|
||||
editorExplorerDesc: 'Steuert, wie Dateien geöffnet werden',
|
||||
editorExplorerMerged: 'Zusammengeführt',
|
||||
editorExplorerMergedDesc: 'Dateien wechseln im selben Fenster; neue Fenster starten mit geöffneter Baumansicht',
|
||||
editorExplorerSplit: 'Getrennt',
|
||||
editorExplorerSplitDesc: 'Fenster ohne Pfad sind der eigenständige Explorer (nur Dateibaum); jede Datei öffnet ein eigenes Fenster (mit angedocktem, standardmäßig eingeklapptem Baum)',
|
||||
editorTreeToggle: 'Dateibaum-Panel',
|
||||
editorPathPlaceholder: 'Dateipfad (relativ zum Sitzungsverzeichnis oder absolut), Enter zum Öffnen',
|
||||
editorSearchPlaceholder: 'Nach Dateiname suchen…',
|
||||
editorSearchNoResults: 'Keine passenden Dateien',
|
||||
editorSearchTruncated: 'Zu viele Ergebnisse – nur ein Teil wird angezeigt',
|
||||
editorEmptyHint: 'Wählen Sie eine Datei aus dem Dateibaum rechts oder dem Pfadeingabefeld oben, um mit der Vorschau zu beginnen',
|
||||
openFileNewTab: 'In neuem Tab öffnen',
|
||||
openFileSide: 'Seitlich öffnen',
|
||||
openWithMenu: 'Öffnen mit',
|
||||
openWithSshSuffix: ' (SSH)',
|
||||
pinOpenWith: 'Im Menü anheften',
|
||||
unpinOpenWith: 'Lösen',
|
||||
openWithExplorer: 'Dateimanager',
|
||||
openWithVscode: 'VS Code',
|
||||
openWithCursor: 'Cursor',
|
||||
openWithZed: 'Zed',
|
||||
openWithSettingsSshTitle: 'SSH-Remote-Host',
|
||||
openWithSettingsSshDesc: 'Leer = lokaler Arbeitsbereich; bei Angabe von user@host oder eines SSH-Alias verwenden die VSCode-Familienöffner das vscode-remote/ssh-remote-Protokoll, und Dateimanager / Zed / benutzerdefinierte Editoren außerhalb der VSCode-Familie werden im Menü ausgeblendet',
|
||||
openWithSettingsSshPlaceholder: 'user@host oder SSH-Alias',
|
||||
openWithSettingsCustomTitle: 'Benutzerdefinierte Editoren',
|
||||
openWithSettingsCustomDesc: 'Name + URL-Vorlage ({path}-Platzhalter) + VSCode-Familien-Kennzeichen; im SSH-Modus können nur Editoren der VSCode-Familie Remote-Pfade öffnen',
|
||||
openWithSettingsAdd: 'Hinzufügen',
|
||||
openWithSettingsName: 'Name',
|
||||
openWithSettingsTemplate: 'z. B. cursor://file/{path}',
|
||||
openWithSettingsFamily: 'VSCode-Familie',
|
||||
openWithSettingsFamilyDesc: 'Dieser Editor spricht das VSCode-URL-Protokoll (unterstützt SSH-Remote-Öffnen)',
|
||||
openWithSettingsRemove: 'Entfernen',
|
||||
openWithSettingsInvalidHint: 'Editoren ohne Namen oder mit Vorlage ohne {path} / scheme:// erscheinen nicht im Menü',
|
||||
newTab: 'Neuer Tab',
|
||||
openExplorer: 'Explorer',
|
||||
brokenSymlink: 'Defekter Symlink',
|
||||
openGit: 'Git-Panel',
|
||||
newTerminal: 'Neues Terminal',
|
||||
terminalLimit: 'Terminal-Limit erreicht (3)',
|
||||
close: 'Schließen',
|
||||
closeOtherTabs: 'Andere Tabs schließen',
|
||||
closeLeftTabs: 'Tabs links schließen',
|
||||
closeRightTabs: 'Tabs rechts schließen',
|
||||
moveToFreeWindow: 'In freies Fenster verschieben',
|
||||
floatDropHint: 'Loslassen, um in einem freien Fenster zu öffnen',
|
||||
dockToSidebar: 'Zurück zur Seitenleiste',
|
||||
pinTerminal: 'Terminal anheften',
|
||||
pinAgentTerminal: 'Agent-Terminal anheften',
|
||||
pinToWorkspace: 'An Arbeitsbereich anheften',
|
||||
pinToGlobal: 'Global anheften',
|
||||
unpinTerminal: 'Lösen',
|
||||
pinnedTerminalTooltip: '{kind} · {scope} · {cwd}',
|
||||
pinnedTerminalKindUi: 'UI-Terminal',
|
||||
pinnedTerminalKindAgent: 'Agent-Terminal',
|
||||
pinnedTerminalScopeWorkspace: 'An Arbeitsbereich angeheftet',
|
||||
pinnedTerminalScopeGlobal: 'Global angeheftet',
|
||||
pinnedRailLabel: 'Angeheftete Terminals',
|
||||
closePinnedTerminal: 'Terminal schließen',
|
||||
collapse: 'Seitenleiste einklappen',
|
||||
expand: 'Seitenleiste ausklappen',
|
||||
collapseBottomPanel: 'Unteres Panel einklappen',
|
||||
expandBottomPanel: 'Unteres Panel ausklappen',
|
||||
terminalError: 'Terminalverbindung fehlgeschlagen',
|
||||
terminalConnectFailed: 'Terminal konnte wiederholt keine Verbindung herstellen',
|
||||
terminalRetry: 'Erneut versuchen',
|
||||
terminalDepsFailed: 'Die Terminal-Abhängigkeit node-pty konnte nicht geladen werden',
|
||||
terminalDepsHint: 'Führen Sie den folgenden Befehl in einem Terminal oder in cmd auf dem DSH-System aus, um dies zu beheben, und klicken Sie dann auf „Erneut versuchen“ (node-pty bleibt mit der DSH-Core-Version synchron):',
|
||||
terminalDepsProfile: ' (erkanntes Profil: {profile})',
|
||||
preview: 'Vorschau',
|
||||
toc: 'Inhaltsverzeichnis',
|
||||
edit: 'Bearbeiten',
|
||||
mermaidError: 'Mermaid-Rendering fehlgeschlagen',
|
||||
mermaidZoomIn: 'Vergrößern',
|
||||
mermaidZoomOut: 'Verkleinern',
|
||||
mermaidZoomReset: 'Zurücksetzen',
|
||||
mermaidZoomHint: 'Scrollen zum Zoomen · Ziehen zum Verschieben · Esc zum Schließen',
|
||||
refresh: 'Aktualisieren',
|
||||
showInFolder: 'Im Ordner anzeigen',
|
||||
refreshUnsavedConfirm: 'Die Datei wurde geändert. Beim Aktualisieren gehen ungespeicherte Änderungen verloren. Fortfahren?',
|
||||
save: 'Speichern',
|
||||
saved: 'Gespeichert',
|
||||
unsaved: 'Nicht gespeichert',
|
||||
saveFailed: 'Speichern fehlgeschlagen',
|
||||
truncation: 'Datei zu groß – nur die ersten 512KB werden angezeigt',
|
||||
binary: 'Binärdatei, Vorschau nicht verfügbar',
|
||||
loading: 'Wird geladen…',
|
||||
error: 'Laden fehlgeschlagen',
|
||||
retry: 'Erneut versuchen',
|
||||
splitLeft: 'Nach links teilen',
|
||||
splitRight: 'Nach rechts teilen',
|
||||
splitUp: 'Nach oben teilen',
|
||||
splitDown: 'Nach unten teilen',
|
||||
notRepo: 'Dieses Verzeichnis ist kein Git-Repository',
|
||||
noChanges: 'Keine Änderungen',
|
||||
statusTruncated: 'Zu viele Änderungen; nur die ersten 2000 Einträge werden angezeigt',
|
||||
stage: 'Stagen',
|
||||
unstage: 'Unstagen',
|
||||
stageAll: 'Alle stagen',
|
||||
unstageAll: 'Alle unstagen',
|
||||
commitPlaceholder: 'Commit-Nachricht (Ctrl+Enter)',
|
||||
commit: 'Committen',
|
||||
commitError: 'Commit fehlgeschlagen',
|
||||
branch: 'Branch',
|
||||
worktree: 'Worktree',
|
||||
checkoutError: 'Branchenwechsel fehlgeschlagen',
|
||||
history: 'Verlauf',
|
||||
changes: 'Änderungen',
|
||||
staged: 'Gestaged',
|
||||
unstaged: 'Nicht gestaged',
|
||||
cancel: 'Abbrechen',
|
||||
diffEmpty: 'Keine Textunterschiede',
|
||||
diffLoadError: 'Diff konnte nicht geladen werden',
|
||||
diffBinary: 'Binär',
|
||||
diffAdded: 'Hinzugefügt',
|
||||
diffDeleted: 'Gelöscht',
|
||||
diffRenamed: 'Umbenannt',
|
||||
diffExpand: '{count} weitere Zeilen ausklappen',
|
||||
diffCollapse: 'Einklappen',
|
||||
discard: 'Änderungen verwerfen',
|
||||
discardTitle: 'Änderungen verwerfen',
|
||||
discardDesc: 'Dadurch werden die Arbeitsbereichsänderungen von „{path}“ verworfen (nicht wiederherstellbar).',
|
||||
viewCommitDiff: 'Commit-Diff ansehen',
|
||||
copyShortHash: 'Kurzen Hash kopieren',
|
||||
copyFullHash: 'Vollständigen Hash kopieren',
|
||||
copySubject: 'Commit-Titel kopieren',
|
||||
revertCommit: 'Commit zurücknehmen',
|
||||
revertTitle: 'Commit zurücknehmen',
|
||||
revertDesc: 'Erstellt einen neuen Commit im aktuellen Branch, der „{subject}“ rückgängig macht.',
|
||||
cherryPickCommit: 'Commit cherripicken',
|
||||
cherryPickTitle: 'Commit cherripicken',
|
||||
cherryPickDesc: 'Wendet die Änderungen von „{subject}“ auf den aktuellen Branch an.',
|
||||
timeJustNow: 'gerade eben',
|
||||
timeMinutesAgo: 'vor {n} Min.',
|
||||
timeHoursAgo: 'vor {n} Std.',
|
||||
timeYesterday: 'gestern',
|
||||
loadMore: 'Mehr laden',
|
||||
historyLoadError: 'Laden weiterer Verlaufseinträge fehlgeschlagen',
|
||||
produced: 'Erstellt',
|
||||
producedOpen: 'In der Seitenleiste öffnen',
|
||||
disconnected: 'Terminalverbindung getrennt, Verbindung wird wiederhergestellt…',
|
||||
exited: 'Terminalprozess beendet',
|
||||
noSession: 'Wählen Sie eine Sitzung, um die Seitenleiste zu verwenden',
|
||||
pluginNotLoaded: 'Plugin nicht geladen; Tab vorübergehend nicht verfügbar:',
|
||||
hiddenFiles: 'Versteckte Dateien',
|
||||
parent: 'Übergeordnetes Verzeichnis',
|
||||
copied: 'Kopiert',
|
||||
copy: 'Kopieren',
|
||||
newFile: 'Neue Datei',
|
||||
openEditor: 'Editor öffnen',
|
||||
gitDetail: 'Änderungsdetails ansehen',
|
||||
referenceFile: '@Datei',
|
||||
addToConversation: 'Zur Unterhaltung hinzufügen',
|
||||
copyRelative: 'Relativen Pfad kopieren',
|
||||
copyAbsolute: 'Absoluten Pfad kopieren',
|
||||
download: 'Herunterladen',
|
||||
uploadFiles: 'Dateien hochladen',
|
||||
uploadFolder: 'Ordner hochladen',
|
||||
uploadHere: 'Hierher hochladen',
|
||||
uploadDropHint: 'Dateien/Ordner hierher ziehen, um sie hochzuladen',
|
||||
uploadDropChat: 'In den Chatbereich ziehen: Bilder zur Unterhaltung hinzufügen',
|
||||
uploadTo: 'Nach {dir} hochladen',
|
||||
uploadingTo: 'Wird nach {dir} hochgeladen…',
|
||||
uploadProgress: 'Lade {done}/{total} hoch: {name}',
|
||||
uploadDone: '{count} Dateien hochgeladen',
|
||||
uploadFailed: 'Upload fehlgeschlagen: {error}',
|
||||
uploadFailedUnknown: 'Unbekannter Fehler',
|
||||
uploadTooLarge: 'Datei zu groß (über dem Upload-Limit)',
|
||||
uploadCancelled: 'Upload abgebrochen',
|
||||
settingsNav: 'Seitenkarte',
|
||||
settingsIntro: 'Verwalten Sie, was die Seitenkarte anzeigt und wie sie sich verhält',
|
||||
settingsPopupDesc: 'Optionen für „{feature}“ konfigurieren',
|
||||
settingsDone: 'Fertig',
|
||||
settingsOpenTitle: 'Für neue Unterhaltungen standardmäßig geöffnet',
|
||||
settingsOpenDesc: 'Die Seitenkarte wird für brandneue Unterhaltungen automatisch ausgeklappt; bestehende Unterhaltungen behalten ihr eigenes Layout',
|
||||
settingsWidthTitle: 'Standardbreite',
|
||||
settingsWidthDesc: 'Standardanteil der Seitenkarte an der Fensterbreite für neue Unterhaltungen (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'Chat-Dateien in der Seitenleiste öffnen',
|
||||
settingsOpenPathDesc: 'Dateilinks im Chat (Werkzeugzeilen, erstellte Dateien, Erwähnungen) werden im Seitenleisten-Editor geöffnet statt in der System-Standardanwendung',
|
||||
settingsOpenToolsTitle: 'Seitenleisten-Öffnungswerkzeug für das Modell bereitstellen',
|
||||
settingsOpenToolsDesc: 'Wenn aktiviert, kann das Modell über das sidebar_open-Werkzeug Dateien, Ordner und HTTP(S)-Seiten in der Seitenleiste öffnen (standardmäßig deaktiviert)',
|
||||
settingsTitleBarTitle: 'Kompatibilitätsmodus der Position',
|
||||
settingsTitleBarDesc: 'Kompatibilitätsschema der Titelleiste wählen: automatische Erkennung (Standard, konservativ) / offizielles DSH-Web / bekannte Desktop-Shells / benutzerdefiniert (Verschiebung + benutzerdefiniertes CSS)',
|
||||
settingsTitleBarStripTitle: 'Verschiebungsabstand',
|
||||
settingsTitleBarStripDesc: 'Höhe des Titelleisten-Streifens: um wie viele Pixel Seitenleisten-Buttons und -Inhalt nach unten rutschen (0–120, Standard 40; wirkt beim benutzerdefinierten Schema)',
|
||||
settingsSchemeAutoTitle: 'Automatische Erkennung',
|
||||
settingsSchemeAutoDesc: 'Konservativ: nur die Standard-API „Window Controls Overlay“ trägt bei (echte Caption-Overlay-Höhe); reine Web-Umgebungen erhalten keine Anpassung',
|
||||
settingsSchemeWebTitle: 'Offizielles DSH-Web',
|
||||
settingsSchemeWebDesc: 'Explizit die offizielle Webversion erklären: keinerlei Anpassung (nicht einmal die Standard-WCO-Geometrie)',
|
||||
settingsSchemeCustomTitle: 'Benutzerdefiniert',
|
||||
settingsSchemeCustomDesc: 'Volle Kontrolle: benutzerdefiniertes CSS einspielen (kann eingebaute Stile überschreiben) und den Verschiebungsabstand der Titelleiste festlegen',
|
||||
settingsSchemeDetectedSuffix: 'erkannt',
|
||||
settingsCustomCssTitle: 'Benutzerdefiniertes CSS',
|
||||
settingsCustomCssDesc: 'Am Seitenende angehängte Stile (bei gleicher Priorität gewinnt der spätere; zum Überschreiben von JS-Inline-Variablen ist !important nötig)',
|
||||
settingsCustomCssPlaceholder: '/* z. B. 36px für eine Shell mit selbst gezeichneter Titelleiste reservieren */\nhtml[data-dsh-title-bar-height="36"] {\n --dsh-title-bar-strip: 36px !important;\n}',
|
||||
settingsSaveFailed: 'Speichern fehlgeschlagen',
|
||||
settingsConflict: 'Die Einstellung wurde in einem anderen Fenster geändert – bitte erneut versuchen',
|
||||
binaryNoPreview: 'Dieser Dateityp kann nicht in der Vorschau angezeigt werden',
|
||||
downloadToView: 'Zum Ansehen herunterladen',
|
||||
settingsSubagentTitle: 'Aufgaben-Seite bei einem Subagenten automatisch öffnen',
|
||||
settingsSubagentDesc: 'Die Seitenkarte wird ausgeklappt und die Aufgaben-Seite geöffnet, wenn die aktuelle Unterhaltung einen neuen Subagenten erzeugt; ausschalten, um sie manuell zu öffnen',
|
||||
settingsJobsTitle: 'Aufgaben-Seite bei einer neuen Hintergrundaufgabe automatisch öffnen',
|
||||
settingsJobsDesc: 'Die Seitenkarte wird ausgeklappt und die Aufgaben-Seite geöffnet, sobald für die aktuelle Unterhaltung eine neue Hintergrundaufgabe erscheint (jede neue Aufgabe löst dies aus); ausschalten, um sie manuell zu öffnen',
|
||||
settingsToolsTitle: 'Terminal-Werkzeuge für das Modell bereitstellen',
|
||||
settingsToolsDesc: 'Wenn aktiviert, kann das Modell über die 8 terminal_*-Werkzeuge Terminale in der Seitenleiste erstellen und steuern (standardmäßig deaktiviert)',
|
||||
settingsBottomTerminalTitle: 'Beim ersten Ausklappen des unteren Panels automatisch ein Terminal öffnen',
|
||||
settingsBottomTerminalDesc: 'Wird das untere Panel in einer Sitzung zum ersten Mal ausgeklappt, wird dort versucht, einen neuen Terminal-Tab zu öffnen (das Terminal-Kontingent gilt weiterhin; standardmäßig aktiviert)',
|
||||
settingsFontFamilyTitle: 'Terminal-Schriftart',
|
||||
settingsFontFamilyDesc: 'Benutzerdefinierte Terminal-Schriftfamilie (ein CSS-font-family-Stapel wie "JetBrains Mono", monospace; leer lassen, um der Monospace-Schrift des Themas zu folgen)',
|
||||
settingsFontFamilyPlaceholder: '"JetBrains Mono", monospace',
|
||||
settingsFontSizeTitle: 'Terminal-Schriftgröße',
|
||||
settingsFontSizeDesc: 'Terminal-Schriftgröße in px (9–32, Standard 13)',
|
||||
settingsFontSizeSuffix: 'px',
|
||||
settingsShellTitle: 'Shell-Pfad',
|
||||
settingsShellDesc: 'Shell für UI- und Modell-Terminals (absoluter Pfad oder ausführbarer Name). Leer lassen für die bestehende Reihenfolge: config.shell aus der yaml → $SHELL / Login-Shell / powershell.exe unter Windows. Wirkt für danach geöffnete Terminals',
|
||||
settingsShellPlaceholder: 'z. B. /bin/zsh (leer = automatisch)',
|
||||
settingsShellArgsTitle: 'Shell-Argumente',
|
||||
settingsShellArgsDesc: 'Explizite Shell-Startargumente, durch Leerzeichen getrennt; wenn nicht leer, ersetzen sie die Standardargumente vollständig (gleicher Vertrag wie shellArgs in der yaml)',
|
||||
settingsShellArgsPlaceholder: 'z. B. -l (leer = Standardargumente)',
|
||||
settingsTabsTitle: 'Seitenleisten-Inhalt',
|
||||
settingsViewersTitle: 'Datei-Viewer',
|
||||
settingsGeneralTitle: 'Allgemein',
|
||||
settingsPopup: 'Funktionseinstellungen',
|
||||
settingsViewerCatchAll: 'Catch-all: beliebige Datei',
|
||||
viewerImage: 'Bild',
|
||||
viewerPdf: 'PDF',
|
||||
viewerMarkdown: 'Markdown',
|
||||
viewerCode: 'Code',
|
||||
viewerBinary: 'Binär-Download',
|
||||
viewerHtml: 'HTML',
|
||||
browser: 'Browser',
|
||||
browserPlaceholder: 'URL eingeben, z. B. example.com',
|
||||
browserGo: 'Los',
|
||||
browserBack: 'Zurück',
|
||||
browserForward: 'Vor',
|
||||
browserStart: 'URL eingeben, um mit dem Browsen zu beginnen (Sandbox-Modus)',
|
||||
browserBlockedScheme: 'Blockiert: nur http/https-Links sind erlaubt',
|
||||
browserBlockedLoopback: 'Blockiert: lokale und interne Adressen können hier nicht aufgerufen werden',
|
||||
browserInvalid: 'Ungültige URL',
|
||||
browserNoSandboxWarning: 'Sandbox aus: Die aktuelle Seite läuft mit vollen Benutzeroberflächen-Rechten (in den Einstellungen wieder aktivierbar)',
|
||||
htmlNoSandboxWarning: 'Sandbox aus: Dieses HTML läuft mit vollen Benutzeroberflächen-Rechten (in den Einstellungen wieder aktivierbar)',
|
||||
sandboxStatusOn: 'Sandbox-Modus: aktiviert · Seiten können weder auf die Daten der Oberfläche noch auf lokale Dateien zugreifen; Anmeldungen und Drittanbieter-Cookies funktionieren möglicherweise nicht',
|
||||
sandboxUnlock: 'Vorübergehend deaktivieren (unsicher)',
|
||||
sandboxRestore: 'Sandbox wiederherstellen',
|
||||
settingsHtmlDefaultUnsafeTitle: 'HTML-Vorschauen standardmäßig ohne Sandbox öffnen (unsicher)',
|
||||
settingsHtmlDefaultUnsafeDesc: 'Wenn aktiviert, startet jede neu geöffnete HTML-Vorschau ohne Sandbox (gleiche Herkunft wie die Oberfläche – sie kann Sitzungsdateien und interne APIs lesen); die Statuszeile bietet weiterhin eine einmalige Wiederherstellung',
|
||||
settingsHtmlSandboxTitle: 'HTML-Vorschau-Sandbox deaktivieren (unsicher)',
|
||||
settingsHtmlSandboxDesc: 'Ohne Sandbox läuft das angezeigte HTML mit der gleichen Herkunft wie die Oberfläche: Es kann Sitzungsdateien und lokalen Speicher lesen und interne APIs aufrufen. Nur für vollständig vertrauenswürdige Dateien aktivieren',
|
||||
settingsBrowserSandboxTitle: 'Browser-Sandbox deaktivieren (unsicher)',
|
||||
settingsBrowserSandboxDesc: 'Ohne Sandbox läuft jede besuchte Website mit der gleichen Herkunft wie die Oberfläche: Sie kann Sitzungsdaten lesen und sich als Ihre Anmeldung ausgeben. Nur für vollständig vertrauenswürdige Websites aktivieren',
|
||||
settingsBrowserLinksTitle: 'Externe Links aus dem Chat in der Seitenleiste öffnen',
|
||||
settingsBrowserLinksDesc: 'Wenn aktiviert, öffnet ein Klick auf einen externen Link im Chat oder in der Oberfläche die Seitenleiste statt eines neuen Fensters; HTTP und HTTPS werden getrennt über die Schalter unten gesteuert; Ctrl/Cmd-Klick umgeht dies immer',
|
||||
settingsBrowserHttpTitle: 'HTTP-Seiten in der Seitenleiste öffnen',
|
||||
settingsBrowserHttpDesc: 'Wenn aktiviert, öffnet ein Klick auf einen HTTP-Externlink im Chat oder in der Oberfläche die Seitenleiste (Plugin-Seiten mit urlTarget gewinnen); Ctrl/Cmd-Klick umgeht dies immer',
|
||||
settingsBrowserHttpsTitle: 'HTTPS-Seiten in der Seitenleiste öffnen',
|
||||
settingsBrowserHttpsDesc: 'Wenn aktiviert, öffnet ein Klick auf einen HTTPS-Externlink im Chat oder in der Oberfläche die Seitenleiste. Standardmäßig deaktiviert: Die meisten HTTPS-Sites verweigern die Einbettung, der Systembrowser ist der reibungslosere Standard',
|
||||
settingsBrowserLoopbackTitle: 'Erlaubte lokale Adressen',
|
||||
settingsBrowserLoopbackDesc: 'Kommagetrennte Allowlist von Loopback-Adressen (z. B. localhost:5174 oder 127.0.0.1:8080), die der Seitenleisten-Browser besuchen darf; leer blockiert standardmäßig alle lokalen Adressen. Die Sandbox bleibt aktiv — Seiten können keine GUI-Daten lesen',
|
||||
settingsBrowserLoopbackPlaceholder: 'z. B. localhost:5174, 127.0.0.1:8080',
|
||||
browserOpenExternal: 'Im Browser öffnen',
|
||||
browserEmbedBlocked: '{host} hat die Einbettung verweigert',
|
||||
browserEmbedBlockedDesc: 'Die Site verbietet die Anzeige in anderen Seiten (X-Frame-Options / frame-ancestors) und kann daher nicht in der Seitenleiste geladen werden. Öffnen Sie sie stattdessen direkt im Browser.',
|
||||
browserEmbedAnyway: 'Trotzdem laden',
|
||||
subagent: 'Aufgaben',
|
||||
openSubagent: 'Aufgaben',
|
||||
subagentMainAgent: 'Hauptagent',
|
||||
subagentEmpty: 'Keine Subagenten',
|
||||
subagentEmptyDesc: 'Subagenten, die unter dem Hauptagenten erzeugt werden, erscheinen hier',
|
||||
subagentRunning: 'Läuft',
|
||||
subagentInactive: 'Inaktiv',
|
||||
subagentModeOneShot: 'Einmalig',
|
||||
subagentModeContinuable: 'Fortsetzbar',
|
||||
subagentCount: '{count} Subagenten',
|
||||
subagentCountRunning: '{count} Subagenten · {running} läuft',
|
||||
subagentDiagCorrupt: 'Beschädigt',
|
||||
subagentDiagUnsupported: 'Nicht unterstützt',
|
||||
subagentDiagUnavailable: 'Nicht verfügbar',
|
||||
subagentThinking: 'Denkt nach…',
|
||||
sideChat: 'Seitenchat (beta)',
|
||||
sideChatNew: 'Neuer Thread',
|
||||
sideChatUntitled: 'Neuer Thread',
|
||||
sideChatEmpty: 'Keine Seitenunterhaltungen',
|
||||
sideChatEmptyDesc: 'Jede Seitenunterhaltung ist ein eigener Tab in der Tab-Leiste – sie erbt den Kontext der aktuellen Sitzung und wird nie Teil der Hauptunterhaltung',
|
||||
sideChatCreating: 'Seitenunterhaltung wird erstellt…',
|
||||
sideChatRetry: 'Erneut versuchen',
|
||||
sideChatThreads: 'Thread wechseln / neu',
|
||||
sideChatSave: 'Als neue Sitzung speichern',
|
||||
sideChatSaveTitle: 'Diesen Thread in der Hauptsitzungsliste zu einer Top-Level-Sitzung erheben',
|
||||
sideChatSaved: 'Als neue Sitzung gespeichert',
|
||||
sideChatNoTurn: 'Das Speichern ist nach der ersten abgeschlossenen Runde verfügbar',
|
||||
sideChatPendingDrop: 'Die letzte unbeantwortete Nachfrage wird in der gespeicherten Sitzung nicht enthalten sein',
|
||||
sideChatFirstPlaceholder: 'Stellen Sie die erste Frage – der Kontext ist geerbt…',
|
||||
sideChatComposerPlaceholder: 'Nachfrage stellen…',
|
||||
sideChatThinking: 'Denkt vertieft…',
|
||||
sideChatThink: 'Denkvorgang',
|
||||
sideChatInjection: 'Kontext eingespielt',
|
||||
sideChatSend: 'Senden',
|
||||
sideChatCancel: 'Stoppen',
|
||||
sideChatCancelTitle: 'Laufende Runde abbrechen (wartende Nachrichten bleiben erhalten)',
|
||||
sideChatClose: 'Thread schließen',
|
||||
sideChatCloseTitle: 'Den Agenten des Threads freigeben (Verlauf bleibt erhalten)',
|
||||
sideChatError: 'Seitenchat-Fehler: {message}',
|
||||
jobs: 'Hintergrundaufgaben',
|
||||
jobsCount: '{count} Hintergrundaufgaben',
|
||||
jobsCountRunning: '{count} Hintergrundaufgaben · {running} läuft',
|
||||
jobStatusRunning: 'Läuft',
|
||||
jobStatusStopping: 'Wird beendet',
|
||||
jobStatusCompleted: 'Abgeschlossen',
|
||||
jobStatusKilled: 'Beendet',
|
||||
jobStatusFailed: 'Fehlgeschlagen',
|
||||
jobDurationSeconds: '{seconds} s',
|
||||
jobDurationMinutes: '{minutes} m {seconds} s',
|
||||
jobDurationHours: '{hours} h {minutes} m',
|
||||
jobViewOutput: 'Ausgabe ansehen',
|
||||
jobHideOutput: 'Ausgabe ausblenden',
|
||||
jobNoOutput: 'Noch keine Ausgabe',
|
||||
jobNotReadYet: 'Warten, bis das Modell die Ausgabe dieser Aufgabe liest (nach Ausführung von job_output durch das Modell erscheint die Ausgabe hier)',
|
||||
jobOutputTruncated: 'Ausgabe zu lang, gekürzt angezeigt',
|
||||
jobOutputError: 'Ausgabe konnte nicht gelesen werden',
|
||||
jobKill: 'Beenden',
|
||||
jobKillConfirm: 'Zum Bestätigen erneut klicken',
|
||||
jobKillError: 'Beenden fehlgeschlagen',
|
||||
addPluginsTabCard: 'Tab-Plugins hinzufügen',
|
||||
addPluginsTabCardDesc: 'Eine neue Seitenleisten-Seite registrieren',
|
||||
addPluginsViewerCard: 'Vorschau-Plugins hinzufügen',
|
||||
addPluginsViewerCardDesc: 'Eine Dateityp-Vorschau registrieren',
|
||||
addPluginsTabDesc: 'Seitenleisten-Seiten (Tabs) können durch Plugins erweitert werden. Plugins registrieren sich über den ctx.betterSidebar-Dienst; ein Klick auf „Installieren“ kopiert den Installationsbefehl – fügen Sie ihn in einem Terminal ein, in dem Ihr DSH-Profil liegt, und führen Sie ihn aus.',
|
||||
addPluginsViewerDesc: 'Datei-Viewer können durch Plugins erweitert werden. Plugins registrieren sich über den ctx.betterSidebar-Dienst; ein Klick auf „Installieren“ kopiert den Installationsbefehl – fügen Sie ihn in einem Terminal ein, in dem Ihr DSH-Profil liegt, und führen Sie ihn aus.',
|
||||
addPluginsBrowseMore: 'Weitere Plugins auf GitHub durchsuchen (Topic: dsh-better-sidebar)',
|
||||
addPluginsSearch: 'Nach Plugin-Name oder -Beschreibung suchen…',
|
||||
addPluginsNoMatch: 'Keine passenden Plugins',
|
||||
addPluginsRecommended: 'Empfohlene Plugins',
|
||||
addPluginsEmpty: 'Noch keine Plugins katalogisiert – veröffentlichen Sie Ihre Plugin unter dem GitHub-Topic',
|
||||
openPlugin: 'Öffnen',
|
||||
copyInstall: 'Installationsbefehl kopieren',
|
||||
pluginOfficeDesc: 'Office-Suite-Vorschau (.docx / .xlsx / .pptx) für den better-sidebar-Editor, die die schweren Office-Render-Bibliotheken aus dem Kernbundle heraushält',
|
||||
pluginFlowglassDesc: 'Live-Sitzungsflussdiagramm: dreispurige Darstellung von Benutzer, Assistent und Werkzeugaufrufen mit parallelen Gruppen, Subagent-Zweigen, Drill-down und Live-Status; registriert bei installiertem better-sidebar einen nativen „Flowglass“-Tab und behält sonst eine eigenständige Schublade',
|
||||
pluginGitForgeDesc: '„Git-Anmeldedaten“-Tab: Konto-Bibliothek für Forges wie GitHub/Gitea + projektbezogene Freigaben + harte Push-Richtlinie; Tokens liegen nur in lokalen Secrets, nie im Modellkontext; bietet ein schreibgeschütztes GitForge-Werkzeug und einen HTTPS-Credential-Helper für den Agenten',
|
||||
pluginGitRemotesDesc: '„Git-Remotes“-Tab: Branches/Upstream/ahead-behind ansehen, fetch (optional prune), ff-only pull und push erst nach Bestätigung im Tab. Ersetzt nicht den eingebauten Stagen/Commit-Tab und bietet weder force-push noch automatisches Push-Werkzeug für das Modell',
|
||||
pluginSentinelDesc: 'Bedingungsgesteuertes Wecksystem für Agenten: Datei-/Prozess-/Port-/HTTP-/Befehls-/Webhook-Sensoren wecken ruhende Sitzungen, wenn Bedingungen zutreffen; registriert einen „Sentinel“-Tab mit der serverweiten Überwachungstabelle',
|
||||
pluginSidebarQaDesc: 'Auswählen-und-Fragen mit Tab-Aufteilung: Text in der Unterhaltung auswählen → rechts im Panel fragen → eine eigene Folgesitzung (❓Nachfrage) im selben Arbeitsbereich; ein schnelles Modell ohne Denkvorgang komprimiert den Hauptkontext und spielt ihn samt Zitat ein, ohne die Hauptunterhaltung zu unterbrechen. Nachfragen lassen sich verschachteln, fortsetzen und archivieren',
|
||||
pluginSshTunnelDesc: '„SSH-Tunnel“-Tab: Multi-Host-Inventar + projektbezogene Freigaben + lokale Schlüsselverwahrung; Modellwerkzeug SSHManager (exec/SFTP/Sitzungsstrategien); zentrales interaktives Terminal und zweispaltiges SFTP',
|
||||
pluginTurnReviewDesc: 'Eine menschliche Freigabestufe für den Diff der „gerade abgeschlossenen Runde“: Approve / Request changes pro Pfad mit optionalem Kommentar; Pfade gruppiert nach Hauptsitzung / Subagent / nicht zugeordnet; Inline-Diff von Snapshot-vs-jetzt, bevor Sie entscheiden. Kein Fork, kein /rewind',
|
||||
pluginVideoPreviewDesc: 'Inline-Videovorschau (.mp4/.webm/.mov/.mkv/.avi usw.) für den better-sidebar-Editor, gestützt auf eine eigene /video-Hostroute mit HTTP-Range-Unterstützung (206) – das Scrubbing funktioniert und Dateien unterliegen nicht dem 20MB-mediaLimit',
|
||||
pluginDocsPanelDesc: '„Globale Dokumente“ in der DSH-Seitenleiste: eigene Markdown-Notizen aus jedem Arbeitsbereich lesen – Dateiliste, Gliederung, Öffnen in Chrome / VS Code und Kopieren-Buttons; das Dokumentenverzeichnis ist konfigurierbar (Standard ~/.dsh/docs)',
|
||||
pluginEgoBrowserDesc: 'Der Agent-Browser für DeepSeek Harness: 32 ego_*-Tools steuern ein echtes Chromium; ein nativer „ego-Browser“-Tab in der Seitenleiste zeigt live jede Seite, die der Agent besucht – Klicken, Ziehen und Tippen zum Übernehmen. Registriert den Tab automatisch, wenn better-sidebar vorhanden ist, sonst eine schwebende Blase',
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* French translation of the better-sidebar copy.
|
||||
*
|
||||
* Mirrors the key set of the zh dictionary in `locales.ts` (enforced by
|
||||
* the `Record<keyof typeof zh, string>` type annotation). The better-locale
|
||||
* override store registers this dict under the `betterSidebar` namespace,
|
||||
* so it takes effect when the user has selected the `fr` override.
|
||||
*/
|
||||
|
||||
/** fr dictionary for the `betterSidebar` namespace. */
|
||||
export const fr: Record<string, string> = {
|
||||
files: 'Fichiers',
|
||||
explorer: 'Explorateur',
|
||||
git: 'Gestion de code source',
|
||||
terminal: 'Terminal',
|
||||
editor: 'Éditeur',
|
||||
editorExplorer: 'Comportement d’ouverture des fichiers',
|
||||
editorExplorerDesc: 'Contrôle la façon dont les fichiers s’ouvrent',
|
||||
editorExplorerMerged: 'Fusionné',
|
||||
editorExplorerMergedDesc: 'Les fichiers se remplacent dans la même fenêtre ; les nouvelles fenêtres ouvrent l’arborescence par défaut',
|
||||
editorExplorerSplit: 'Séparé',
|
||||
editorExplorerSplitDesc: 'Les fenêtres sans chemin sont l’explorateur autonome (arborescence uniquement) ; chaque fichier ouvre sa propre fenêtre (avec arborescence, réduite par défaut)',
|
||||
editorTreeToggle: 'Panneau d’arborescence',
|
||||
editorPathPlaceholder: 'Saisissez un chemin de fichier (relatif au répertoire de session ou absolu), Entrée pour ouvrir',
|
||||
editorSearchPlaceholder: 'Rechercher par nom de fichier…',
|
||||
editorSearchNoResults: 'Aucun fichier correspondant',
|
||||
editorSearchTruncated: 'Trop de résultats, seule une partie des correspondances est affichée',
|
||||
editorEmptyHint: 'Choisissez un fichier dans l’arborescence à droite ou dans le champ de chemin ci-dessus pour commencer l’aperçu',
|
||||
openFileNewTab: 'Ouvrir dans un nouvel onglet',
|
||||
openFileSide: 'Ouvrir de côté',
|
||||
openWithMenu: 'Ouvrir avec',
|
||||
openWithSshSuffix: ' (SSH)',
|
||||
pinOpenWith: 'Épingler au menu',
|
||||
unpinOpenWith: 'Désépingler',
|
||||
openWithExplorer: 'Explorateur de fichiers',
|
||||
openWithVscode: 'VS Code',
|
||||
openWithCursor: 'Cursor',
|
||||
openWithZed: 'Zed',
|
||||
openWithSettingsSshTitle: 'Hôte distant SSH',
|
||||
openWithSettingsSshDesc: 'Vide = espace de travail local ; avec un user@host ou un alias SSH, les ouvreurs de la famille VSCode passent aux protocoles vscode-remote/ssh-remote, et l’explorateur de fichiers / Zed / les éditeurs personnalisés hors famille VSCode sont masqués du menu',
|
||||
openWithSettingsSshPlaceholder: 'user@host ou alias SSH',
|
||||
openWithSettingsCustomTitle: 'Éditeurs personnalisés',
|
||||
openWithSettingsCustomDesc: 'Nom + modèle d’URL (placeholder {path}) + famille VSCode ou non ; en mode SSH, seuls les éditeurs de la famille VSCode peuvent ouvrir un distant',
|
||||
openWithSettingsAdd: 'Ajouter',
|
||||
openWithSettingsName: 'Nom',
|
||||
openWithSettingsTemplate: 'ex. cursor://file/{path}',
|
||||
openWithSettingsFamily: 'Famille VSCode',
|
||||
openWithSettingsFamilyDesc: 'Cet éditeur utilise le protocole d’URL de VSCode (prend en charge l’ouverture à distance via SSH)',
|
||||
openWithSettingsRemove: 'Supprimer',
|
||||
openWithSettingsInvalidHint: 'Les éditeurs sans nom ou sans modèle (contenant {path} et commençant par scheme://) n’apparaissent pas dans le menu',
|
||||
newTab: 'Nouvel onglet',
|
||||
openExplorer: 'Explorateur',
|
||||
brokenSymlink: 'Lien symbolique cassé',
|
||||
openGit: 'Panneau Git',
|
||||
newTerminal: 'Nouveau terminal',
|
||||
terminalLimit: 'Nombre maximal de terminaux atteint (3)',
|
||||
close: 'Fermer',
|
||||
closeOtherTabs: 'Fermer les autres onglets',
|
||||
closeLeftTabs: 'Fermer les onglets à gauche',
|
||||
closeRightTabs: 'Fermer les onglets à droite',
|
||||
moveToFreeWindow: 'Déplacer vers la fenêtre flottante',
|
||||
floatDropHint: 'Relâchez pour ouvrir dans une fenêtre flottante',
|
||||
dockToSidebar: 'Revenir à la barre latérale',
|
||||
pinTerminal: 'Épingler le terminal',
|
||||
pinAgentTerminal: 'Épingler le terminal Agent',
|
||||
pinToWorkspace: 'Épingler à l\'espace de travail',
|
||||
pinToGlobal: 'Épingler globalement',
|
||||
unpinTerminal: 'Désépingler',
|
||||
pinnedTerminalTooltip: '{kind} · {scope} · {cwd}',
|
||||
pinnedTerminalKindUi: 'Terminal UI',
|
||||
pinnedTerminalKindAgent: 'Terminal Agent',
|
||||
pinnedTerminalScopeWorkspace: 'Épinglé à l\'espace de travail',
|
||||
pinnedTerminalScopeGlobal: 'Épinglé globalement',
|
||||
pinnedRailLabel: 'Terminaux épinglés',
|
||||
closePinnedTerminal: 'Fermer le terminal',
|
||||
collapse: 'Réduire la barre latérale',
|
||||
expand: 'Déployer la barre latérale',
|
||||
collapseBottomPanel: 'Réduire le panneau inférieur',
|
||||
expandBottomPanel: 'Déployer le panneau inférieur',
|
||||
terminalError: 'Échec de la connexion au terminal',
|
||||
terminalConnectFailed: 'Le terminal a échoué à se connecter à plusieurs reprises',
|
||||
terminalRetry: 'Réessayer',
|
||||
terminalDepsFailed: 'Échec du chargement de la dépendance terminal node-pty',
|
||||
terminalDepsHint: 'Exécutez la commande suivante dans un terminal ou cmd de l’environnement DSH pour réparer, puis cliquez sur Réessayer (node-pty reste à la même version que le cœur DSH) :',
|
||||
terminalDepsProfile: ' (profil détecté : {profile})',
|
||||
preview: 'Aperçu',
|
||||
toc: 'Sommaire',
|
||||
edit: 'Modifier',
|
||||
mermaidError: 'Échec du rendu Mermaid',
|
||||
mermaidZoomIn: 'Agrandir',
|
||||
mermaidZoomOut: 'Réduire',
|
||||
mermaidZoomReset: 'Réinitialiser',
|
||||
mermaidZoomHint: 'Molette pour zoomer · glisser pour déplacer · Échap pour fermer',
|
||||
refresh: 'Actualiser',
|
||||
showInFolder: 'Afficher dans le dossier',
|
||||
refreshUnsavedConfirm: 'Le fichier a changé sur le disque. Actualiser supprimera les modifications non enregistrées. Continuer ?',
|
||||
save: 'Enregistrer',
|
||||
saved: 'Enregistré',
|
||||
unsaved: 'Non enregistré',
|
||||
saveFailed: 'Échec de l’enregistrement',
|
||||
truncation: 'Fichier trop volumineux, seuls les 512 premiers Ko sont affichés',
|
||||
binary: 'Fichier binaire, aperçu impossible',
|
||||
loading: 'Chargement…',
|
||||
error: 'Échec du chargement',
|
||||
retry: 'Réessayer',
|
||||
splitLeft: 'Diviser à gauche',
|
||||
splitRight: 'Diviser à droite',
|
||||
splitUp: 'Diviser en haut',
|
||||
splitDown: 'Diviser en bas',
|
||||
notRepo: 'Ce répertoire n’est pas un dépôt git',
|
||||
noChanges: 'Aucune modification',
|
||||
statusTruncated: 'Trop de modifications ; seules les 2000 premières entrées sont affichées',
|
||||
stage: 'Mettre en scène',
|
||||
unstage: 'Annuler la mise en scène',
|
||||
stageAll: 'Tout mettre en scène',
|
||||
unstageAll: 'Tout retirer de la scène',
|
||||
commitPlaceholder: 'Message de commit (Ctrl+Entrée)',
|
||||
commit: 'Valider',
|
||||
commitError: 'Échec du commit',
|
||||
branch: 'Branche',
|
||||
worktree: 'Arborescence de travail',
|
||||
checkoutError: 'Échec du changement de branche',
|
||||
history: 'Historique',
|
||||
changes: 'Modifications',
|
||||
staged: 'En scène',
|
||||
unstaged: 'Hors scène',
|
||||
cancel: 'Annuler',
|
||||
diffEmpty: 'Aucune différence textuelle',
|
||||
diffLoadError: 'Échec du chargement de la différence',
|
||||
diffBinary: 'Binaire',
|
||||
diffAdded: 'Ajouté',
|
||||
diffDeleted: 'Supprimé',
|
||||
diffRenamed: 'Renommé',
|
||||
diffExpand: 'Déployer les {count} lignes restantes',
|
||||
diffCollapse: 'Réduire',
|
||||
discard: 'Abandonner les modifications',
|
||||
discardTitle: 'Abandonner les modifications',
|
||||
discardDesc: 'Les modifications de l’espace de travail de « {path} » seront abandonnées (irréversible).',
|
||||
viewCommitDiff: 'Voir la différence du commit',
|
||||
copyShortHash: 'Copier le hash court',
|
||||
copyFullHash: 'Copier le hash complet',
|
||||
copySubject: 'Copier le message du commit',
|
||||
revertCommit: 'Annuler ce commit',
|
||||
revertTitle: 'Annuler ce commit',
|
||||
revertDesc: 'Un nouveau commit inversant « {subject} » sera créé sur la branche actuelle.',
|
||||
cherryPickCommit: 'Cherry-pick de ce commit',
|
||||
cherryPickTitle: 'Cherry-pick de ce commit',
|
||||
cherryPickDesc: 'Les modifications de « {subject} » seront appliquées à la branche actuelle.',
|
||||
timeJustNow: 'à l’instant',
|
||||
timeMinutesAgo: 'il y a {n} min',
|
||||
timeHoursAgo: 'il y a {n} h',
|
||||
timeYesterday: 'hier',
|
||||
loadMore: 'Charger plus',
|
||||
historyLoadError: 'Échec du chargement de davantage d’historique',
|
||||
produced: 'Produits de cette exécution',
|
||||
producedOpen: 'Ouvrir dans la barre latérale',
|
||||
disconnected: 'Connexion du terminal perdue, reconnexion…',
|
||||
exited: 'Le processus du terminal s’est terminé',
|
||||
noSession: 'Sélectionnez une session pour utiliser la barre latérale',
|
||||
pluginNotLoaded: 'Plugin non chargé, onglet indisponible pour le moment :',
|
||||
hiddenFiles: 'Fichiers cachés',
|
||||
parent: 'Répertoire parent',
|
||||
copied: 'Copié',
|
||||
copy: 'Copier',
|
||||
newFile: 'Nouveau fichier',
|
||||
openEditor: 'Ouvrir l’éditeur',
|
||||
gitDetail: 'Voir le détail des modifications',
|
||||
referenceFile: '@fichier',
|
||||
addToConversation: 'Ajouter à la discussion',
|
||||
copyRelative: 'Copier le chemin relatif',
|
||||
copyAbsolute: 'Copier le chemin absolu',
|
||||
download: 'Télécharger',
|
||||
uploadFiles: 'Importer des fichiers',
|
||||
uploadFolder: 'Importer un dossier',
|
||||
uploadHere: 'Importer ici',
|
||||
uploadDropHint: 'Glissez des fichiers/dossiers ici pour les importer',
|
||||
uploadDropChat: 'Glissez dans la zone de discussion : ajouter des images à la discussion',
|
||||
uploadTo: 'Importer dans {dir}',
|
||||
uploadingTo: 'Import en cours vers {dir}…',
|
||||
uploadProgress: 'Import de {done}/{total} : {name}',
|
||||
uploadDone: '{count} fichier(s) importé(s)',
|
||||
uploadFailed: 'Échec de l’import : {error}',
|
||||
uploadFailedUnknown: 'Erreur inconnue',
|
||||
uploadTooLarge: 'Fichier trop volumineux, limite d’import dépassée',
|
||||
uploadCancelled: 'Import annulé',
|
||||
settingsNav: 'Carte latérale',
|
||||
settingsIntro: 'Gérer le contenu affiché par la carte latérale et son comportement par défaut',
|
||||
settingsPopupDesc: 'Configurer les options liées à « {feature} »',
|
||||
settingsDone: 'Terminé',
|
||||
settingsOpenTitle: 'Ouvert par défaut pour les nouvelles sessions',
|
||||
settingsOpenDesc: 'Déployer automatiquement la carte latérale lors de la création d’une session ; les sessions existantes conservent chacune leur disposition',
|
||||
settingsWidthTitle: 'Part de largeur par défaut',
|
||||
settingsWidthDesc: 'Pourcentage de la largeur de la fenêtre occupé par la carte latérale pour les nouvelles sessions (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'Ouvrir les fichiers de discussion dans la barre latérale',
|
||||
settingsOpenPathDesc: 'À la place de l’application par défaut du système, ouvrir dans l’éditeur de la barre latérale les liens vers les fichiers du chat (lignes d’outils, listes de produits, mentions de fichiers)',
|
||||
settingsOpenToolsTitle: 'Injecter l\'outil d\'ouverture latérale pour le modèle',
|
||||
settingsOpenToolsDesc: 'Une fois activé, le modèle peut ouvrir des fichiers, dossiers et pages HTTP(S) dans la barre latérale via l\'outil sidebar_open (désactivé par défaut)',
|
||||
settingsTitleBarTitle: 'Mode de compatibilité de position',
|
||||
settingsTitleBarDesc: 'Choisissez le schéma de compatibilité de la barre de titre : détection automatique (par défaut, conservateur) / Web officiel DSH / coquilles de bureau connues / schéma personnalisé (distance de décalage + CSS personnalisé)',
|
||||
settingsTitleBarStripTitle: 'Distance de décalage',
|
||||
settingsTitleBarStripDesc: 'Hauteur de la bande de la barre de titre : nombre de pixels dont les boutons et le contenu de la barre latérale descendent (0–120, défaut 40 ; s’applique en schéma personnalisé)',
|
||||
settingsSchemeAutoTitle: 'Détection automatique',
|
||||
settingsSchemeAutoDesc: 'Schéma conservateur : ne cède de la place selon la hauteur réelle de la barre de titre que lorsque l’API standard Window Controls Overlay est disponible ; aucune modification dans un environnement web',
|
||||
settingsSchemeWebTitle: 'Web officiel DSH',
|
||||
settingsSchemeWebDesc: 'Déclare explicitement l’exécution dans la version web officielle : aucune adaptation (même la géométrie standard WCO n’est pas appliquée)',
|
||||
settingsSchemeCustomTitle: 'Schéma personnalisé',
|
||||
settingsSchemeCustomDesc: 'Entièrement à votre contrôle : injecter du CSS personnalisé (pouvant remplacer les styles intégrés) et définir la distance de décalage de la barre de titre',
|
||||
settingsSchemeDetectedSuffix: 'détecté',
|
||||
settingsCustomCssTitle: 'CSS personnalisé',
|
||||
settingsCustomCssDesc: 'Styles ajoutés à la fin de la page (à priorité égale, le dernier écrit gagne ; utilisez !important pour remplacer les variables en ligne écrites par JS)',
|
||||
settingsCustomCssPlaceholder: '/* ex. : réserver 36px pour une coquille à barre de titre dessinée */\nhtml[data-dsh-title-bar-height="36"] {\n --dsh-title-bar-strip: 36px !important;\n}',
|
||||
settingsSaveFailed: 'Échec de l’enregistrement',
|
||||
settingsConflict: 'Les réglages ont été modifiés par une autre fenêtre, veuillez réessayer',
|
||||
binaryNoPreview: 'Ce type de fichier ne prend pas en charge l’aperçu',
|
||||
downloadToView: 'Télécharger pour consulter',
|
||||
settingsSubagentTitle: 'Déployer automatiquement la page de gestion des tâches à la détection d’un sous-agent',
|
||||
settingsSubagentDesc: 'Lorsque la session actuelle produit un nouveau sous-agent, déployer automatiquement la barre latérale et ouvrir la page de gestion des tâches ; une fois désactivé, l’ouverture est manuelle',
|
||||
settingsJobsTitle: 'Déployer automatiquement la page des tâches d’arrière-plan sur nouvelle tâche',
|
||||
settingsJobsDesc: 'Lorsque la session actuelle produit une nouvelle tâche d’arrière-plan, déployer automatiquement la barre latérale et ouvrir la page des tâches (déclenché à chaque nouvelle tâche) ; une fois désactivé, l’ouverture est manuelle',
|
||||
settingsToolsTitle: 'Injecter des outils de terminal au modèle',
|
||||
settingsToolsDesc: 'Une fois activé, le modèle peut créer et piloter des terminaux de la barre latérale via les 8 outils terminal_* (désactivé par défaut)',
|
||||
settingsBottomTerminalTitle: 'Ouvrir automatiquement un terminal au premier déploiement du panneau inférieur',
|
||||
settingsBottomTerminalDesc: 'Lors du premier déploiement du panneau inférieur dans une session, tenter d’y ouvrir automatiquement un nouvel onglet de terminal (la limite du nombre de terminaux s’applique toujours ; activé par défaut)',
|
||||
settingsFontFamilyTitle: 'Police du terminal',
|
||||
settingsFontFamilyDesc: 'Famille de polices personnalisée du terminal (CSS font-family, ex. "JetBrains Mono", monospace ; vide = suit la police monospace du thème)',
|
||||
settingsFontFamilyPlaceholder: '"JetBrains Mono", monospace',
|
||||
settingsFontSizeTitle: 'Taille de police du terminal',
|
||||
settingsFontSizeDesc: 'Taille de police du terminal (9–32, défaut 13)',
|
||||
settingsFontSizeSuffix: 'px',
|
||||
settingsShellTitle: 'Chemin du shell',
|
||||
settingsShellDesc: 'Shell lancé pour les terminaux de l’interface et du modèle (chemin absolu ou nom d’exécutable). Vide = résolution dans l’ordre existant : config.shell du yaml → $SHELL / shell de connexion / powershell.exe sur Windows. S’applique aux terminaux ouverts ensuite',
|
||||
settingsShellPlaceholder: 'ex. /bin/zsh (vide = résolution automatique)',
|
||||
settingsShellArgsTitle: 'Arguments du shell',
|
||||
settingsShellArgsDesc: 'Arguments explicites de lancement du shell, séparés par des espaces ; non vide = remplacement complet des arguments par défaut (contrat identique aux shellArgs du yaml)',
|
||||
settingsShellArgsPlaceholder: 'ex. -l (vide = arguments par défaut)',
|
||||
settingsTabsTitle: 'Contenu de la barre latérale',
|
||||
settingsViewersTitle: 'Aperçus de fichiers',
|
||||
settingsGeneralTitle: 'Général',
|
||||
settingsPopup: 'Réglages de la fonctionnalité',
|
||||
settingsViewerCatchAll: 'Par défaut : n’importe quel fichier',
|
||||
viewerImage: 'Image',
|
||||
viewerPdf: 'PDF',
|
||||
viewerMarkdown: 'Markdown',
|
||||
viewerCode: 'Code',
|
||||
viewerBinary: 'Téléchargement binaire',
|
||||
viewerHtml: 'HTML',
|
||||
browser: 'Navigateur',
|
||||
browserPlaceholder: 'Saisissez une adresse, ex. example.com',
|
||||
browserGo: 'Aller',
|
||||
browserBack: 'Précédent',
|
||||
browserForward: 'Suivant',
|
||||
browserStart: 'Saisissez une adresse pour commencer à naviguer (mode bac à sable)',
|
||||
browserBlockedScheme: 'Bloqué : seuls les liens http/https sont pris en charge',
|
||||
browserBlockedLoopback: 'Bloqué : l’accès aux adresses locales ou internes n’est pas autorisé dans le navigateur',
|
||||
browserInvalid: 'Adresse invalide',
|
||||
browserNoSandboxWarning: 'Bac à sable désactivé : la page actuelle partage l’origine de l’interface et dispose de l’ensemble des droits de session (restauration possible dans les réglages)',
|
||||
htmlNoSandboxWarning: 'Bac à sable désactivé : ce HTML partage l’origine de l’interface, peut lire les fichiers de session et les interfaces internes (restauration possible dans les réglages)',
|
||||
sandboxStatusOn: 'Mode bac à sable : activé · la page ne peut pas accéder aux données de l’interface ni aux fichiers locaux ; l’état de connexion et les cookies tiers peuvent être indisponibles',
|
||||
sandboxUnlock: 'Déverrouiller temporairement (non sécurisé)',
|
||||
sandboxRestore: 'Restaurer le bac à sable',
|
||||
settingsHtmlDefaultUnsafeTitle: 'Ouvrir l’aperçu HTML en mode non bac à sable par défaut (non sécurisé)',
|
||||
settingsHtmlDefaultUnsafeDesc: 'Une fois activé, chaque ouverture d’un fichier HTML démarre l’aperçu en état non bac à sable (même origine que l’interface, pouvant lire les fichiers de session et les interfaces internes) ; la barre d’état permet de restaurer temporairement le bac à sable',
|
||||
settingsHtmlSandboxTitle: 'Désactiver le bac à sable de l’aperçu HTML (non sécurisé)',
|
||||
settingsHtmlSandboxDesc: 'Une fois désactivé, le HTML de l’aperçu s’exécute avec la même origine que l’interface, peut lire les fichiers de session, le stockage local et appeler les interfaces internes. À activer uniquement pour des fichiers entièrement fiables',
|
||||
settingsBrowserSandboxTitle: 'Désactiver le bac à sable du navigateur (non sécurisé)',
|
||||
settingsBrowserSandboxDesc: 'Une fois désactivé, tout site visité s’exécute avec la même origine que l’interface, peut lire les données de session et usurper votre état de connexion. À activer uniquement pour des sites entièrement fiables',
|
||||
settingsBrowserLinksTitle: 'Ouvrir les liens externes du chat dans la barre latérale',
|
||||
settingsBrowserLinksDesc: 'Une fois activé, cliquer sur un lien externe dans le chat ou l’interface l’ouvre dans la barre latérale au lieu d’une nouvelle fenêtre ; HTTP et HTTPS sont contrôlés séparément par les interrupteurs ci-dessous ; Ctrl/Cmd+clic autorise temporairement',
|
||||
settingsBrowserHttpTitle: 'Ouvrir les pages HTTP de côté',
|
||||
settingsBrowserHttpDesc: 'Une fois activé, cliquer sur un lien externe HTTP du chat ou de l’interface l’ouvre dans la barre latérale (les pages de plugin déclarant urlTarget sont prioritaires) ; Ctrl/Cmd+clic autorise temporairement',
|
||||
settingsBrowserHttpsTitle: 'Ouvrir les pages HTTPS de côté',
|
||||
settingsBrowserHttpsDesc: 'Une fois activé, cliquer sur un lien externe HTTPS du chat ou de l’interface l’ouvre dans la barre latérale. Désactivé par défaut : la plupart des sites HTTPS refusent l’encapsulation, le navigateur système est plus fluide',
|
||||
settingsBrowserLoopbackTitle: 'Adresses locales autorisées',
|
||||
settingsBrowserLoopbackDesc: 'Liste blanche séparée par des virgules des adresses de bouclage (ex. localhost:5174 ou 127.0.0.1:8080) que le navigateur de la barre latérale peut visiter ; vide bloque toutes les adresses locales par défaut. Le bac à sable reste actif — les pages ne peuvent pas lire les données de l interface',
|
||||
settingsBrowserLoopbackPlaceholder: 'ex. localhost:5174, 127.0.0.1:8080',
|
||||
browserOpenExternal: 'Ouvrir dans le navigateur',
|
||||
browserEmbedBlocked: '{host} a refusé la demande d’encapsulation',
|
||||
browserEmbedBlockedDesc: 'Ce site interdit son affichage dans d’autres pages (X-Frame-Options / frame-ancestors) et ne peut donc pas être chargé dans la barre latérale. Vous pouvez l’ouvrir directement dans le navigateur',
|
||||
browserEmbedAnyway: 'Charger quand même',
|
||||
subagent: 'Gestion des tâches',
|
||||
openSubagent: 'Gestion des tâches',
|
||||
subagentMainAgent: 'Agent principal',
|
||||
subagentEmpty: 'Aucun sous-agent pour l’instant',
|
||||
subagentEmptyDesc: 'Les sous-agents dérivés de l’agent principal actuel s’afficheront ici',
|
||||
subagentRunning: 'En cours',
|
||||
subagentInactive: 'Inactif',
|
||||
subagentModeOneShot: 'À usage unique',
|
||||
subagentModeContinuable: 'Reprenable',
|
||||
subagentCount: '{count} sous-agent(s)',
|
||||
subagentCountRunning: '{count} sous-agent(s) · {running} en cours',
|
||||
subagentDiagCorrupt: 'Répertoire corrompu',
|
||||
subagentDiagUnsupported: 'Entrée non prise en charge',
|
||||
subagentDiagUnavailable: 'Indisponible',
|
||||
subagentThinking: 'Réflexion…',
|
||||
sideChat: 'Discussion latérale (bêta)',
|
||||
sideChatNew: 'Nouvelle discussion',
|
||||
sideChatUntitled: 'Nouvelle discussion',
|
||||
sideChatEmpty: 'Aucune discussion latérale pour l’instant',
|
||||
sideChatEmptyDesc: 'Chaque discussion latérale est un onglet indépendant de la barre d’onglets, qui hérite du contexte de la session actuelle et n’entre jamais dans la session principale',
|
||||
sideChatCreating: 'Création de la discussion latérale…',
|
||||
sideChatRetry: 'Réessayer',
|
||||
sideChatThreads: 'Changer de fil / nouveau',
|
||||
sideChatSave: 'Enregistrer comme nouvelle session',
|
||||
sideChatSaveTitle: 'Promouvoir ce fil en session de premier niveau, visible dans la liste des sessions principales',
|
||||
sideChatSaved: 'Enregistré comme nouvelle session',
|
||||
sideChatNoTurn: 'Au moins un tour de discussion doit être terminé avant l’enregistrement',
|
||||
sideChatPendingDrop: 'La dernière relance inachevée ne sera pas incluse dans la nouvelle session',
|
||||
sideChatFirstPlaceholder: 'Saisissez la première question, le contexte de la session actuelle est hérité…',
|
||||
sideChatComposerPlaceholder: 'Relance…',
|
||||
sideChatThinking: 'Approfondissement…',
|
||||
sideChatThink: 'Processus de réflexion',
|
||||
sideChatInjection: 'Contexte injecté',
|
||||
sideChatSend: 'Envoyer',
|
||||
sideChatCancel: 'Arrêter',
|
||||
sideChatCancelTitle: 'Interrompre le tour en cours (la file d’attente est conservée)',
|
||||
sideChatClose: 'Fermer le fil',
|
||||
sideChatCloseTitle: 'Libérer l’agent du fil (l’historique est conservé)',
|
||||
sideChatError: 'Erreur de la discussion latérale : {message}',
|
||||
jobs: 'Tâches d’arrière-plan',
|
||||
jobsCount: '{count} tâche(s) d’arrière-plan',
|
||||
jobsCountRunning: '{count} tâche(s) d’arrière-plan · {running} en cours',
|
||||
jobStatusRunning: 'En cours',
|
||||
jobStatusStopping: 'Arrêt en cours',
|
||||
jobStatusCompleted: 'Terminée',
|
||||
jobStatusKilled: 'Interrompue',
|
||||
jobStatusFailed: 'Échouée',
|
||||
jobDurationSeconds: '{seconds} s',
|
||||
jobDurationMinutes: '{minutes} min {seconds} s',
|
||||
jobDurationHours: '{hours} h {minutes} min',
|
||||
jobViewOutput: 'Voir la sortie',
|
||||
jobHideOutput: 'Réduire la sortie',
|
||||
jobNoOutput: 'Aucune sortie pour l’instant',
|
||||
jobNotReadYet: 'En attente que le modèle lise la sortie de cette tâche (la sortie s’affichera ici une fois que le modèle aura exécuté job_output)',
|
||||
jobOutputTruncated: 'Sortie trop longue, affichage tronqué',
|
||||
jobOutputError: 'Échec de la lecture de la sortie',
|
||||
jobKill: 'Interrompre',
|
||||
jobKillConfirm: 'Cliquez à nouveau pour confirmer l’interruption',
|
||||
jobKillError: 'Échec de l’interruption',
|
||||
addPluginsTabCard: 'Ajouter un plugin d’onglet',
|
||||
addPluginsTabCardDesc: 'Enregistrer une nouvelle page de la barre latérale',
|
||||
addPluginsViewerCard: 'Ajouter un plugin d’aperçu',
|
||||
addPluginsViewerCardDesc: 'Enregistrer un aperçu d’un type de fichier',
|
||||
addPluginsTabDesc: 'Les pages de la barre latérale (onglets) peuvent être étendues par des plugins. Les plugins s’enregistrent via le service ctx.betterSidebar ; cliquez sur « Installer » pour copier la commande d’installation, collez-la dans un terminal de l’environnement DSH et exécutez-la.',
|
||||
addPluginsViewerDesc: 'Les aperçus de fichiers peuvent être étendus par des plugins. Les plugins s’enregistrent via le service ctx.betterSidebar ; cliquez sur « Installer » pour copier la commande d’installation, collez-la dans un terminal de l’environnement DSH et exécutez-la.',
|
||||
addPluginsBrowseMore: 'Parcourir plus de plugins sur GitHub (topic : dsh-better-sidebar)',
|
||||
addPluginsSearch: 'Rechercher par nom / description de plugin…',
|
||||
addPluginsNoMatch: 'Aucun plugin correspondant',
|
||||
addPluginsRecommended: 'Plugins recommandés',
|
||||
addPluginsEmpty: 'Aucun plugin référencé pour l’instant, publiez le vôtre sous le topic GitHub',
|
||||
openPlugin: 'Aller',
|
||||
copyInstall: 'Copier la commande d’installation',
|
||||
pluginOfficeDesc: 'Aperçu de la suite Office (.docx / .xlsx / .pptx) pour l’éditeur better-sidebar, avec les lourdes bibliothèques de rendu Office extraites du package principal et installées à la demande',
|
||||
pluginFlowglassDesc: 'Schéma de flux de session en temps réel : trois couloirs pour l’utilisateur, l’assistant et les appels d’outils, avec regroupement en parallèle, branches de sous-agents, exploration par niveau et états en temps réel ; enregistre l’onglet natif « Flowglass » une fois better-sidebar installé, sinon conserve un tiroir indépendant',
|
||||
pluginGitForgeDesc: 'Onglet « Identifiants Git » de better-sidebar : bibliothèque de comptes GitHub/Gitea (et autres forges) + autorisations par projet + blocage strict des stratégies push ; les tokens restent dans les secrets locaux, jamais dans le contexte du modèle ; fournit un outil GitForge en lecture seule et un helper HTTPS de credentials pour l’agent',
|
||||
pluginGitRemotesDesc: 'Onglet Git Remotes de better-sidebar : branches/amonts/ahead-behind, fetch (avec prune possible), pull en ff-only, push seulement après confirmation. Ne remplace pas la mise en scène/les commits du Git intégré et ne fournit pas de force-push ni de push automatique par le modèle',
|
||||
pluginSentinelDesc: 'Système de réveil d’agent piloté par conditions : capteurs fichier/processus/port/HTTP/commande/webhook, les sessions en veille se réveillent automatiquement lorsque les conditions sont remplies ; enregistre l’onglet « Sentinel » affichant le tableau de surveillance global du serveur',
|
||||
pluginSidebarQaDesc: 'Onglet de questions sur sélection basé sur better-sidebar : sélection dans la discussion → question dans le panneau de droite → session de relance indépendante du même espace de travail (❓ relance · thème) : un modèle rapide sans réflexion compresse le contexte de la discussion principale puis l’injecte avec la citation, sans interrompre la discussion principale ; les relances peuvent être imbriquées, poursuivies et archivées',
|
||||
pluginSshTunnelDesc: 'Onglet « Tunnel SSH » de better-sidebar : inventaire multi-machines + autorisations par projet + clés conservées localement ; outil de modèle SSHManager (exec/SFTP/stratégies de session) ; terminal interactif central et SFTP à deux volets',
|
||||
pluginTurnReviewDesc: 'Passerelle humaine sur le diff de « ce dernier tour » : Approve / Request changes, sans forker la session ; fichiers regroupés par session principale / sous-agent / non attribués, cochez les fichiers pour renvoyer + commentaire facultatif ; cliquez un fichier pour voir le diff entre l’instantané de début de tour et maintenant. Ce n’est pas /rewind',
|
||||
pluginVideoPreviewDesc: 'Aperçu vidéo en ligne dans l’éditeur better-sidebar (.mp4/.webm/.mov/.mkv/.avi, etc.), avec une route hôte /video prenant en charge HTTP Range (206) ; barre de progression déplaçable, non limité par la mediaLimit de 20 Mo',
|
||||
pluginDocsPanelDesc: '« Documentation globale » dans la barre latérale DSH : notes Markdown globales, lisibles depuis n’importe quel espace de travail — sélection dans la liste, saut via le plan flottant, ouverture externe Chrome / VS Code, copie de code, répertoire configurable (défaut ~/.dsh/docs)',
|
||||
pluginEgoBrowserDesc: 'Le navigateur d’agent pour DeepSeek Harness : 32 outils ego_* pilotent un vrai Chromium ; un onglet natif « ego browser » dans la barre latérale montre en direct chaque page visitée par l’agent — cliquez, glissez et tapez pour reprendre la main. Enregistre l’onglet automatiquement si better-sidebar est présent, sinon une bulle flottante',
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user