Compare commits
145
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0aafa94c4d | ||
|
|
df6f99c4a3 | ||
|
|
40a05af9ba | ||
|
|
2b88c85ea7 | ||
|
|
2224991047 | ||
|
|
63bcb47ed0 | ||
|
|
4422001c5e | ||
|
|
4c242acf7b | ||
|
|
e5d34ac89f | ||
|
|
5a7e2139a0 | ||
|
|
8753096a58 | ||
|
|
b2e497b9c9 | ||
|
|
817ee8fc6d | ||
|
|
d725e54608 | ||
|
|
da5540c9ac | ||
|
|
0c18e492cf | ||
|
|
1ef2192a44 | ||
|
|
4fe67e3965 | ||
|
|
fe2017af49 | ||
|
|
6317dde7c0 | ||
|
|
22e772ce45 | ||
|
|
a4e94c976e | ||
|
|
1d05e39be2 | ||
|
|
d5bbb52c89 | ||
|
|
5451f5c699 | ||
|
|
62c83ff611 | ||
|
|
112f151c04 | ||
|
|
787cff4319 | ||
|
|
e567bf6924 | ||
|
|
9ffaeb2f78 | ||
|
|
52a7e6a553 | ||
|
|
0a2f1ac018 | ||
|
|
7f2674ab70 | ||
|
|
22a0452a59 | ||
|
|
e786d8f112 | ||
|
|
70a150794b | ||
|
|
ccb89fd91b | ||
|
|
17db3cf014 | ||
|
|
6c1efc1b00 | ||
|
|
bbf953b8a8 | ||
|
|
a0bbfa0089 | ||
|
|
754974afea | ||
|
|
191b764154 | ||
|
|
725b2f7cec | ||
|
|
036a747c80 | ||
|
|
6729239575 | ||
|
|
7545de3765 | ||
|
|
14ee16c108 | ||
|
|
35c5c05d6e | ||
|
|
bec90212f5 | ||
|
|
0e9b9169eb | ||
|
|
0b6ac5d267 | ||
|
|
d4765819c0 | ||
|
|
9ebaa09f80 | ||
|
|
2d9f90b551 | ||
|
|
223b2545d9 | ||
|
|
91de0e2932 | ||
|
|
b56ceec41d | ||
|
|
4da2b43610 | ||
|
|
9a2c56eb42 | ||
|
|
9ce3c2706a | ||
|
|
8c452104e5 | ||
|
|
b3ebe52c3e | ||
|
|
c4c856a450 | ||
|
|
1fa777293f | ||
|
|
993f7aaacd | ||
|
|
fb2afabc72 | ||
|
|
2448068369 | ||
|
|
d660f33966 | ||
|
|
aa1b9945d9 | ||
|
|
4a6a77a27e | ||
|
|
d7e3e2b986 | ||
|
|
51546f7433 | ||
|
|
ba0114749f | ||
|
|
d88dcfc3a5 | ||
|
|
3e408dbe48 | ||
|
|
eecf9dc536 | ||
|
|
ad22abcfe6 | ||
|
|
81919516d8 | ||
|
|
162c16ec32 | ||
|
|
8909455df2 | ||
|
|
a9a635138a | ||
|
|
e847771954 | ||
|
|
e8761ee851 | ||
|
|
8aa9441627 | ||
|
|
870499430b | ||
|
|
26706bc3e9 | ||
|
|
829ad49ddf | ||
|
|
de0eac54b7 | ||
|
|
8722bfb29c | ||
|
|
8025b83a45 | ||
|
|
05773e0f2a | ||
|
|
146840bb4f | ||
|
|
364a48df98 | ||
|
|
df1f3b9666 | ||
|
|
5bf7dfe3c4 | ||
|
|
900654b8fd | ||
|
|
d990c10e59 | ||
|
|
6b02285742 | ||
|
|
6fbfeedc01 | ||
|
|
9302d02cf5 | ||
|
|
0c3d1d6e32 | ||
|
|
157e02e768 | ||
|
|
04693192df | ||
|
|
03e19c4cd4 | ||
|
|
dc40f82180 | ||
|
|
6df06717ad | ||
|
|
8456d77a92 | ||
|
|
2dc2dcf418 | ||
|
|
2a99968fa3 | ||
|
|
bb21657eae | ||
|
|
e356791661 | ||
|
|
93ad87dfe7 | ||
|
|
a5c52b3f1b | ||
|
|
e39ea92ade | ||
|
|
76b57508f4 | ||
|
|
e1b9b539e4 | ||
|
|
7f9a040065 | ||
|
|
16d8df2e93 | ||
|
|
a2d3faf7b5 | ||
|
|
f59ffd0741 | ||
|
|
f7332f332b | ||
|
|
e105b01f2b | ||
|
|
b4ab3f154f | ||
|
|
d5b1201ad8 | ||
|
|
6bccd21a49 | ||
|
|
dbc73f0bf3 | ||
|
|
68839aa5fb | ||
|
|
1ec1565b13 | ||
|
|
6ef11ae010 | ||
|
|
aa01140252 | ||
|
|
dc33087ce1 | ||
|
|
8820efd6bf | ||
|
|
7b5c7ddcc4 | ||
|
|
f8bb3013ff | ||
|
|
463e96b6b4 | ||
|
|
98e9a4ff0f | ||
|
|
5a1e75e46b | ||
|
|
f418692ad0 | ||
|
|
e8834bd349 | ||
|
|
040b182b1c | ||
|
|
fa301da29e | ||
|
|
2eb9f66ce8 | ||
|
|
0142ac550b | ||
|
|
b7138cf4fd |
@@ -41,6 +41,8 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
- name: Build
|
||||
@@ -88,8 +90,30 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
# `test:windows` runs the whole suite in ONE fork (`--maxWorkers=1`),
|
||||
# not `pnpm test`'s unbounded pool. Two distinct Windows-only failures
|
||||
# motivated it, both recorded in docs/plans/2026-09-10-dsh-0.1.5-rc.2-adaptation.md:
|
||||
#
|
||||
# 1. Six red runs were real 5000 ms vitest timeouts in specs that
|
||||
# really start a process (tests/agent-pty:71/:106,
|
||||
# tests/install-powershell:105 — a cold powershell.exe start
|
||||
# measured 12.1 s, tests/git.spec.ts:85 at 9.6 s). The suite now
|
||||
# declares its own budgets (global testTimeout 15 s, 30 s in the
|
||||
# two heaviest describes), and serial execution stops the
|
||||
# process-spawning specs starving each other on a 2-core runner.
|
||||
# 2. Four runs died at teardown with `[vitest-pool]: Worker forks
|
||||
# emitted error / Worker exited unexpectedly` while reporting
|
||||
# N-1 of N files passed and zero FAIL lines — a worker recycling
|
||||
# race, not a test failure. It survived --maxWorkers=2, so the
|
||||
# lane now recycles nothing at all.
|
||||
#
|
||||
# No retries: they would hide a real regression instead of removing the
|
||||
# race. The cost is wall-clock (the Windows step runs several minutes
|
||||
# against a 30-minute job budget), not signal.
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
run: pnpm test:windows
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
- name: Check consumer type surface
|
||||
@@ -127,10 +151,31 @@ jobs:
|
||||
# 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.
|
||||
# DSH 0.1.2-rc.1 is published under the `next` dist-tag; the lanes
|
||||
# speak the slash-only RPC dialect (tests/e2e/host-protocol.ts).
|
||||
# DSH 0.1.5-rc.2 is the `next` dist-tag on npm (`latest` still points
|
||||
# at rc.1); the lanes speak the slash-only RPC dialect
|
||||
# (tests/e2e/host-protocol.ts).
|
||||
#
|
||||
# The larger heap is the fix for one of the two ways this step used to
|
||||
# die (runs 34490912649 and 34363627184 on the alpha line): the pinned
|
||||
# version's TRANSITIVES are declared as floating `^` ranges, so while
|
||||
# upstream publishes a prerelease in stages (0.1.5-rc.2 went out
|
||||
# 14:43-14:57 on 2026-09-10) npm resolved a mixed rc.1/rc.2 peer graph —
|
||||
# 3062 ERESOLVE lines, then "Ineffective mark-compacts near heap limit"
|
||||
# (exit 134). The other failure mode was a hard ETARGET on a sibling
|
||||
# package that appeared minutes later; pinning a version that is fully
|
||||
# published (not mid-flight) is the fix for that one.
|
||||
#
|
||||
# Peer resolution stays ON: `--legacy-peer-deps` was tried in the first
|
||||
# cut of this fix and made the install broken rather than robust — it
|
||||
# skips exactly the peers a global install has to supply, and
|
||||
# @deepseek-ai/dsh-app-boot requires `@deepseek-ai/cordis-plugin-group`
|
||||
# (a peer, not a dependency) at boot, so the CLI died with
|
||||
# ERR_MODULE_NOT_FOUND before the smoke could even start. Verified
|
||||
# locally that the plain install resolves it.
|
||||
- name: Install dsh CLI (pinned)
|
||||
run: npm install -g @deepseek-ai/dsh@0.1.2-rc.1
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
run: npm install -g @deepseek-ai/dsh@0.1.5-rc.2
|
||||
|
||||
# lib/ is gitignored; the tarball is produced from this build.
|
||||
- name: Build and pack the npm tarball
|
||||
|
||||
@@ -88,6 +88,9 @@ jobs:
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
@@ -95,8 +98,9 @@ jobs:
|
||||
run: pnpm build
|
||||
|
||||
# The consumer-facing declaration surface guard — mirrors the ci.yml
|
||||
# verification order (typecheck → test → build → check:consumer-types)
|
||||
# so a release is gated on the same checks a PR runs.
|
||||
# verification order (typecheck → lint → test → build →
|
||||
# check:consumer-types) so a release is gated on the same checks a PR
|
||||
# runs.
|
||||
- name: Check consumer type surface
|
||||
run: pnpm check:consumer-types
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# dsh-better-sidebar 仓库规则(AGENTS)
|
||||
# dsh-apes-sidebar 仓库规则(AGENTS)
|
||||
|
||||
> 本文只含**项目全局开发规则**(面向贡献者与 agent)。
|
||||
> 消费插件接入 API 全参考(`ctx.betterSidebar` 服务、TabDescriptor / FileViewerDescriptor 全字段、声明式设置、自由窗口、皮肤契约等)→ [docs/external-plugin-guide.md](docs/external-plugin-guide.md);逐特性设计史(含实施偏差记录)→ [docs/plans/](docs/plans/)。
|
||||
> 消费插件接入 API 全参考(`ctx.betterSidebar` 服务、TabDescriptor / FileViewerDescriptor 全字段、声明式设置、原生右侧栏承载面、皮肤契约等)→ [docs/external-plugin-guide.md](docs/external-plugin-guide.md);逐特性设计史(含实施偏差记录)→ [docs/plans/](docs/plans/)。
|
||||
|
||||
---
|
||||
|
||||
@@ -17,28 +17,29 @@
|
||||
|
||||
## 2. CI 挂载冒烟(`plugin-mount` job / `pnpm test:mount`)
|
||||
|
||||
「npm 打包 → 真实挂载 → 无头渲染」门禁(证明打包产物在真实 DSH 挂载后不 crash):`pnpm build && pnpm pack` 产 tarball → `scripts/e2e-mount.sh` 装进全新 scratch profile(`dsh plugin --profile web add file:<tarball>`)并启动真实 `dsh web`(keyless,`--port 0`)→ `tests/e2e/mount.e2e.ts`(Playwright)断言 `[data-dsh-better-sidebar]` 挂载、无错误条/pageerror/console 错误,经「+ 菜单」逐个打开内置 tab(含终端懒加载 chunk),再经文件树打开 seed 文件强制加载 editor chunk(`client-editor.js`)。
|
||||
「npm 打包 → 真实挂载 → 无头渲染」门禁(证明打包产物在真实 DSH 挂载后不 crash):`pnpm build && pnpm pack` 产 tarball → `scripts/e2e-mount.sh` 装进全新 scratch profile(`dsh plugin --profile web add <tarball>`)并启动真实 `dsh web`(keyless,`--port 0`)→ `tests/e2e/mount.e2e.ts`(Playwright)断言 `[data-dsh-apes-sidebar]` 挂载、无错误条/pageerror/console 错误,展开 DSH 原生右侧栏后经其 guide 页逐个打开插件 tab 类型(含终端懒加载 chunk),再经插件文件树(原生 `files` kind 接管)打开 seed 文件强制加载 editor chunk(`client-editor.js`),并跑 mermaid / README 预览与 sidechat 宿主路由烟测。
|
||||
|
||||
本地:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`。CI 钉 `@deepseek-ai/dsh@0.1.2-rc.1`(`next` dist-tag;peer 下限 `^0.1.2-rc.1`)。e2e spec 命名 `*.e2e.ts` + vitest `exclude` 双保险;**改 `exclude` 必须保留默认排除项**(exclude 整体替换默认值)。
|
||||
本地:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`。CI 钉 `@deepseek-ai/dsh@0.1.5-rc.2`(npm `next`;`latest` 仍是 rc.1;peer 下限 `^0.1.5-rc.1`)。该步骤用 `NODE_OPTIONS=--max-old-space-size=4096`:钉版自身的传递依赖是浮动 `^` 范围,上游分阶段发布预发布版时(rc.2 于 2026-09-10 的 14:43–14:57 逐包上线)npm 会组出混合 peer 图,3062 条 ERESOLVE 后 OOM(exit 134);另一个失败模式是对未发布兄弟包 ETARGET,靠「钉一个已完整发布的版本」修掉。**不要加 `--legacy-peer-deps`**:它跳过的正是全局安装必须提供的 peer,`@deepseek-ai/dsh-app-boot` 在 boot 时 require 的 `@deepseek-ai/cordis-plugin-group` 是 peer 而非 dependency,加了它 CLI 直接 `ERR_MODULE_NOT_FOUND`(实测过一次,见 rc.2 计划 C 节)。`ci-windows` 的 `Test` 跑 `pnpm test:windows`(`--maxWorkers=2`)而非 `pnpm test`——多个 spec 真起进程(`agent-pty` / `pty-deps` / `pty-helpers` / `install-powershell` / `smoke`),2 核 runner 上并行起 ConPTY / 冷启 `powershell.exe` 是超时与 worker 静默死亡的放大器;`vitest.config.ts` 的全局 `testTimeout: 15_000`(默认 5000 在 Windows 上对真起进程的用例太低,三个不同文件先后翻车)是配套的一半。e2e spec 命名 `*.e2e.ts` + vitest `exclude` 双保险;**改 `exclude` 必须保留默认排除项**(exclude 整体替换默认值)。
|
||||
|
||||
---
|
||||
|
||||
## 3. DSH 0.1.2 适配要点(0.1.2-rc.1+ 基线)
|
||||
## 3. DSH 0.1.5 适配要点(0.1.5-rc.1+ 基线)
|
||||
|
||||
**v0.18.0 起插件毕业正式版(npm `latest`),仅支持 DSH 0.1.2-rc.1+**(peer 下限 `^0.1.2-rc.1`,CI 钉 `@deepseek-ai/dsh@0.1.2-rc.1`,DSH rc.x 发在 `next` dist-tag)。v0.18.0-alpha.0 进入 alpha 通道时已删除对 0.1.0-rc.8 ~ 0.1.1-rc.2 的兼容层——双方言 RPC(点分回退)、MarkdownText 双形状 labels、`dsh-client-runtime` externals/inject 残留行;stable DSH 用户请固定装 v0.17.1(npm 上的旧 latest,0.1.2-alpha.x 宿主继续用 npm `alpha` 的 v0.18.0-alpha.0)。发版:release.yml 按版本号是否含 `-` 自动选 `alpha`/`latest` dist-tag。
|
||||
**v0.19.0(正式版,npm `latest`)起仅支持 DSH 0.1.5-rc.1+**(peer 下限 `^0.1.5-rc.1`,CI 钉 `@deepseek-ai/dsh@0.1.5-rc.2`)。**v0.19.1 把钉版推进到 rc.2 而 peer 下限不动**:rc.1 → rc.2 的 delta 里没有任何触及本插件的面(零 `packages/api|host|session|agent` 变更,真正的代码改动只有消息反馈弹窗、产物卡片 CSS、`CodeFileIcon` 的 SVG 数据拆分),`^0.1.5-rc.1` 天然容纳 rc.2,rc.1 用户无需升级即可用新版插件。0.1.5-alpha.2 用户停留在 **v0.19.0-alpha.1**(npm `alpha`)——rc 线的 delta 都很小,插件不再对 alpha 线做运行时兼容;0.1.2-rc.1 稳定线用户继续用 **v0.18.1**——0.1.5 的会话事件模型与文件打开漏斗都变了,插件不再对 0.1.2 做运行时兼容。发版:release.yml 按版本号是否含 `-` 自动选 `alpha`/`latest` dist-tag(0.19.1 无后缀 → `latest`;npm `alpha` 标签仍指向 `0.19.0-alpha.1`)。
|
||||
|
||||
宿主契约(均经真机挂载冒烟 14/14 验证):
|
||||
|
||||
1. **一次性 token 鉴权**:就绪行 `dsh web: http://127.0.0.1:<port>/?token=<43字符>`(导航换签名 cookie,干净 URL 401)。`e2e-mount.sh` 的 URL grep 必须延伸到空白(`[^ ]*`,在 `/` 截断丢 token);e2e 统一走 `tests/e2e/host.ts`(token 必选:`parseLaunchUrl` 对裸 origin 直接抛错),带 stamps 导航走 `gotoPage()`(先 addCookies 再直达——token 换 cookie 的 303 会丢弃同 URL 其它 query 参数)。插件 `/sidebar/*` 路由不受影响,同源 fetch 照旧。
|
||||
2. **Remote gateway 斜杠 RPC(唯一方言)**:`POST /api/workspace/create`,payload 恰为 `{args: {...}}`,**args 按控制器 TS 参数名包装**(`workspace/create`、`session/create` → `{args:{request:{...}}}`;`session/list` 参数名 `_request` **不可省略**——`{}` 也被拒 `args fields do not match the descriptor`);envelope `method` 与路径一致,点分路径 404。请求由 `tests/e2e/host-protocol.ts` 的 `rpcAttempt` 构造、`tests/e2e-host-protocol.spec.ts` 锁定;要调新方法先在真机验参数名再进 `RPC_ARGS_KEY`。
|
||||
3. **`MarkdownText` labels 嵌套契约**:必填 `labels: { code: { copyLabel, copiedLabel }, footnotes }`(漏传回退硬编码中文)。四个渲染点(mermaid.tsx / MarkdownHtml.tsx / TextEditor.tsx / SideChatView.tsx)统一走 `src/client/markdown-labels.tsx` 的 `markdownTextProps()`。
|
||||
4. **侧边对话转录走自有路由,不碰客户端宿主 RPC**:`ctx.connection.api`(含 `sessions.history`)在 alpha.1 整体移除,继任 `session/follow|page` 又对 `origin:'subagent'` 会话强制 subagent 地址(普通 `{kind:'session'}` 被 `agent-busy` 拒)且分页 `throughSeq` 不得超当前游标。转录因此由 **`sidechat.events`** 插件路由供给(`src/sidechat-routes.ts`:live 读 `agent.session.snapshotEvents()`(0.1.2-alpha.4 起的按需读 API,前身 `Session.events` 属性已删)、冷读 `sessionPersistence.inspect`,服务端 `session/end-seed` 切割 + `afterSeq` 增量);`ctx.connection` 镜像与 inject 已删。设计见 [docs/plans/2026-08-20-sidechat-tab-design.md](docs/plans/2026-08-20-sidechat-tab-design.md) §10。
|
||||
5. **`dsh-settings` 无运行时 `settingsNamespace`**:命名空间合法性校验转为编译期模板字面量 `SettingsNamespaceInput`(小写字母开头 + `[a-z0-9-]` 尾部),`'dsh-better-sidebar'` 字面量直接过——宿主侧直接传常量(`src/index.ts` 的 settings inject)。
|
||||
4. **侧边对话转录走自有路由,不碰客户端宿主 RPC**:`ctx.connection.api`(含 `sessions.history`)在 alpha.1 整体移除,继任 `session/follow|page` 又对 `origin:'subagent'` 会话强制 subagent 地址(普通 `{kind:'session'}` 被 `agent-busy` 拒)且分页 `throughSeq` 不得超当前游标。转录因此由 **`sidechat.events`** 插件路由供给(`src/sidechat-routes.ts`:live 读 `agent.session.snapshotEvents()`、冷读 `sessionPersistence.inspect`,服务端 `session/end-seed` 切割 + `afterSeq` 增量);`ctx.connection` 镜像与 inject 已删。**0.1.5 起实时增量不在日志里**:`assistant/chunk` 事件被删除,进行中的模型增量改由 `agent/assistant-stream` 瞬时帧发布(`start` / `chunk` / `end`,不入会话日志),结算时才落 `assistant/message`(内嵌 `stream`)或 `assistant/attempt`(失败尝试,内嵌 `stream`)。插件在宿主侧 `src/assistant-live.ts` 折叠这些帧成有界缓冲,`sidechat.events` 的 `live` 字段按「当前 attempt 全量、每次轮询替换」下发(不是增量),转录映射与继承快照都读它。设计见 [docs/plans/2026-08-20-sidechat-tab-design.md](docs/plans/2026-08-20-sidechat-tab-design.md) §10。
|
||||
5. **`dsh-settings` 无运行时 `settingsNamespace`**:命名空间合法性校验转为编译期模板字面量 `SettingsNamespaceInput`(小写字母开头 + `[a-z0-9-]` 尾部),`'dsh-apes-sidebar'` 字面量直接过——宿主侧直接传常量(`src/index.ts` 的 settings inject)。
|
||||
6. **`dsh-subagent` 的 `SUBAGENT_DESCRIPTOR_VERSION` 2 → 3**:sidechat 种子的 `subagent/descriptor` 版本由宿主包盖章,插件不硬编码;测试断言跟随常量(`tests/sidechat-routes.spec.ts`),勿钉字面量。
|
||||
7. **`@deepseek-ai/dsh-client-runtime` 包已消亡**(继任 seed 是裸名 `dsh-client-store`,无 `/client` 子路径):peerDependencies、devDependencies、`dsh.client.inject`、chunk externals 白名单(`src/client/chunk-loader.ts` / `tsdown.config.ts` / `tests/chunk-loader.spec.ts` / `tests/manifest-consistency.spec.ts` 四处同步)均已无该条目。
|
||||
8. **e2e scratch profile 的 `minimumReleaseAgeExclude` 含 `'@deepseek-ai/*'`**(`scripts/e2e-mount.sh` / `e2e-aggregate-mount.sh`,与仓库根 `pnpm-workspace.yaml` 同策):alpha 版本常在发布后 24h 内跑 lane,pnpm 11 的 `minimumReleaseAge` 默认会拒装新鲜包。
|
||||
9. **插件开发树的 dsh-* 传递 peer 需提升为 devDependencies**(alpha.3 首见):`dsh-subagent` 等 npm 包把 `dsh-attachment` 等 dsh-* 姊妹包全部声明为 peerDependencies(由宿主 bundle 树统一提供,宿主侧无此问题),插件仓库若只直接依赖其中一部分,其余 peer 在 pnpm 下会解析到树上残留的旧版——如 `dsh-attachment@0.1.1-rc.1` 缺 `admitPromptContent` 导出、`dsh-subagent` 产物 import 它时测试加载即崩。因此 devDependencies 需涵盖 dev 树实际触达的全部 peer(attachment / code-runtime / scope / session-projection / system-prompt / user-approval / util-time 七个即为此提升,与直接依赖同款精确钉版);`@deepseek-ai/cordis` peer 自 alpha.3 起要求 `^4.0.2`(上游全线 peer 已升)。适配新 alpha 版本时先跑 `pnpm peers check`,把新失配的传递 peer 一并提升进 devDependencies。例外:**`dsh-client-locale` 的 peer/devDep 允许落后于基线**(alpha.5 时上游停在 0.1.2-alpha.3 未发新版)——peer 下限 `^0.1.2-alpha.3` 天然容纳同 tuple 的 alpha.5 运行时,此时保持旧钉版并在 `pnpm-workspace.yaml` 注明,待上游发版再追平;rc.1 起上游恢复发版,peer/devDep 已与基线一并钉 `0.1.2-rc.1`,该例外消除。
|
||||
10. **聊天文件打开漏斗是 `remote.session.openWorkspacePath`**(`ctx.workspaces.openPath` 已随 alpha 删除,`IWorkspaces` 无此方法):「聊天区文件在侧边栏打开」拦截在 `ctx.inject(['remote.session'], …)` 内以 **defineProperty 数据属性遮蔽**该命名空间方法(gateway client 的方法是 accessor 属性、异步挂载、contribution 重挂载时服务重建——inject 回调重跑即自愈);实现见 `src/client/openpath-intercept.ts`,设计见 [docs/plans/2026-08-31-openpath-intercept-alpha-design.md](docs/plans/2026-08-31-openpath-intercept-alpha-design.md)。
|
||||
9. **插件开发树的 dsh-* 传递 peer 需提升为 devDependencies**(alpha.3 首见):`dsh-subagent` 等 npm 包把 `dsh-attachment` 等 dsh-* 姊妹包全部声明为 peerDependencies(由宿主 bundle 树统一提供,宿主侧无此问题),插件仓库若只直接依赖其中一部分,其余 peer 在 pnpm 下会解析到树上残留的旧版——如 `dsh-attachment@0.1.1-rc.1` 缺 `admitPromptContent` 导出、`dsh-subagent` 产物 import 它时测试加载即崩。因此 devDependencies 需涵盖 dev 树实际触达的全部 peer(attachment / code-runtime / scope / session-projection / system-prompt / user-approval / util-time 七个即为此提升,与直接依赖同款精确钉版);`@deepseek-ai/cordis` peer 自 alpha.3 起要求 `^4.0.2`(上游全线 peer 已升)。适配新 alpha 版本时先跑 `pnpm peers check`,把新失配的传递 peer 一并提升进 devDependencies。例外:**`dsh-client-locale` 的 peer/devDep 允许落后于基线**(alpha.5 时上游停在 0.1.2-alpha.3 未发新版)——peer 下限 `^0.1.2-alpha.3` 天然容纳同 tuple 的 alpha.5 运行时,此时保持旧钉版并在 `pnpm-workspace.yaml` 注明,待上游发版再追平;rc.1 起上游恢复发版,该包回到与 DSH 同 tuple(0.1.2-rc.1 → 0.1.5-rc.1,peer 下限 `^0.1.5-rc.1`),该例外消除。**alpha.2 起还有一类:上游包自身的 `dependencies` 丢失**——`@deepseek-ai/dsh-client-ui-primitives@0.1.5-alpha.2` 的 manifest 不再声明任何 `dependencies`(alpha.1 声明了 19 个),但 `lib/*.js` 仍裸 import `anser` / `shiki` / `@shikijs/langs/*` / `mdast-util-*` / `micromark-*` / `katex`。宿主由预构建前端 bundle 满足,独立安装的 dev/test 树不会——vitest 一碰 primitives 就 `Cannot find package 'anser'`。此时把 alpha.1 声明的同一组版本提升进 devDependencies(`clsx` 已在 dependencies 无需重复)。**rc.1 复查:`@deepseek-ai/dsh-client-ui-primitives@0.1.5-rc.1` 仍不声明任何 `dependencies`,bundle 仍裸 import 同一组包**(rc.2 同:只把 `CodeFileIcon` 的 SVG 数据拆进了新文件,无 `dependencies`)——这组提升不得回退,`pnpm peers check` / 单测一旦报 `Cannot find package 'anser'` 即是有人回退了。
|
||||
10. **右侧栏是 DSH 原生栏,插件只提供 tab 类型**:聊天里一切文件打开(工具行 / 产物行 / 正文提及 / 行内代码路径)统一走 `ctx.sidebarRight.openResource(fileAddressFor(sessionId, cwd, path))`(`packages/client/ui-chat/src/client/apply.ts` 是唯一调用点),`remote.session.openWorkspacePath` 在 0.1.5 客户端已无调用者,插件的 openpath 拦截随之删除。插件的每个 `TabDescriptor` 注册成原生 tab 类型(`kind = descriptor.id`,`extension` 带)+ 原生 tab 体(`sidebar.right.pane.tab` keyed 槽,key = `dsh-apes-sidebar:<id>`);`editor` 类型同时认领 `dsh-resource://file/**`(压过内置 `text` 的 `fallback`),并接管内置 `files` 页面 kind(`openTab('files')` 打开插件文件树,注销即复位)。`ctx.sidebarRight.openTab/openResource/close` 只对**在屏会话**写入;跨会话用具体类上的 `openTabIn/openResourceIn/closeIn`(不在 `ISidebarRight` 接口里,需结构化探测),目标会话未挂载时排队到上屏重放(`src/client/native/surface.ts`)。原生 tab 的插件侧状态(合成 `SidebarTab`、树展开集合、实例编号)在 `src/client/native/tab-adapter.tsx`;原生布局只在内存,不做持久化。**注册必须等服务、不能等槽声明**:原生栏先声明 `sidebar.right.pane.tab` 再 `provide('sidebarRightTabs')`,真机 profile(web,0.1.5-alpha.1)上槽声明回调里 `ctx.get('sidebarRightTabs')` 仍是 undefined(实测 3 秒后才出现),按槽触发注册会静默什么都不注册且永不重试——用 `ctx.inject(['sidebarRightTabs'], cb)` 驱动类型注册(`tests/native-surface.spec.ts` 以「槽先触发、服务后到达」的顺序守护),槽声明只用来挂 tab 体。指南 §0 列了全部行为差异。**alpha.2 起(v0.19.0-alpha.1)**:指南条目一度改成「图标+标题」胶囊、`TabDescriptor.description` 随之下线,新建标签页的默认页改为从注册表选(恰好 1 个指南条目 → 直接开它,0 或 ≥2 → 开指南),`revealIfOpened` 对「页面」在同一 pane 内强制去重。原生 tab 体宿主 `.paneBody` 是**有确定高度的块级滚动容器**(非 flex 容器),native 适配层因此给每个 tab 体包一层 `height:100%` 的列 flex 宿主(`sidebar.module.css` 的 `.nativeTabHost`),tab 组件根继续用 `flex:1`/`height:100%`——否则根盒塌成内容高度,sidechat 的输入框就贴不到面板底。全局面板(根级 `main` keyed 槽 + `sidebar.panellist` + `ctx.layout.selectPanel/beginNavigation`、`rightbar` 改根级并新增 `rightbar.session`)插件**不接入**,只做兼容。底部工作台的中心列锚点改认 `[data-slot="main.conversation"]`(并跳过 `display: contents` 祖先,同时保留 alpha.1 的 `[data-slot="conversation"]`);文件地址语法跟随 alpha.2(`fileAddressFor` 一律 session 作用域、绝对路径保留前导 `/`、`parseFileAddress` 前缀解析并去 `?`/`#`)。**rc.1 起(v0.19.0)**:`SidebarRightGuideEntry.description` 回归(可选),插件恢复 `TabDescriptor.description`(六个内置类型各声明一条说明,`guideDesc*` 词条回到 20 份词典),但**宿主的原生指南只在列出的条目 ≤ 4 条时渲染说明**(上游 `MAX_DESCRIBED_ENTRIES = 4`;更长的列表是整列丢弃,不是截断)——插件默认贡献 6 个 guide 条目(文件 / 文件变动 / 任务管理 / 侧边对话 / 终端 / 浏览器),所以**默认组合下说明不渲染**,只有读者在插件设置页关掉足够多 tab 类型、把 guide 压到 ≤ 4 条时才出现;插件**不恢复**旧的 `nativeGuideDesc` 通用兜底句(宿主自己没有兜底,通用句是噪音),没声明说明的 descriptor 就不发 `description` 字段;条目缺 `icon` 时由宿主补方块占位。
|
||||
11. **自定义种子必须带 fork 标记对(`meta.isSeeded: true` + `inheritedEventCount`)**:dsh-session 契约「只给 seed 不标 isSeeded,种子算重放历史而非继承前缀」——缺标记时 `Session.ownEvents()` 含整个种子,子会话的 inbox 折(`inboxProjectionDefinition`,0.1.5 起 `@deepseek-ai/dsh-agent` 只导出 `Inbox` 接口,实现是该投影)重放种子里的 `agent/inbox/spliced`,**继承父会话切割时刻未领取的 inbox 输入**(排队用户消息 next-turn、长回合中工具结果上下文/steering next-step——后者即「上下文很长时侧边对话先把之前的 User msg 发出去」的根因,幽灵消息排在 boundary 之前最先发给模型)。宿主 `session.fork`(api-session-controller)的调用即规范形态;回归由 `tests/sidechat-seed-validation.spec.ts`(真实 `Session.create` + 对 `ownEvents()` 跑 inbox 折)守护。0.1.5 复评:无更优雅的 sidechat 宿主 API(`ContinuableStartSpec` 仍无 seed 字段),`AgentRegistry.create` 接缝补齐标记即为规范用法;`Session.create` 的 header `version` 必须用 `SESSION_FORMAT_VERSION`(0.1.5 是 `3` 字面量类型),勿钉 `0`。
|
||||
|
||||
---
|
||||
|
||||
@@ -58,14 +59,16 @@
|
||||
- **构建纯度门**:client bundle 禁止 value-import `@dsh-external/*` 或非白名单 `@deepseek-ai/*`(`tsdown.config.ts` 拦截);`import type {}` 被擦除不触发——类型可共享,运行时符号不行;跨插件交互走 `ctx.betterSidebar` 方法调用。
|
||||
- **懒加载 chunk**:重依赖(xterm/CodeMirror/mermaid)在独立 bundle(`lib/client-<name>.js`),经 `/sidebar/bundle` 按需下发、`globalThis.__dshChunks__` 物化(`src/client/chunk-loader.ts`),**核心 bundle 禁止静态 import `src/client/chunks/*`**。
|
||||
- **i18n**:词典在 `betterSidebar` 命名空间,跟随 DSH `ctx.locale`;**新增 zh key 必须同步 `src/client/locales-ja.ts` 的 ja 翻译**(否则 ja 下回退 en)。渲染 `MarkdownText` 必须经 `markdownTextProps()`(§3 第 3 条)。
|
||||
- **皮肤契约**:视觉值只消费 `--dsw-alias-*` / `--dsw-font-*` / `--ds-*` 令牌,无硬编码颜色;契约全文与 titleBar 四方案模型见[指南 §12](docs/external-plugin-guide.md),改动必须同步该节与 `tests/theme.spec.ts`。
|
||||
- **皮肤契约**:视觉值只消费 `--dsw-alias-*` / `--dsw-font-*` / `--ds-*` 令牌,无硬编码颜色,**插件没有任何豁免面**——文件/文件夹图标是 DSH 官方 `FileTypeIcon` 的图形(宿主自己的调色板),插件画的每个 glyph(含内置 tab 彩色图标)颜色都来自令牌;`tests/theme.spec.ts` 同时守护「图标模块零颜色字面量」「样式表每条 `color` 解析到令牌」「没有图标数据被做成 chunk」。契约全文与 titleBar 四方案模型见[指南 §12](docs/external-plugin-guide.md),改动必须同步该节与 `tests/theme.spec.ts`。
|
||||
- **契约反向引用**:皮肤契约被 `src/client/shell-presets.ts`、`tests/e2e/mount.e2e.ts` 的注释以「指南 §12」引用——调整指南章节结构时同步检查这两处。
|
||||
- **接入 API 即文档**:`src/client/service.ts` 与 `src/client/builtins/` 的任何行为变更,必须同步 [docs/external-plugin-guide.md](docs/external-plugin-guide.md)(唯一权威接入文档,不再双份维护)。
|
||||
- **文件/文件夹图标**:回退链是「具体 `names`/`exts` 注册 → catch-all(`exts: []`)→ 宿主 `FileTypeIcon`」。插件**不持有**扩展名表、图标数据或图标 chunk(#429 的 563 条数据与 `fileIconTheme` 开关在 rc.2 适配时删除,因为宿主已导出官方图形)。**语义雷区**:宿主分类器覆盖任意路径,所以注册 `exts: []` 会接管所有未具体命中的行——只想补几个类型的插件必须用 `exts`/`names`。
|
||||
- **内置 tab 图标**:六个内置类型与 diff 视图的 glyph 集中在 `src/client/builtins/tab-icons.tsx`(颜色在 `tab-icons.module.css`,全部 `--dsw-alias-*`),三处消费面自动同步:底部工作台 tab 条、原生指南胶囊、原生 tab 芯片。**原生芯片的图标是插件自己画的**——宿主的 `SidebarRightTabDefinition` 没有 icon 字段,但 `sidebar.right.pane.tab.title` 槽就是芯片内容(`NativeTabTitle` 渲染 `[glyph][title]`,glyph `aria-hidden` 以免改可访问名)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 文档与测试地图
|
||||
|
||||
- **接入 API 全参考**:[docs/external-plugin-guide.md](docs/external-plugin-guide.md)(消费插件开发者向;§4 Tab API / §5 FileViewer API / §7 服务方法 / §10 平台陷阱 / §11 自由窗口 / §12 皮肤契约 / §15 真实案例)。
|
||||
- **接入 API 全参考**:[docs/external-plugin-guide.md](docs/external-plugin-guide.md)(消费插件开发者向;§0 原生栏承载面 / §4 Tab API / §5 FileViewer API / §7 服务方法 / §10 平台陷阱 / §11 已移除的自由窗口 / §12 皮肤契约 / §15 真实案例)。
|
||||
- **设计文档**:[docs/plans/](docs/plans/)(30+ 份逐特性设计,含实施偏差记录)。
|
||||
- **关键测试守护**:`tests/service.spec.ts` / `builtins.spec.ts`(注册表与内置清单:7 tab + 6 viewer)/ `market-manifest.spec.ts`(市场约束)/ `e2e-host-protocol.spec.ts`(RPC 双协议)/ `free-window.spec.tsx`(自由窗口)/ `theme.spec.ts`(皮肤契约)/ `plugin-list.spec.ts`(推荐插件目录)/ `fs-search.spec.ts`(host 文件名搜索)。
|
||||
- **关键测试守护**:`tests/service.spec.ts` / `builtins.spec.ts`(注册表与内置清单:7 tab + 6 viewer)/ `market-manifest.spec.ts`(市场约束)/ `e2e-host-protocol.spec.ts`(RPC 双协议)/ `native-surface.spec.ts`(原生右侧栏承载面)/ `theme.spec.ts`(皮肤契约)/ `plugin-list.spec.ts`(推荐插件目录)/ `fs-search.spec.ts`(host 文件名搜索)。
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# dsh-better-sidebar 命令面薄封装:目标仅转发 package.json scripts(唯一事实源),
|
||||
# dsh-apes-sidebar 命令面薄封装:目标仅转发 package.json scripts(唯一事实源),
|
||||
# 新增/改名 script 不需要动这里;本文件只补目标发现(help)与 CI 门禁聚合
|
||||
# (check / mount / mount-aggregate / registry)。
|
||||
# 注:lint 目标留给引入 ESLint 的后续 PR。
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
@@ -17,11 +16,14 @@ build: ## 构建(清 lib/ → tsc → tsdown)
|
||||
typecheck: ## 类型检查(tsc --noEmit)
|
||||
pnpm typecheck
|
||||
|
||||
lint: ## 代码检查(eslint .,flat config 最小规则集)
|
||||
pnpm lint
|
||||
|
||||
test: ## 单元测试(vitest run)
|
||||
pnpm test
|
||||
|
||||
check: ## 聚合校验门禁:typecheck → build → test → check:consumer-types(对齐 CI)
|
||||
pnpm typecheck && pnpm build && pnpm test && pnpm check:consumer-types
|
||||
check: ## 聚合校验门禁:typecheck → lint → build → test → check:consumer-types(对齐 CI)
|
||||
pnpm typecheck && pnpm lint && pnpm build && pnpm test && pnpm check:consumer-types
|
||||
|
||||
clean: ## 清理构建产物与测试报告(lib/、*.tgz、playwright-report/、test-results/)
|
||||
rm -rf lib playwright-report test-results
|
||||
@@ -39,4 +41,4 @@ mount-aggregate: ## 聚合双挂载回归:build + pack → pnpm test:mount:agg
|
||||
registry: ## 组装 plugin-registry 暂存(registry/,不入库):build → node scripts/package-registry.mjs
|
||||
pnpm build && node scripts/package-registry.mjs
|
||||
|
||||
.PHONY: help install build typecheck test check clean pack mount mount-aggregate registry
|
||||
.PHONY: help install build typecheck lint test check clean pack mount mount-aggregate registry
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
# dsh-apes-sidebar
|
||||
|
||||
> **Fork notice** — This project is a fork of [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) v0.18.0 (commit `9e1a034`), distributed under the MIT License. Original copyright (c) 2026 dsh-external. This fork adds the `apes:*` information-layer tabs (tasks / agents / apps / messages / profile) on top of the upstream sidebar. See `package.json → forkedFrom` for the exact upstream pin.
|
||||
> [!NOTE]
|
||||
> **Fork notice** — 本项目是 [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) 的 fork,
|
||||
> 基线 **upstream v0.19.1**(commit `8753096`,MIT)。
|
||||
>
|
||||
> - **上游 v0.18.0 → v0.19.1 的关键变化**:v0.19.0-alpha.1 起,插件的内容**注册进 DSH 原生右侧栏**
|
||||
> (`src/client/native/`)——不再自绘第二套右侧面板与开关按钮。这正是本 fork 此前"窗口里有两套侧边栏
|
||||
> 与两套开关"的根因;升到 v0.19.1 后**只剩一套**。
|
||||
> - **本 fork 的增量**:`src/client/apes/` 下的 3 个信息层 tab(`apes:agents` 智能体 / `apes:apps` 应用 /
|
||||
> `apes:profile` 画像),按 DSH 标准 `TabDescriptor` 接口注册——因此它们会自动获得上游原生适配层
|
||||
> 的全部待遇(进原生 guide、tab 芯片图标、开合与分栏)。
|
||||
> - 版本线独立于上游:`1.2.0`(fork)。上游历史版本见其 CHANGELOG。
|
||||
> - **📚 项目百科**(需求分析 → 系统架构 → 功能说明 → 部署指南 → 运维手册 → 故障排查 → 开发与测试):
|
||||
> <https://gitea.apescale.com/figmar/apes-sidebar/wiki> 源文件在仓库 `docs/wiki/`,
|
||||
> 改完用 `bash scripts/push-wiki.sh` 一条命令发布。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **已适配 DSH 原生侧边栏 API**(v0.19.0 起,DSH `0.1.5-rc.1+`):右列就是 DSH 自己的右侧栏——插件的每个 tab 类型与 tab 体通过 `ctx.sidebarRightTabs` / `ctx.sidebarRight` 注册与打开,聊天里的文件打开统一走 `ctx.sidebarRight.openResource('dsh-resource://file/…')`,插件**不再自绘右侧面板**(旧的浮窗能力同步移除)。自绘的底部工作台与开放给其他插件的 `ctx.betterSidebar` 服务保持不变,接入方式见[插件接入指南](docs/external-plugin-guide.md)。
|
||||
|
||||
<!-- 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://www.npmjs.com/package/dsh-apes-sidebar"><img alt="npm version" src="https://img.shields.io/npm/v/dsh-apes-sidebar" /></a>
|
||||
<a href="https://www.npmjs.com/package/dsh-apes-sidebar"><img alt="npm downloads" src="https://img.shields.io/npm/dm/dsh-apes-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 版本(v0.18.0 正式版):0.1.2-rc.1+" src="https://img.shields.io/badge/DSH-0.1.2--rc.1%2B-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 />
|
||||
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.19.1 正式版):0.1.5-rc.1+(已在 0.1.5-rc.2 上验证)" src="https://img.shields.io/badge/DSH-0.1.5--rc.1%2B_%28verified_rc.2%29-4d6bfe" /></a>
|
||||
<a href="https://github.com/topics/dsh-apes-sidebar"><img alt="插件生态:GitHub topic dsh-apes-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="文件变动" 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" /><br /><br />
|
||||
<b>右侧栏 + 底部面板双工作台</b>,并把 <code>ctx.betterSidebar</code> 服务开放给所有插件——<br />
|
||||
通过 <code>registerTab</code> / <code>registerFileViewer</code> 注册新的侧边栏页面与文件预览器。
|
||||
@@ -23,7 +39,7 @@
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<img alt="dsh-better-sidebar 工作台截图" src="https://github.com/user-attachments/assets/991c4b70-d45a-461f-a8c8-0a28b4218e60" />
|
||||
<img alt="dsh-apes-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>
|
||||
|
||||
@@ -42,15 +58,14 @@
|
||||
|
||||
## ✨ 功能一览
|
||||
|
||||
- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大;README 级内嵌 HTML——徽章墙 / `<details>` 折叠 / 表格内联标签经 DOMPurify 消毒真实渲染;浮动目录大纲一键跳转)/ HTML / PDF
|
||||
- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红;文件树与文件 tab 按扩展名显示图标——markdown / 图片 / PDF / 代码 / 配置 / 压缩包等各有 glyph,插件可经 `registerFileIcon` 注册自定义图标与目录图标)+ 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 / 历史 / 暂存·提交·还原 / worktree·子仓库选择)与本轮文件视角(模型读 / 写 / 编辑实时追踪,按文件分组、按类型筛选)**双视角合一**;统一 diff 渲染(改蓝配对 + 行内字符级高亮 + 语法着色 + 上下文折叠),底部可拖拽预览面板,可一键展开为独立 diff tab(默认自由浮窗,可在设置改为面板下半 split)
|
||||
- **🌿 文件变动**:Git 视角(真 diff / 历史 / 暂存·提交·还原 / worktree·子仓库选择)与本轮文件视角(模型读 / 写 / 编辑实时追踪,按文件分组、按类型筛选)**双视角合一**;统一 diff 渲染(改蓝配对 + 行内字符级高亮 + 语法着色(含 mjs/cjs/mts/cts、CSS/SCSS/Less、HTML/XML/SVG/Vue、GraphQL、JSONC/JSON5)+ 上下文折叠),底部可拖拽预览面板,可一键展开为独立 diff tab(落进工作台的 diff 分栏);`.md` 操作(读 / 写 / 编辑)预览头部可切换**阅读模式**——经共享 MarkdownText 渲染 GFM 表格 / 任务列表 / 删除线 / 脚注 / 数学公式,本地图片自动改写为 `/sidebar/file` 媒体路由;含 ```mermaid 围栏时走编辑器同款懒加载 mermaid 渲染器(图可点击缩放 / 平移);**敏感内容脱敏**——凭据形态路径整文件遮罩、普通文件按内容形态遮值(api_key: / Bearer / sk- / AKIA / ghp_ / PEM 等,字段名保留),默认开启、预览面板一键开关(localStorage 记忆),仅影响显示、不改会话数据。已知边界:mermaid 无引号节点标签含被遮密钥时,图回退源码(规避:标签加引号);`.html` 操作(读 / 写 / 编辑)预览头部可切换**渲染模式**——复用编辑器同款 `/sidebar/html` 路由 iframe,相对资源(./style.css、img/x.png)同路由解析,分段读取也渲染完整文档,恒定沙箱(opaque origin + CSP 头,无逃生门);`.pdf` 操作(读 / 写 / 编辑)同样可切换**渲染模式**——复用编辑器同款 PDF 预览(媒体路由字节流 + 显式 Blob,浏览器原生查看器内嵌,附下载入口)
|
||||
- **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止)
|
||||
- **💬 侧边对话(beta)**:Codex 风格的侧边线程——继承主会话完整上下文(含进行中的回合与工具调用)独立运行,不进入主会话;线程内可持续追问,一键「保存为新会话」提升为顶层会话
|
||||
- **🪟 双工作台**:右侧栏 + 底部面板;拖 Tab 拆分 / 合并分栏(可跨面板),移动端自动合并全宽抽屉
|
||||
- **🪟 自由窗口**:把标签栏的任一 tab 拖到主会话区域——成为可移动 / 缩放 / 置顶的悬浮窗口(默认 390×780),拖回侧边栏 pane 即停靠,随会话持久化;`features` 含 `'floatWindows'`,插件 tab 无差别支持
|
||||
- **🖥️ 原生右侧栏 + 底部工作台**:右列交给 DSH 0.1.5 的原生右侧栏——插件把每个 tab 类型注册成原生 tab(文件打开走 `dsh-resource://file/**`,并接管内置「文件」页 / 文件树),插件自己只保留底部工作台(分栏 / 终端 / 随会话持久化),开合按钮挂在会话头右侧
|
||||
- **📌 固定终端**:右键终端 Tab 可「固定到工作区 / 固定到全局」——固定后切换会话不消失,在 TabBar 内联呈现(跨会话虚拟 Tab,点击就地激活,PTY 按 home 会话 id+tab 直连宿主 PTY,无需切回宿主会话);Agent 终端被 reconcile 移除时豁免保留
|
||||
- **🔁 会话隔离**:布局 / Tab / 面板按会话持久化,陈旧状态自动净化
|
||||
- **⚙️ 声明式设置**:设置页「侧边卡片」逐项独立开关,二级设置经齿轮弹窗
|
||||
@@ -64,14 +79,14 @@
|
||||
**前置**:已装好 DSH(`dsh web` 能正常运行),Node.js ≥ 20、pnpm ≥ 10。
|
||||
|
||||
**支持的 DSH 版本**:
|
||||
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.18.0 正式版):0.1.2-rc.1+" src="https://img.shields.io/badge/DSH-0.1.2--rc.1%2B-4d6bfe" /></a>
|
||||
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.19.1 正式版):0.1.5-rc.1+(已在 0.1.5-rc.2 上验证)" src="https://img.shields.io/badge/DSH-0.1.5--rc.1%2B_%28verified_rc.2%29-4d6bfe" /></a>
|
||||
|
||||
> 📌 **正式版**:`v0.18.0` 起适配 DSH **0.1.2-rc.1+**(npm dist-tag `latest`),不再支持 0.1.0-rc.8 ~ 0.1.1-rc.2——DSH stable(≤ 0.1.1-rc.2)用户请固定安装 `dsh-better-sidebar@0.17.1`(`@latest` 已由 v0.18.0 接管);停留在 0.1.2-alpha.x 的宿主请先升级 DSH,或继续用 `dsh-better-sidebar@alpha`(v0.18.0-alpha.0)。
|
||||
> 📌 **正式版**:`v0.19.0` 起适配 DSH **0.1.5-rc.1+**(npm dist-tag `latest`;`v0.19.1` 已在 **0.1.5-rc.2** 上完成真机挂载验证,rc.1 用户无需升级即可用本版——peer 下限仍是 `^0.1.5-rc.1`)。仍停在 DSH 0.1.5-alpha.2 的用户请固定安装 `dsh-apes-sidebar@0.19.0-alpha.1`;0.1.2-rc.1 稳定线用户继续用 `dsh-apes-sidebar@0.18.x`;DSH ≤ 0.1.1-rc.2 请用 `dsh-apes-sidebar@0.17.1`。
|
||||
|
||||
```sh
|
||||
dsh plugin --profile web add dsh-better-sidebar@latest # 首次会因 pnpm 11 拦截 node-pty 构建脚本而失败(依赖已写入)
|
||||
dsh plugin --profile web add dsh-apes-sidebar@latest # 首次会因 pnpm 11 拦截 node-pty 构建脚本而失败(依赖已写入)
|
||||
cd ~/.dsh/profiles/web && pnpm approve-builds --all # 放行构建脚本(自动重跑安装)
|
||||
dsh plugin --profile web add dsh-better-sidebar@latest # 重跑即成功
|
||||
dsh plugin --profile web add dsh-apes-sidebar@latest # 重跑即成功
|
||||
```
|
||||
|
||||
装完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可看到侧边栏(DSH 对 client 改动热加载,无需重启;仅 host 半更新时需要重启)。
|
||||
@@ -79,10 +94,10 @@ dsh plugin --profile web add dsh-better-sidebar@latest # 重跑即成功
|
||||
**方式二:让 DSH 自己装**——把下面这段提示词发给任意一个 DSH 会话:
|
||||
|
||||
```text
|
||||
帮我安装 dsh-better-sidebar 插件(DSH 侧边栏工作台),步骤:
|
||||
1. 执行 dsh plugin --profile web add dsh-better-sidebar@latest(首次会被 pnpm 11 拦截 node-pty 构建脚本而失败,属正常)
|
||||
帮我安装 dsh-apes-sidebar 插件(DSH 侧边栏工作台),步骤:
|
||||
1. 执行 dsh plugin --profile web add dsh-apes-sidebar@latest(首次会被 pnpm 11 拦截 node-pty 构建脚本而失败,属正常)
|
||||
2. 在 ~/.dsh/profiles/web 下执行 pnpm approve-builds --all(放行构建脚本,会自动重跑安装)
|
||||
3. 再次执行 dsh plugin --profile web add dsh-better-sidebar@latest
|
||||
3. 再次执行 dsh plugin --profile web add dsh-apes-sidebar@latest
|
||||
4. 完成后提醒我硬刷新浏览器(Cmd/Ctrl+Shift+R)
|
||||
遇到报错先查 https://github.com/omdsh-dev/DSH-better-sidebar README 的常见问题表。
|
||||
```
|
||||
@@ -93,7 +108,7 @@ dsh plugin --profile web add dsh-better-sidebar@latest # 重跑即成功
|
||||
<summary><b>更新</b></summary>
|
||||
|
||||
```sh
|
||||
dsh plugin --profile web add dsh-better-sidebar@latest
|
||||
dsh plugin --profile web add dsh-apes-sidebar@latest
|
||||
```
|
||||
|
||||
也可把 `~/.dsh/profiles/web/package.json` 里的版本号改高后 `pnpm install`。改完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可(client 改动无需重启 DSH)。
|
||||
@@ -108,10 +123,10 @@ dsh plugin --profile web add dsh-better-sidebar@latest
|
||||
| 报 `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` 之前。 |
|
||||
| 页面出现**两个侧边栏** | 双挂载。旧的手动挂载行:`~/.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-apes-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`。 |
|
||||
| 提示 `dsh: command not found` | 先安装 DSH;或直接用 `npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-apes-sidebar@latest`。 |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -123,11 +138,11 @@ dsh plugin --profile web add dsh-better-sidebar@latest
|
||||
```text
|
||||
1. git clone https://github.com/omdsh-dev/DSH-better-sidebar.git ~/Code/DSH-better-sidebar
|
||||
cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build
|
||||
2. ~/.dsh/profiles/web/package.json 的 dependencies 写 "dsh-better-sidebar": "link:<克隆目录绝对路径>"
|
||||
2. ~/.dsh/profiles/web/package.json 的 dependencies 写 "dsh-apes-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'
|
||||
name: 'dsh-apes-sidebar'
|
||||
config:
|
||||
shell: /bin/zsh
|
||||
shellArgs:
|
||||
@@ -137,7 +152,7 @@ dsh plugin --profile web add dsh-better-sidebar@latest
|
||||
5. 硬刷新浏览器(Cmd/Ctrl+Shift+R)即可看到效果(client 改动无需重启 DSH;host 半改动才需重启)
|
||||
```
|
||||
|
||||
更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.16.1"` 再 `pnpm install`。
|
||||
更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-apes-sidebar": "^0.16.1"` 再 `pnpm install`。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -151,7 +166,7 @@ git clone https://github.com/omdsh-dev/DSH-better-sidebar.git && cd DSH-better-s
|
||||
pnpm install && pnpm build
|
||||
node scripts/package-registry.mjs # 组装 registry/ 暂存(含清单 + 产物 + README,不入库)
|
||||
dsh registry install ./registry # 安装(默认禁用)
|
||||
dsh registry enable dsh-external/dsh-better-sidebar
|
||||
dsh registry enable dsh-external/dsh-apes-sidebar
|
||||
```
|
||||
|
||||
更新:`git pull && pnpm install && pnpm build` → `node scripts/package-registry.mjs` → `dsh registry uninstall/install/enable`。切换通道前先移除另一通道的挂载。
|
||||
@@ -168,7 +183,7 @@ dsh registry enable dsh-external/dsh-better-sidebar
|
||||
| **🖥️ 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>双视角合一:**Git 视角**保留完整源代码管理(暂存 / 取消暂存 / 提交(`Ctrl+Enter`)/ 还原、历史、worktree 与子仓库选择);**本轮文件视角**实时折叠会话事件日志,记录模型读 / 写 / 编辑的每个文件(按文件分组、按类型筛选、操作数角标)。点击任意改动在底部**可拖拽预览面板**查看统一 diff——删红 / 增绿 / 改蓝配对 + 行内字符级高亮 + 语法着色 + 上下文折叠——也可一键展开为 VSCode 式独立 diff tab(同一渲染栈)。</sub><br/><div align="center"><img width="420" alt="文件变动" 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> |
|
||||
| **💬 侧边对话(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> | **🖥️ DSH 原生右侧栏 + 插件底部工作台**<br/><sub>右列是 DSH 自己的右侧栏:插件把每个 tab 类型注册成原生 tab(含接管内置「文件」页与文件预览),聊天里的文件点击直接落到原生栏;插件自有底部面板可与其同时展开,拖 Tab 到分栏边缘**拆分**、拖到中间**合并**,高度拖上缘调节;开合按钮在会话头右侧。</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> |
|
||||
|
||||
## 🌐 插件生态
|
||||
@@ -176,7 +191,7 @@ dsh registry enable dsh-external/dsh-better-sidebar
|
||||
`ctx.betterSidebar` 服务向所有插件开放两个扩展点:**`registerTab`(注册侧边栏页面)** 与 **`registerFileViewer`(注册文件预览器)**。内置的 8 tab + 6 viewer 与第三方插件走同一套 API,能力完全对等。
|
||||
|
||||
```ts
|
||||
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
|
||||
import type {} from 'dsh-apes-sidebar' // 触发 ctx.betterSidebar 类型合并
|
||||
export const inject = ['betterSidebar']
|
||||
export function apply(ctx: Context) {
|
||||
ctx.effect(() => ctx.betterSidebar.registerTab({
|
||||
@@ -190,7 +205,7 @@ export function apply(ctx: Context) {
|
||||
}
|
||||
```
|
||||
|
||||
GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar) 下已有 **28+ 生态插件**(持续增长中):
|
||||
GitHub topic [`dsh-apes-sidebar`](https://github.com/topics/dsh-apes-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 />
|
||||
@@ -213,7 +228,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
|
||||
| [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 面板对称 |
|
||||
| [ztyhehe/dsh-apes-sidebar-svn](https://github.com/ztyhehe/dsh-apes-sidebar-svn) | <img alt="stars" src="https://img.shields.io/github/stars/ztyhehe/dsh-apes-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 笔记,任何工作区随时可读 |
|
||||
@@ -226,7 +241,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
|
||||
| [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 彩色)、多实例并行、进程树跨平台杀死 |
|
||||
| [dong-victor/dsh-apes-sidebar-starter](https://github.com/dong-victor/dsh-apes-sidebar-starter) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-apes-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`):侧边栏文件活动页——记录文件读取 / 新增 / 修改历史与统计,按文件夹平铺,点击用原生预览打开 |
|
||||
| [Hoemr/dsh-better-overleaf](https://github.com/Hoemr/dsh-better-overleaf) | <img alt="stars" src="https://img.shields.io/github/stars/Hoemr/dsh-better-overleaf?style=flat&color=4d6bfe" /> | Overleaf 标签页:直连 CDP 浏览器登录(支持第三方 Chromium)、项目切换、工作区下 overleaf/ 目录本地 git 镜像与双向同步 |
|
||||
|
||||
@@ -241,7 +256,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
|
||||
|---|---|---|
|
||||
| [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、流式输出、保存回写 |
|
||||
| [dong-victor/dsh-apes-sidebar-jupyter](https://github.com/dong-victor/dsh-apes-sidebar-jupyter) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-apes-sidebar-jupyter?style=flat&color=4d6bfe" /> | `.ipynb` 可运行 Notebook 视图:懒启动 Python kernel、流式输出、保存回写 |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -252,69 +267,116 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
|
||||
|
||||
| 插件 | ⭐ | 简介 |
|
||||
|---|---|---|
|
||||
| [eg-bole/dsh-better-sidebar-icons](https://github.com/eg-bole/dsh-better-sidebar-icons) | <img alt="stars" src="https://img.shields.io/github/stars/eg-bole/dsh-better-sidebar-icons?style=flat&color=4d6bfe" /> | VSCode 风格文件 / 文件夹图标主题:文件树与编辑器 Tab 换上熟悉的开发环境图标(vscode-icons 移植,纯 DOM 覆盖零侵入,安装 / 卸载零残留) |
|
||||
| [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 |
|
||||
| [eg-bole/dsh-apes-sidebar-icons](https://github.com/eg-bole/dsh-apes-sidebar-icons) | <img alt="stars" src="https://img.shields.io/github/stars/eg-bole/dsh-apes-sidebar-icons?style=flat&color=4d6bfe" /> | VSCode 风格文件 / 文件夹图标主题:文件树与编辑器 Tab 换上熟悉的开发环境图标(vscode-icons 移植,纯 DOM 覆盖零侵入,安装 / 卸载零残留) |
|
||||
| [dong-victor/dsh-apes-sidebar-terminal-plus](https://github.com/dong-victor/dsh-apes-sidebar-terminal-plus) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-apes-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" /> | 预览划选增强:侧边栏预览里划选文本 → 浮动「发送到会话」 |
|
||||
| [Hoemr/dsh-quicklook](https://github.com/Hoemr/dsh-quicklook) | <img alt="stars" src="https://img.shields.io/github/stars/Hoemr/dsh-quicklook?style=flat&color=4d6bfe" /> | QuickLook 式空格预览:活动文件标签页按 Space 全尺寸查看图片 / PDF / 文本,Space 或 Esc 关闭 |
|
||||
|
||||
</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-apes-sidebar` topic 即出现在 [topic 页](https://github.com/topics/dsh-apes-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 版本(v0.18.0 正式版):0.1.5-rc.2+" src="https://img.shields.io/badge/DSH-0.1.5--rc.2%2B-4d6bfe" /></a> · 完整发布历史见 [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)
|
||||
**支持的 DSH 版本**:<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.19.1 正式版):0.1.5-rc.1+(已在 0.1.5-rc.2 上验证)" src="https://img.shields.io/badge/DSH-0.1.5--rc.1%2B_%28verified_rc.2%29-4d6bfe" /></a> · 完整发布历史见 [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)
|
||||
|
||||
### v1.1.0(fork · DSH 2.0.10 / 0.1.5-rc.2 适配 + 精简 + 风格对齐)
|
||||
### v0.19.1
|
||||
|
||||
> 📌 **DSH 2.0.10 适配**:跟随 DSH 2.0.10 内发的 `@deepseek-ai/dsh-*@0.1.5-rc.2`,peer + devDeps 全量对齐。
|
||||
> 📌 **正式版**(npm `latest`,无 prerelease 后缀):钉版推进到 **DSH 0.1.5-rc.2**(npm `next`),**peer 下限仍是 `^0.1.5-rc.1`**——rc.2 的上游 delta 里没有任何触及本插件的面(零 `packages/api|host|session|agent` 变更,真实代码改动只有消息反馈弹窗、产物卡片 CSS 与 `CodeFileIcon` 的 SVG 数据拆分),因此 rc.1 用户无需升级 DSH 即可用本版。DSH 0.1.5-alpha.2 用户继续用 **v0.19.0-alpha.1**;0.1.2-rc.1 稳定线继续用 **v0.18.1**。
|
||||
|
||||
**🗑 精简 tab(去 `apes:tasks`)**
|
||||
- 🎯 **适配 DSH 0.1.5-rc.2**:devDependency 钉版、CI 挂载车道与 `SIDEBAR_SERVICE_VERSION` 同步到 rc.2;插件侧**零代码改动**(上游 delta 未触及本插件,逐文件核对见 [docs/plans/2026-09-10-dsh-0.1.5-rc.2-adaptation.md](docs/plans/2026-09-10-dsh-0.1.5-rc.2-adaptation.md)——300 个变更文件里绝大多数只是各包 `package.json` 的版本号单行)。
|
||||
- 🎨 **文件图标(#611)与内置 Tab 图标改为彩色**(#531 + #594 合并,实现按 rc.2 重做):
|
||||
- **文件/文件夹图标走 DSH 官方图形**:回退链末端是宿主 `ui-primitives` 的 `FileTypeIcon`(48 类官方全彩代码/配置图形 + markdown/图片/PDF/Office/视频/文件夹的类目色板),插件**不再自带扩展名表,也不再需要图标懒加载 chunk**——`#429` 那份 563 条彩色数据与 `lib/client-file-icons.js`(255 kB)连同 `fileIconTheme` 设置开关一并删除,彩色就是唯一形态(核心 bundle 因此只增 10 kB)。
|
||||
- **新增对外 `registerFileIcon` API**(能力 `'fileIcons'`):按扩展名(`exts`)、精确文件名(`names`)、目录名(`folderNames`)注册自己的图标,优先级降序、同级按注册序;**注意**:宿主分类器覆盖任意路径,所以注册 `exts: []` 的 catch-all 会接管所有未具体命中的行。
|
||||
- **内置 tab 图标彩色**:文件 / 文件变动 / 任务管理 / 侧边对话 / 终端 / 浏览器 六个类型与 diff 视图的 glyph 换成彩色版本(颜色全部来自 `--dsw-alias-*` 令牌,皮肤照旧全覆盖)。
|
||||
- **原生右侧栏的 tab 芯片也带图标**:宿主的 tab 定义没有 icon 字段,但 `sidebar.right.pane.tab.title` 槽就是芯片内容——插件在该槽渲染「图标 + 标题」(编辑器 tab 带路径时显示该文件的图标),glyph 为 `aria-hidden`,芯片可访问名不变。
|
||||
- 🛠 **CI 修复一:Windows lane 的真实超时**。`ci-windows` 在 2026-09-09/10 窗口内红了 8 次,其中 6 次是真起进程的用例撞上 vitest 默认 5000ms:`tests/agent-pty.spec.ts`(真起 PowerShell + ConPTY)与 `tests/install-powershell.spec.ts`(冷启 `powershell.exe` 实测 12.1s)现在各自声明 30s 预算,且 `vitest.config.ts` 的全局 `testTimeout` 从默认 5000ms 提到 **15s**(第一次真实 Windows 跑又在**第三个**文件上翻车:`tests/git.spec.ts:85` 耗时 9607ms——三个文件同一个根因,逐文件加超时是打地鼠);`waitForTranscript` 的内层轮询预算从 5000ms 降到 15s,**内层预算必须小于外层**(原先是同一个 5000ms,结构性必然超时)。`ci-windows` 的 `Test` 改跑 `pnpm test:windows`(`--maxWorkers=2`),在 2 核 runner 上不再让真起进程的 spec 互相抢占。
|
||||
- 🛠 **CI 修复二:挂载车道的 npm 安装**。`plugin-mount` 的 `npm install -g @deepseek-ai/dsh@<ver>` 曾 4 次失败(2 次 `ETARGET`、2 次 `JavaScript heap out of memory` exit 134):钉版自身的传递依赖是浮动 `^` 范围,上游**分阶段发布**预发布版时(rc.2 于 09-10 的 14:43–14:57 逐包上线)npm 会组出 rc.1/rc.2 混合 peer 图(3062 条 ERESOLVE)。现在钉一个**已完整发布**的 rc.2(修 ETARGET)并加 `NODE_OPTIONS=--max-old-space-size=4096`(修 OOM)。中途试过 `--legacy-peer-deps`,被真实 CI 否掉:它跳过的正是全局安装必须提供的 peer,`dsh-app-boot` 在 boot 时 require 的 `@deepseek-ai/cordis-plugin-group` 是 peer 而非 dependency,加了它 CLI 直接 `ERR_MODULE_NOT_FOUND`。
|
||||
|
||||
- 移除 `apes:tasks` —— 与 DSH 原生侧栏的官方会话树功能重叠,保留 **3 个** `apes:*` 信息层 tab:`apes:agents` / `apes:apps` / `apes:profile`
|
||||
- 同步清理死代码:`ApesTasksView` + `collectTreeJobs`/`orderJobs`/`isJobLive`/`jobDotState` import + apes i18n 死 key(tasks/status/duration)
|
||||
### v0.19.0
|
||||
|
||||
**🎨 风格对齐 DSW 设计令牌**
|
||||
> 📌 **正式版**(npm `latest`,无 prerelease 后缀):本版仅支持 **DSH 0.1.5-rc.1+**(peer 下限 `^0.1.5-rc.1`,CI 钉 `@deepseek-ai/dsh@0.1.5-rc.1`)。DSH 0.1.5-alpha.2 用户请继续用 **v0.19.0-alpha.1**(npm `alpha` 标签仍指向它);0.1.2-rc.1 稳定线继续用 **v0.18.1**。
|
||||
|
||||
- apes 视图内联样式的颜色全部改用 DSW alias 令牌(`--dsw-alias-bg-*` / `--dsw-alias-border-*` / `--dsw-alias-label-*` / `--dsw-alias-state-success-*` / `--dsw-alias-interactive-*`),与 DSH 原生侧栏视觉统一;`var(...)` 带字面 fallback 保证非 DSH 环境(单测 harness)仍可渲染
|
||||
- 字号 / 间距 / 圆角保持内联值(DSH 未对插件面板公开 `--ds-*` 几何令牌,其自身 vendor bundle 亦内联)
|
||||
**✨ 新功能**
|
||||
|
||||
**🔧 0.1.5-rc.2 适配修复**
|
||||
- 📝 **新建标签页列表恢复可选说明**(#613):DSH 0.1.5-rc.1 让 `SidebarRightGuideEntry.description` 回归(可选),插件随之恢复 `TabDescriptor.description`,六个内置类型各写回一条说明(原生指南的 文件 / 文件变动 / 任务管理 / 侧边对话 / 终端 / 浏览器 六行都带上它),`guideDesc*` 词条回到 20 份词典。**宿主的原生指南只在列出的条目 ≤ 4 条时渲染说明**(更长的列表是整列丢弃,不是截断),而插件默认贡献 6 个 guide 条目——因此默认组合下说明不渲染,只有在插件设置页关掉足够多的 tab 类型、把 guide 压到 ≤ 4 条时才会出现。未声明说明的条目仍是「图标 + 标题」单行(插件不补通用兜底句)。
|
||||
|
||||
- primitives npm 发布缺陷(15 个运行时依赖被误列为 devDeps)→ 补进本地 `devDependencies` 供 vitest 内联解析
|
||||
- sidechat seed 校验契约升级:`dsh-session@0.1.5` 的 `assertAssistantSettlementShape` 要求 assistant 消息的 `data` 顶层带 `stream: Array` → 测试 seed helper 对齐
|
||||
- 完整 gate:typecheck ✓ / build ✓ / test 119 files · 1249 passed ✓
|
||||
**🐛 修复**
|
||||
|
||||
---
|
||||
- 无。rc.1 相对 alpha.2 的 delta 很小(373 个变更文件,绝大多数是上游各包版本号、原生右侧栏预览 UI 打磨与测试快照),除上述说明字段回归(并附带上游 `files` 类型改用自己的彩色文件夹图标作指南字形)外,没有需要插件适配的变更。
|
||||
|
||||
### v1.0.0(fork · apes 信息层)
|
||||
**🧰 CI 与内部**
|
||||
|
||||
> 📌 **fork 独立版本线**:自 upstream `dsh-better-sidebar@v0.18.0`(commit `9e1a034`)fork,新增 `apes:*` 信息层 tab。upstream 历史版本见下方 v0.18.0 段。
|
||||
- 基线推进到 **DSH 0.1.5-rc.1+**(#613):peer 下限、devDependency 钉版、CI 挂载车道与 `dsh.plugin.json` 的 `engines.dsh` 同步(rc.1 在 npm 上同时是 `latest` 与 `next`)。
|
||||
- 明确未变、不必再核:全局主面板模型(`main` 槽 / `sidebar.panellist` / `ctx.layout` / 根级 `rightbar` + `rightbar.session`,插件仍不接入)、文件地址语法(`packages/util/workspace-path` 只动了版本号)、原生 tab 体宿主契约(`.paneBody` 仍是有确定高度的块级滚动容器)、core / agent / session / subagent 宿主 API 与 `ui-primitives` 导出面(仅 CodeBlock 渲染变化)。
|
||||
- `@deepseek-ai/dsh-client-ui-primitives@0.1.5-rc.1` 仍不声明 `dependencies` 而 bundle 仍裸 import `anser` / `shiki` / `@shikijs/langs/*` / `mdast-util-*` / `micromark-*` / `katex`——上一版提升进 devDependencies 的那组包因此保留,不得回退。
|
||||
- 真机验证(DSH 0.1.5-rc.1 + 插件 0.19.0):门禁 `typecheck` / `lint` / `check:consumer-types` 全绿,单测 **124 files · 1296 passed · 9 skipped**,`pnpm peers check` 干净;挂载冒烟对真实 rc.1 **7 passed**(含 tab 体填充断言,以及新增的「指南 ≤4 条时说明才渲染」断言);本地 3080 实测:guide 六行仍是「图标 + 标题」(6 > 4,说明按上游规则不渲染;上游把胶囊 `min-height` 从 48px 调到 56px),文件树点击 `AGENTS.md` 落到插件编辑器(CodeMirror 就绪),sidechat 输入框贴底(宿主盒 962px == 面板体 962px,composer 底边距 8px),底部工作台与中心列左右边完全重合,`pageerror` 0(控制台仅有第三方 `dsh-tauri-worktree` 的 `/api/dsh-worktree/attach` 500,与本次改动无关)。
|
||||
|
||||
**🪧 新增 4 个 `apes:*` 信息层 tab**(独立模块 `src/client/apes/`,与 upstream builtin tab 解耦)
|
||||
### v0.19.0-alpha.1
|
||||
|
||||
- **`apes:tasks` 任务**:接 `ctx.sessions.list` 的 `jobsBySession` + running sessions —— live 任务列表(label / 运行中·完成·失败 / kind / 耗时 + owner 归属)
|
||||
- **`apes:agents` 智能体**:接 `subagentsByParent` 目录(降级 `byId`)—— subagent 拓扑(main → 直接 children 的 activity:running / inactive)
|
||||
- **`apes:apps` 应用**:展示当前工作区 cwd(`scope.cwd` + `byId[current].cwd`;无 DSH host feed,保留说明占位)
|
||||
- **`apes:profile` 画像**:接 `byId[current]` —— 当前会话画像(title / cwd / running / agent 状态)
|
||||
> 🧪 **alpha 通道**(npm dist-tag `alpha`,安装 `dsh-apes-sidebar@alpha`):本版仅支持 **DSH 0.1.5-alpha.2+**(peer 下限 `^0.1.5-alpha.2`,CI 钉 `@deepseek-ai/dsh@0.1.5-alpha.2`)。0.1.5-alpha.1 请继续用 **v0.19.0-alpha.0**;0.1.2-rc.1 稳定线用 **v0.18.1**(npm `latest`)。
|
||||
|
||||
**🔌 数据接入**
|
||||
**✨ 新功能**
|
||||
|
||||
- 数据源 `ctx.sessions.list.getSnapshot()` 经 `useSyncExternalStore` 响应式订阅(snapshot 含 `current` / `byId` / `subagentsByParent` / `jobsBySession`)
|
||||
- 复用 upstream pure helper(无 UI / Node 依赖):`subagent-lineage.ts`(`rootAncestor` / `isSideThreadSummary`)+ `subagent-jobs.ts`(`collectTreeJobs` / `orderJobs` / `isJobLive` / `jobDotState`)
|
||||
- UI 独立内联样式,不耦合 builtin CSS module / `StateDot`(保持 apes 模块隔离,cherry-pick upstream 时只碰 `index.tsx` 一处)
|
||||
- 🪟 **每个 tab 体都填满面板**(#609):原生右侧栏的 tab 体宿主是「有确定高度的块级滚动容器」而非 flex 容器,此前插件各 tab 的根只写 `flex: 1`,在块容器里塌成内容高度——侧边对话的输入框因此贴不到面板底(转录一长就被推出可视区)。现在 native 适配层统一给每个 tab 体包一层 `height: 100%` 的列 flex 宿主,插件全部 tab(含第三方 `registerTab` 注册的 descriptor)恢复与底部工作台一致的填满语义。
|
||||
|
||||
**🌏 i18n**:4 个视图全部走 `t(key)`,`zh` 默认 + `en` fallback,状态 / 耗时 / 空态均有双语词条
|
||||
**🐛 修复**
|
||||
|
||||
- 🧭 **跟随 DSH 0.1.5-alpha.2 的文件地址语法**:`fileAddressFor` 一律产出 session 作用域地址、绝对路径保留前导 `/`;`parseFileAddress` 改为前缀解析并忽略 `?`/`#` 后缀。
|
||||
- 🪟 **底部工作台的中心列定位跟随 alpha.2 全局面板改动**:`conversation` 槽改为根级 `main` keyed 槽下的 `main.conversation`,定位器改认新 key 并跳过 `display: contents` 槽宿主(alpha.1 的旧 key 仍兼容)。
|
||||
|
||||
**🧰 CI 与内部**
|
||||
|
||||
- 基线推进到 DSH 0.1.5-alpha.2(#609):peer 下限、22 个 devDependency 钉版、CI 挂载车道与 `dsh.plugin.json` engines 同步;`pnpm peers check` 干净(按 §3-9 补提 `dsh-session-persistence` 传递 peer)。
|
||||
- **移除 `TabDescriptor.description`**:alpha.2 的原生指南条目不再渲染第二行(改为「图标+标题」胶囊),该字段与 6 个 `guideDesc*` 词条(20 份词典)一并下线;新建标签页的默认页改由注册表选(恰好 1 个指南条目则直接打开它)。
|
||||
|
||||
### v0.19.0-alpha.0
|
||||
|
||||
> 🧪 **alpha 通道**(npm dist-tag `alpha`,安装 `dsh-apes-sidebar@alpha`):本版仅支持 **DSH 0.1.5-alpha.1+**(peer 下限 `^0.1.5-alpha.1`,CI 钉 `@deepseek-ai/dsh@0.1.5-alpha.1`)。0.1.2-rc.1 稳定线请继续用 **v0.18.1**(npm `latest`)。
|
||||
|
||||
**✨ 新功能**
|
||||
|
||||
- 🖥️ **接入 DSH 原生右侧栏**(#604):右列改为 DSH 自己的右侧栏——插件的 7 个 tab 类型全部注册成原生 tab 类型 + 原生 tab 体;聊天里的文件打开统一走 `ctx.sidebarRight.openResource(dsh-resource://file/…)`;`editor` 类型以 `extension` 优先级认领文件资源(压过内置文本预览)并接管内置「文件」页 kind(注销即复位);跨会话打开在目标会话未上屏时排队重放。
|
||||
- 🧩 **退役插件自绘右侧面板与自由窗口**(#605):右列归 DSH 后,插件只保留底部工作台(单分栏树、随会话持久化),开合按钮注册进 DSH 会话头 utilities 槽;浮窗 API(`floats` / `floatTab` / `dockFloat` / `raiseFloat` / 右键「移动到自由窗口」)、`features` 里的 `'floatWindows'`,以及 `openByDefault` / `defaultWidthPercent` / `changesDiffFloat` 三个设置项一并删除(旧持久化文档里的 `floats` 字段被忽略,不影响加载)。
|
||||
- 🔗 **适配 DSH 0.1.5 宿主契约**(#603):`assistant/chunk` 事件删除 → 实时增量改由 `agent/assistant-stream` 帧折叠(侧边对话转录的 `live` 字段);`sessionPersistence.inspect` 删除 → 冷会话读取改走 `open(id,'read')`;会话头 `version` 用 `SESSION_FORMAT_VERSION`。
|
||||
|
||||
**🐛 修复**
|
||||
|
||||
- 侧边对话转录 / `jobs.output` 回放 / fork 继承等 8 处会话事件读取跟随 0.1.5 契约;自定义种子补齐 fork 标记对,避免继承父会话未领取的 inbox 输入。
|
||||
|
||||
**🧰 CI 与内部**
|
||||
|
||||
- 真机挂载冒烟门禁钉 0.1.5-alpha.1,e2e 增加「展开原生栏 → 经引导页逐个打开插件 tab 类型」的巡检;typecheck / lint / 单测 / 挂载车道全绿。
|
||||
|
||||
### v0.19.0(未发布)
|
||||
|
||||
- 🎨 **可选彩色图标主题**:设置页「文件 → 文件图标」可在**内置单色**与**彩色品牌图标**之间切换——彩色主题含 563 条规则(218 扩展名 + 197 精确文件名 + 148 目录名,如 `.tsx → React`、`package.json → npm`、`node_modules` 着色),数据在**懒加载 chunk**(`lib/client-file-icons.js`)里,只有选中时才下载,默认关闭时启动零开销。数据源自 [#429](https://github.com/omdsh-dev/DSH-better-sidebar/pull/429)(@fenter)。
|
||||
- 🎨 **文件图标多样化 + 对外图标注册 API([file-icons.tsx](./src/client/file-icons.tsx))**:文件树与编辑器文件 tab 不再是单一 `VscFile`——markdown / 图片媒体 / PDF / JSON / 40+ 代码扩展 / 配置 / 数据库 / lock / 压缩包各有专属 glyph(VSCodicons 单色 `currentColor`,遵循皮肤契约),未知扩展回退通用图标。`ctx.betterSidebar` 新增 `registerFileIcon`(`features` 含 `'fileIcons'`):按扩展名 / 精确文件名(`names`)/ 目录名(`folderNames`)注册自定义图标(彩色 ReactNode 亦可,颜色责任在注册方),`exts: []` 为全局默认(只兜内置 glyph 没认领的扩展,不吞掉内置多样性),保留扩展名 `'folder'` / `'folder-open'` 可换目录行图标;`fileIcon` / `folderIcon` 为权威解析器(完整回退链 + 逐工厂崩溃隔离),注册/注销即时生效。接入示例见[外部插件指南 §7](./docs/external-plugin-guide.md)。
|
||||
|
||||
### v0.18.1
|
||||
|
||||
> 📌 **正式版**(npm `latest`):DSH 基线不变(**0.1.2-rc.1+**,peer 下限 `^0.1.2-rc.1`)——本版是 v0.18.0 之后的增量发布:变更面板预览能力增强、文件树可写,以及五项修复。
|
||||
|
||||
**✨ 新功能**
|
||||
|
||||
- 📄 **变更面板操作预览增强**(#499):`.md` 阅读模式(含 mermaid 渲染)、`.html` 与 `.pdf` 内嵌渲染预览;diff 语法高亮扩展到 mjs/cjs/mts/cts、CSS/SCSS/Less、HTML/XML/SVG/Vue、GraphQL、JSONC/JSON5;新增**密钥脱敏**层(预览默认开启,面板头部可切换)
|
||||
- 🗂️ **文件树重命名 / 删除**(#550):行内重命名 + 确认式删除,右键菜单减重与子菜单视口钳制
|
||||
- 🧩 **插件目录名与 shell 预设文案词典化**(#535):跟随宿主语言
|
||||
|
||||
**🐛 修复**
|
||||
|
||||
- 🔀 **git diff 折叠上下文真实展开**(#576,修复 #577):折叠行此前显示「n 行…点击展开」却点不动(`-U3` 裁剪使 gap 段没有行文本);现按需经 `git.show` 拉取两侧完整内容切片填充,带加载 / 失败降级三态与请求去重;顺带修复该路由的 `rev:path` 寻址(此前恒返空)
|
||||
- 💬 **侧边对话种子不再继承父会话未领取的 inbox 消息**(#562):补 fork 标记对,消除「上下文很长时侧边对话先把之前的 User 消息发出去」的幽灵消息
|
||||
- 🖼️ **Markdown 分栏渲染器内的本地图片**(#569):改写为可访问 URL,不再 404
|
||||
|
||||
**🧰 CI 与内部**
|
||||
|
||||
- ESLint flat config 接入 CI 与 Makefile(#536)、Makefile 命令面规范化(#526)、e2e 脚本加固(#527)、共享组件测试工具收敛样板(#524)
|
||||
- 重构:Sidebar.tsx 按关注点拆分(#542)、四处轮询习语收敛到 `use-polling`(#541)、删除 rc.7 宿主的 `__DSH_MODULES__` 回退路径(#540)、One Dark/Light 语法色板单源化(#534)、重复实现收敛与死代码清理(#525)
|
||||
|
||||
### v0.18.0
|
||||
|
||||
> 📌 **正式版**(npm `latest`):本版仅支持 **DSH 0.1.2-rc.1+**(peer 下限 `^0.1.2-rc.1`);不再支持 0.1.0-rc.8 ~ 0.1.1-rc.2——DSH stable 用户请固定安装 `dsh-better-sidebar@0.17.1`,停留在 0.1.2-alpha.x 的宿主继续用 `dsh-better-sidebar@alpha`(v0.18.0-alpha.0)。
|
||||
|
||||
自 v0.17.1 以来的全部更改(v0.18.1-alpha.0 / v0.19.0-alpha.0 两个中间版本号未发布,内容一并并入本版):
|
||||
|
||||
**🔗 宿主线毕业正式版**
|
||||
|
||||
- **进入 DSH 0.1.2 线并毕业**:v0.18.0-alpha.0 删除 0.1.1-rc.x 兼容层、修复 alpha.1+ 侧边对话转录空白(#472);基线经 alpha.3(#497)、alpha.5(#516,`Session.events` → `snapshotEvents()`)逐步上行;聊天文件打开漏斗迁移 `remote.session.openWorkspacePath`(#494)、「Show in folder」reveal 滚动限界(#453)。本版钉版基线 **DSH 0.1.2-rc.1**(相对 alpha.5 零源码变化、纯版本升级;`dsh-client-locale` 恢复发版,全线对齐 rc.1;CI 真机挂载冒烟 14/14)
|
||||
> 📌 **正式版**(npm `latest`):本版仅支持 **DSH 0.1.2-rc.1+**(peer 下限 `^0.1.2-rc.1`);不再支持 0.1.0-rc.8 ~ 0.1.1-rc.2——DSH stable 用户请固定安装 `dsh-apes-sidebar@0.17.1`,停留在 0.1.2-alpha.x 的宿主继续用 `dsh-apes-sidebar@alpha`(v0.18.0-alpha.0)。
|
||||
|
||||
**✨ 新功能**
|
||||
|
||||
@@ -339,23 +401,26 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
|
||||
|
||||
**🌐 生态收录**
|
||||
|
||||
- 新收录 10+ 插件:dsh-better-sidebar-icons(#441)、dsh-sidenote(#451,原 dsh-sidechat #470)、dsh-github-workbench(#410)、dsh-bilingual-reader(#379)、dsh-server-deck(#413)、dsh-md-export(#405)、dsh-code-nav(#404)、dsh-suhuang-scroll(#392)、dsh-better-overleaf(#370)等(均含 18+ 语言 i18n 补齐)
|
||||
- 新收录 10+ 插件:dsh-apes-sidebar-icons(#441)、dsh-sidenote(#451,原 dsh-sidechat #470)、dsh-github-workbench(#410)、dsh-bilingual-reader(#379)、dsh-server-deck(#413)、dsh-md-export(#405)、dsh-code-nav(#404)、dsh-suhuang-scroll(#392)、dsh-better-overleaf(#370)等(均含 18+ 语言 i18n 补齐)
|
||||
|
||||
### v0.19.0-alpha.0
|
||||
<details>
|
||||
<summary><b>历史版本(v0.12.0 – v0.15.2)</b></summary>
|
||||
|
||||
> 🧪 **alpha 通道**:本版仅支持 **DSH 0.1.2-alpha.x**(peer 下限 `^0.1.2-alpha.5`,npm dist-tag `alpha`,安装 `dsh-better-sidebar@alpha`)。该版本号未单独发布,内容已并入 **v0.18.0** 正式版。
|
||||
### v0.19.0-alpha.0(0.1.2-alpha.5 适配,未发布,内容并入 v0.18.0)
|
||||
|
||||
> 🧪 **alpha 通道**:本版仅支持 **DSH 0.1.2-alpha.x**(peer 下限 `^0.1.2-alpha.5`,npm dist-tag `alpha`)。该版本号当时未单独发布,内容已并入 **v0.18.0** 正式版;v0.19.0-alpha.0 这个号后来被 0.1.5 适配线复用(见上)。
|
||||
|
||||
- 🔗 **适配 DSH 0.1.2-alpha.5(npm 已发布,`alpha` dist-tag)**:CI 挂载门禁钉版、`dsh.plugin.json` engines 下限与 `@deepseek-ai/*` peer / devDependencies 基线升至 0.1.2-alpha.5(真机挂载冒烟 14/14 验证)。`dsh-client-locale` 上游停在 0.1.2-alpha.3 未发新版,其 peer 下限 / devDep 保持并天然兼容 alpha.5 运行时(`pnpm peers check` 零失配,无需新增提升传递 peer)。代码适配了 alpha.4 的兼容性标记改动——`Session.events` 属性移除,迁移到按需读 API `snapshotEvents()`(sidechat 转录 live 读、fork 继承、`jobs.output` 回放、subagent 活跃度共 8 处),新建线程 meta 中宿主已删的 `seedLength` 一并移除;alpha.4 其余变化(双向 `send_message`、自定义模型发现复用 Profile 请求头、`SessionSeq`/`SessionLogOffset` 强类型)与 alpha.5(升级启动修复)经核实不触及本插件其余表面。
|
||||
|
||||
### v0.18.1-alpha.0
|
||||
|
||||
> 🧪 **alpha 通道**:本版仅支持 **DSH 0.1.2-alpha.x**(peer 下限 `^0.1.2-alpha.3`,npm dist-tag `alpha`,安装 `dsh-better-sidebar@alpha`)。该版本号未单独发布,内容已并入 **v0.18.0** 正式版。
|
||||
> 🧪 **alpha 通道**:本版仅支持 **DSH 0.1.2-alpha.x**(peer 下限 `^0.1.2-alpha.3`,npm dist-tag `alpha`,安装 `dsh-apes-sidebar@alpha`)。该版本号未单独发布,内容已并入 **v0.18.0** 正式版。
|
||||
|
||||
- 🔗 **适配 DSH 0.1.2-alpha.3(npm 已发布,`alpha` dist-tag)**:CI 挂载门禁钉版、`dsh.plugin.json` engines 下限与 `@deepseek-ai/*` peer / devDependencies 基线升至 0.1.2-alpha.3(真机挂载冒烟 14/14 验证)。逐点核查了 alpha.2 → alpha.3 全部 117 个 commit:插件依赖的宿主契约(token 鉴权、斜杠 RPC、`MarkdownText` labels、`sidechat.events` 所依赖的事件流与持久化 API、`SettingsNamespaceInput`、`SUBAGENT_DESCRIPTOR_VERSION`(仍为 3)、`dsh-client-store`、profile 加载器、node-pty 钉版)均无变化,无需代码适配;alpha.3 的破坏性改动(`BeginSubmissionInput.mode` 必填、subagent 错误码改名 `attachment-invalid`、SQLite 持久化后端移除、投影 change feed 收紧为 identity-gated)经核实均不触及本插件。
|
||||
|
||||
### v0.18.0-alpha.0
|
||||
|
||||
> 🧪 **alpha 通道**:本版仅支持 **DSH 0.1.2-alpha.x**(peer 下限 `^0.1.2-alpha.2`,npm dist-tag `alpha`,安装 `dsh-better-sidebar@alpha`);不再支持 0.1.0-rc.8 ~ 0.1.1-rc.2——stable DSH 用户请用 v0.17.1(npm `latest`)。
|
||||
> 🧪 **alpha 通道**:本版仅支持 **DSH 0.1.2-alpha.x**(peer 下限 `^0.1.2-alpha.2`,npm dist-tag `alpha`,安装 `dsh-apes-sidebar@alpha`);不再支持 0.1.0-rc.8 ~ 0.1.1-rc.2——stable DSH 用户请用 v0.17.1(npm `latest`)。
|
||||
|
||||
- 🔗 **适配 DSH 0.1.2-alpha.2(npm 已发布,`alpha` dist-tag)**:CI 挂载门禁钉版与 `@deepseek-ai/*` devDependencies 基线升至 0.1.2-alpha.2(真机挂载冒烟 14/14 验证)。适配点:`dsh-settings` 移除运行时导出 `settingsNamespace`(命名空间改为编译期校验,宿主侧直接传常量);`dsh-subagent` 描述符版本 2→3(由宿主包盖章,测试断言跟随 `SUBAGENT_DESCRIPTOR_VERSION` 常量);`SessionEvent.ignorable` 恢复与 Remote 网关 `RemoteError` 封装经核实对本插件无破坏。
|
||||
- 🐛 **修复 DSH 0.1.2-alpha.1+ 上侧边对话转录空白**:转录轮询此前仍走 alpha.1 已移除的 `ctx.connection.api`(错误被静默吞掉,tab 永远渲染空转录),现改由插件自有 `sidechat.events` 路由供给(活线程读内存事件日志、冷线程读会话持久化,`afterSeq` 增量拉取,[sidechat-routes.ts](./src/sidechat-routes.ts))。
|
||||
@@ -393,7 +458,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
|
||||
**🐛 修复**
|
||||
|
||||
- 🛒 **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'` 的类型合并方式不变);未使用该导入的插件无影响
|
||||
- 🔤 **类型基底迁移到 `@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-apes-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,任意皮肤)
|
||||
@@ -413,9 +478,6 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
|
||||
- 🔧 **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 以来的全部更改:
|
||||
@@ -534,7 +596,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
|
||||
推荐添加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="微信群二维码" src="https://github.com/user-attachments/assets/cbf211c6-e5b8-49c3-a412-7210c0b33d73" />
|
||||
<img width="220" alt="QQ群二维码" src="https://github.com/user-attachments/assets/9be34629-26ef-4537-aad4-1393c147f81c" />
|
||||
</div>
|
||||
|
||||
@@ -552,21 +614,22 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
|
||||
|
||||
## 🔌 服务化扩展
|
||||
|
||||
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 8 tab + 6 viewer 亦通过同一服务注册)。v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、tab 角标、生命周期回调、定向打开、插件自有设置等)。
|
||||
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 8 tab + 6 viewer 亦通过同一服务注册)。v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、tab 角标、生命周期回调、定向打开、插件自有设置等)。v0.19.0 起新增文件图标注册:`registerFileIcon` 按扩展名(或保留的 `'folder'` / `'folder-open'` 目录扩展名、`exts: []` 全局默认)替换文件树与文件 tab 的图标,彩色 ReactNode 亦可——内置消费、注册即生效,无需自己接线。
|
||||
|
||||
完整接入文档(全字段、匹配算法、HMR 陷阱、声明式设置、版本探测、自由窗口与皮肤契约):**[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)**;仓库开发规则(硬约束 / CI / 发版)见 [`AGENTS.md`](./AGENTS.md)。
|
||||
完整接入文档(全字段、匹配算法、HMR 陷阱、声明式设置、版本探测、原生栏承载面与皮肤契约):**[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)**;仓库开发规则(硬约束 / CI / 发版)见 [`AGENTS.md`](./AGENTS.md)。
|
||||
|
||||
### ➕ 添加插件(推荐插件目录)
|
||||
|
||||
设置页「侧边卡片」两个网格末尾的**虚线卡片**分别打开 Tab / 预览插件弹窗:声明扩展点、「**在 GitHub 上浏览更多插件**」按钮([GitHub topic `dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar))、推荐插件目录(名字 / 仓库 / 简介 / 安装脚本),每个条目「**跳转**」直达仓库、「**复制**」把安装命令写入剪贴板。
|
||||
设置页「侧边卡片」两个网格末尾的**虚线卡片**分别打开 Tab / 预览插件弹窗:声明扩展点、「**在 GitHub 上浏览更多插件**」按钮([GitHub topic `dsh-apes-sidebar`](https://github.com/topics/dsh-apes-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` 守护。
|
||||
**收录新插件**:向 [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts)(Tab 注册)或 [`src/client/plugins-viewers.ts`](./src/client/plugins-viewers.ts)(文件预览注册)追加一条 `PluginEntry`,并把仓库打上 `dsh-apes-sidebar` topic;数据完整性由 `tests/plugin-list.spec.ts` 守护。
|
||||
|
||||
## 🛠️ 开发与构建
|
||||
|
||||
```sh
|
||||
pnpm install # @deepseek-ai/* devDependencies 已发布(基线 0.1.2-rc.1,next dist-tag),直接解析、无需令牌
|
||||
pnpm typecheck # tsc --noEmit
|
||||
pnpm lint # eslint .(flat config:js + typescript-eslint + react-hooks recommended)
|
||||
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
|
||||
pnpm test # vitest(含 manifest 一致性守卫,需先 build)
|
||||
pnpm watch # tsdown --watch
|
||||
@@ -606,7 +669,7 @@ Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元
|
||||
## 🤝 参与贡献
|
||||
|
||||
- **代码改动走 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
|
||||
- **收录生态插件**:给仓库打 `dsh-apes-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`(或 `make check` 一键聚合;CI 另有 npm 打包 → 真实挂载 → 无头渲染门禁 `pnpm test:mount`,及聚合双挂载回归 `pnpm test:mount:aggregate`)
|
||||
- 仓库工作规范见 [`AGENTS.md`](./AGENTS.md)(含仓库硬约束与 CI 说明)
|
||||
|
||||
@@ -637,5 +700,5 @@ Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元
|
||||
---
|
||||
|
||||
<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>
|
||||
<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-apes-sidebar">topic dsh-apes-sidebar</a> 发现更多生态插件</sub>
|
||||
</div>
|
||||
|
||||
+136
-44
@@ -1,16 +1,19 @@
|
||||
# dsh-better-sidebar
|
||||
# dsh-apes-sidebar
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Built on DSH's native sidebar API** (since v0.19.0, DSH `0.1.5-rc.1+`): the right column *is* DSH's own sidebar — every plugin tab type and tab body registers and opens through `ctx.sidebarRightTabs` / `ctx.sidebarRight`, every file open from the chat goes through `ctx.sidebarRight.openResource('dsh-resource://file/…')`, and the plugin **no longer draws a right panel of its own** (the old free-window capability is gone with it). The self-drawn bottom workbench and the `ctx.betterSidebar` service other plugins register against are unchanged — see the [plugin integration guide](docs/external-plugin-guide.md).
|
||||
|
||||
<!-- 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://www.npmjs.com/package/dsh-apes-sidebar"><img alt="npm version" src="https://img.shields.io/npm/v/dsh-apes-sidebar" /></a>
|
||||
<a href="https://www.npmjs.com/package/dsh-apes-sidebar"><img alt="npm downloads" src="https://img.shields.io/npm/dm/dsh-apes-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 (v0.18.0): 0.1.2-rc.1+" src="https://img.shields.io/badge/DSH-0.1.2--rc.1%2B-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 />
|
||||
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.19.1): 0.1.5-rc.1+ (verified on rc.2)" src="https://img.shields.io/badge/DSH-0.1.5--rc.1%2B_%28verified_rc.2%29-4d6bfe" /></a>
|
||||
<a href="https://github.com/topics/dsh-apes-sidebar"><img alt="Plugin ecosystem: GitHub topic dsh-apes-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="Changes" src="https://img.shields.io/badge/-Changes-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>.
|
||||
@@ -22,7 +25,7 @@
|
||||
|
||||
<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" />
|
||||
<img alt="dsh-apes-sidebar workbench" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
|
||||
</div>
|
||||
|
||||
## 📑 Contents
|
||||
@@ -44,11 +47,10 @@
|
||||
- **🌐 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
|
||||
- **🌿 Changes**: one tab, two lenses — **Git** (real diff / history / stage·commit·revert / worktree & child-repo selection) and **This Session** (live tracking of every file the model reads / writes / edits, grouped by file with kind filters); a unified diff renderer (mod pairing + intra-line character highlights + syntax coloring + context folding), a draggable bottom preview pane, and one-click expansion into a dedicated diff tab
|
||||
- **🌿 Changes**: one tab, two lenses — **Git** (real diff / history / stage·commit·revert / worktree & child-repo selection) and **This Session** (live tracking of every file the model reads / writes / edits, grouped by file with kind filters); a unified diff renderer (mod pairing + intra-line character highlights + syntax coloring incl. mjs/cjs/mts/cts, CSS/SCSS/Less, HTML/XML/SVG/Vue, GraphQL, JSONC/JSON5 + context folding), a draggable bottom preview pane, and one-click expansion into a dedicated diff tab; `.md` ops (read / write / edit) offer a **reading-mode** toggle in the preview header — the shared MarkdownText renders GFM tables / task lists / strikethrough / footnotes / math, with local images rewritten through the /sidebar/file media route; documents with ```mermaid fences render through the editor's lazy mermaid renderer (click-to-zoom / pan diagrams); **secret redaction** — credential-shaped paths mask whole files and ordinary files mask secret-shaped values (api_key: / Bearer / sk- / AKIA / ghp_ / PEM …, field names kept), on by default with a one-click preview-pane toggle (persisted in localStorage), display-only — session data untouched. Known edge: an unquoted mermaid label containing a redacted secret breaks the diagram (falls back to source); workaround: quote the label. `.html` ops (read / write / edit) gain a **Render** toggle in the preview head — the editor's `/sidebar/html` route iframe, with relative assets resolving inside the route and segmented reads rendering the full document; always sandboxed (opaque origin + CSP header, no escape hatch). `.pdf` ops (read / write / edit) gain the same **Render** toggle — the editor's PDF preview reused verbatim (media-route bytes + explicit Blob, the browser's native viewer inline, with a download fallback)
|
||||
- **🧩 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
|
||||
- **🖥️ Native right sidebar + plugin bottom workbench**: the right column belongs to DSH 0.1.5's own sidebar — the plugin registers every tab type as a native tab (file opens go through `dsh-resource://file/**`, and the built-in Files page / file tree is taken over), keeping only its own bottom workbench (split panes / terminals / per-session persistence) whose toggle sits in the session header
|
||||
- **📌 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
|
||||
@@ -62,14 +64,14 @@
|
||||
**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 (v0.18.0): 0.1.2-rc.1+" src="https://img.shields.io/badge/DSH-0.1.2--rc.1%2B-4d6bfe" /></a>
|
||||
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.19.1): 0.1.5-rc.1+ (verified on rc.2)" src="https://img.shields.io/badge/DSH-0.1.5--rc.1%2B_%28verified_rc.2%29-4d6bfe" /></a>
|
||||
|
||||
> 📌 **Stable release**: starting with `v0.18.0` the plugin targets DSH **0.1.2-rc.1+** (npm dist-tag `latest`) and drops 0.1.0-rc.8 ~ 0.1.1-rc.2 — stable-DSH (≤ 0.1.1-rc.2) users should stay pinned to `dsh-better-sidebar@0.17.1` (`@latest` now points at v0.18.0); hosts still on 0.1.2-alpha.x should upgrade DSH first, or keep `dsh-better-sidebar@alpha` (v0.18.0-alpha.0).
|
||||
> 📌 **Stable release**: starting with `v0.19.0` the plugin targets DSH **0.1.5-rc.1+** (npm dist-tag `latest`; `v0.19.1` is verified end-to-end against **0.1.5-rc.2**, so rc.1 hosts need no DSH upgrade — the peer floor stays `^0.1.5-rc.1`). Hosts still on DSH 0.1.5-alpha.2 should stay pinned to `dsh-apes-sidebar@0.19.0-alpha.1`; the 0.1.2-rc.1 stable line keeps using `dsh-apes-sidebar@0.18.x`; DSH ≤ 0.1.1-rc.2 should use `dsh-apes-sidebar@0.17.1`.
|
||||
|
||||
```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)
|
||||
dsh plugin --profile web add dsh-apes-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
|
||||
dsh plugin --profile web add dsh-apes-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).
|
||||
@@ -77,10 +79,10 @@ Then **hard-refresh the browser** (Cmd/Ctrl+Shift+R) to see the sidebar (DSH hot
|
||||
**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)
|
||||
Install the dsh-apes-sidebar plugin (a sidebar workbench for DSH):
|
||||
1. Run: dsh plugin --profile web add dsh-apes-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
|
||||
3. Run the add command again: dsh plugin --profile web add dsh-apes-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
|
||||
```
|
||||
@@ -91,7 +93,7 @@ If anything fails, check the troubleshooting table in the README at https://gith
|
||||
<summary><b>Updating</b></summary>
|
||||
|
||||
```sh
|
||||
dsh plugin --profile web add dsh-better-sidebar@latest
|
||||
dsh plugin --profile web add dsh-apes-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.
|
||||
@@ -106,10 +108,10 @@ or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.16.1"`) and
|
||||
| `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`. |
|
||||
| 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-apes-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`. |
|
||||
| `dsh: command not found` | Install DSH first, or run `npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-apes-sidebar@latest`. |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -121,11 +123,11 @@ To debug local changes or track the dev branch, point the dependency at a local
|
||||
```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>"
|
||||
2. In ~/.dsh/profiles/web/package.json dependencies write "dsh-apes-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'
|
||||
name: 'dsh-apes-sidebar'
|
||||
config:
|
||||
shell: /bin/zsh
|
||||
shellArgs:
|
||||
@@ -135,7 +137,7 @@ To debug local changes or track the dev branch, point the dependency at a local
|
||||
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`.
|
||||
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-apes-sidebar": "^0.16.1"` and re-run `pnpm install`.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -149,7 +151,7 @@ git clone https://github.com/omdsh-dev/DSH-better-sidebar.git && cd DSH-better-s
|
||||
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
|
||||
dsh registry enable dsh-external/dsh-apes-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.
|
||||
@@ -166,7 +168,7 @@ Update: `git pull && pnpm install && pnpm build` → `node scripts/package-regis
|
||||
| **🖥️ 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> | **🌿 Changes: Git lens + This-Session lens**<br/><sub>Two lenses on "what changed?": the **Git lens** keeps the full source-control surface (stage / unstage / commit (`Ctrl+Enter`) / revert, history, worktree and child-repo selectors); the **This Session** lens folds the session event log live, recording every file the model read / wrote / edited (grouped by file, kind-filtered, op-count badge). Clicking any change previews it in the **draggable bottom pane** with the unified diff — del red / add green / mod-blue pairing + intra-line character highlights + syntax coloring + context folding — or expands into a VSCode-style dedicated diff tab (same rendering stack).</sub><br/><div align="center"><img width="420" alt="Changes" 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-activate the Tasks page, expanding the sidebar on wide viewports without forcing narrow full-screen drawers open (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> |
|
||||
| **💬 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> | **🖥️ DSH's native right sidebar + plugin bottom workbench**<br/><sub>The right column is DSH's own sidebar: the plugin registers every tab type as a native tab (including the built-in Files page and file previews), so clicking a file in the chat lands there directly; the plugin's own bottom panel can stay open alongside it — drag a tab to a pane edge to **split**, to the middle to **merge**, drag the top edge to resize; the toggle lives in the session header.</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
|
||||
@@ -174,7 +176,7 @@ Update: `git pull && pnpm install && pnpm build` → `node scripts/package-regis
|
||||
The `ctx.betterSidebar` service opens two extension points to every plugin: **`registerTab` (sidebar pages)** and **`registerFileViewer` (file previewers)**. The 8 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
|
||||
import type {} from 'dsh-apes-sidebar' // triggers the ctx.betterSidebar type merge
|
||||
export const inject = ['betterSidebar']
|
||||
export function apply(ctx: Context) {
|
||||
ctx.effect(() => ctx.betterSidebar.registerTab({
|
||||
@@ -188,7 +190,7 @@ export function apply(ctx: Context) {
|
||||
}
|
||||
```
|
||||
|
||||
The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar) already hosts **28+ ecosystem plugins** (and growing):
|
||||
The GitHub topic [`dsh-apes-sidebar`](https://github.com/topics/dsh-apes-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 />
|
||||
@@ -211,7 +213,7 @@ The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sid
|
||||
| [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 |
|
||||
| [ztyhehe/dsh-apes-sidebar-svn](https://github.com/ztyhehe/dsh-apes-sidebar-svn) | <img alt="stars" src="https://img.shields.io/github/stars/ztyhehe/dsh-apes-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 |
|
||||
@@ -224,7 +226,7 @@ The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sid
|
||||
| [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 |
|
||||
| [dong-victor/dsh-apes-sidebar-starter](https://github.com/dong-victor/dsh-apes-sidebar-starter) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-apes-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 |
|
||||
| [Hoemr/dsh-better-overleaf](https://github.com/Hoemr/dsh-better-overleaf) | <img alt="stars" src="https://img.shields.io/github/stars/Hoemr/dsh-better-overleaf?style=flat&color=4d6bfe" /> | Overleaf tab: direct-CDP browser login (third-party Chromium supported), project switching, local git mirrors under the workspace with two-way sync |
|
||||
|
||||
@@ -239,7 +241,7 @@ The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sid
|
||||
|---|---|---|
|
||||
| [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 |
|
||||
| [dong-victor/dsh-apes-sidebar-jupyter](https://github.com/dong-victor/dsh-apes-sidebar-jupyter) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-apes-sidebar-jupyter?style=flat&color=4d6bfe" /> | Runnable `.ipynb` notebook view: lazy-start Python kernel, streaming outputs, save-back |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -250,13 +252,13 @@ The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sid
|
||||
|
||||
| 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 |
|
||||
| [dong-victor/dsh-apes-sidebar-terminal-plus](https://github.com/dong-victor/dsh-apes-sidebar-terminal-plus) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-apes-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" |
|
||||
| [Hoemr/dsh-quicklook](https://github.com/Hoemr/dsh-quicklook) | <img alt="stars" src="https://img.shields.io/github/stars/Hoemr/dsh-quicklook?style=flat&color=4d6bfe" /> | QuickLook-style Space preview: press Space on the active file tab for a full-size image / PDF / text overlay; Space or Esc closes |
|
||||
|
||||
</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`).
|
||||
> 📣 **List your plugin**: tag your repo with the `dsh-apes-sidebar` topic to appear on the [topic page](https://github.com/topics/dsh-apes-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
|
||||
|
||||
@@ -265,11 +267,100 @@ The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sid
|
||||
<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 (v0.18.0): 0.1.2-rc.1+" src="https://img.shields.io/badge/DSH-0.1.2--rc.1%2B-4d6bfe" /></a> · full release history on the [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) page
|
||||
**Supported DSH versions**: <a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.19.1): 0.1.5-rc.1+ (verified on rc.2)" src="https://img.shields.io/badge/DSH-0.1.5--rc.1%2B_%28verified_rc.2%29-4d6bfe" /></a> · full release history on the [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) page
|
||||
|
||||
### v0.19.1
|
||||
|
||||
> 📌 **Stable release** (npm `latest`, no prerelease suffix): the pinned baseline moves to **DSH 0.1.5-rc.2** (npm `next`) while the **peer floor stays `^0.1.5-rc.1`** — nothing in the rc.2 upstream delta touches this plugin (zero changes under `packages/api|host|session|agent`; the only real code edits are the message-feedback dialog, deliverables card CSS and the `CodeFileIcon` SVG data split), so rc.1 hosts need no DSH upgrade to run this version. Hosts on DSH 0.1.5-alpha.2 stay on **v0.19.0-alpha.1**; the 0.1.2-rc.1 stable line keeps using **v0.18.1**.
|
||||
|
||||
- 🎯 **Adapted to DSH 0.1.5-rc.2**: devDependency pins, the CI mount lane and `SIDEBAR_SERVICE_VERSION` all move to rc.2; there is **no plugin-side code change** (the upstream delta does not touch this plugin — file-by-file audit in [docs/plans/2026-09-10-dsh-0.1.5-rc.2-adaptation.md](docs/plans/2026-09-10-dsh-0.1.5-rc.2-adaptation.md): the vast majority of the 300 changed files are single-line `version` bumps).
|
||||
- 🎨 **File icons (#611) and the built-in tab glyphs are now colored** (#531 + #594 merged, implementation redone for rc.2):
|
||||
- **Files and folders use DSH's own artwork**: the fallback link is the host's `FileTypeIcon` from `ui-primitives` (48 full-color official code/config glyphs plus the category-colored sheets for markdown, images, PDF, Office, video and folders), so the plugin **ships no extension table and needs no icon chunk** — #429's 563-entry color dataset and `lib/client-file-icons.js` (255 kB) are gone, along with the `fileIconTheme` switch. Color is simply the behaviour now, and the core bundle grew by 10 kB.
|
||||
- **New public `registerFileIcon` API** (capability `'fileIcons'`): register your own glyphs by extension (`exts`), exact file name (`names`) or directory name (`folderNames`), ranked by priority then registration order. **Caveat**: the host classifier covers every path, so a catch-all (`exts: []`) claims every row the specific rules miss.
|
||||
- **Colored built-in tab glyphs**: Files / Changes / Tasks / Side chat / Terminal / Browser and the diff view all get a colored glyph, painted from `--dsw-alias-*` tokens so every skin still applies.
|
||||
- **The native right Sidebar's tab chips carry a glyph too**: the host's tab definition has no icon field, but the `sidebar.right.pane.tab.title` slot *is* the chip's content — the plugin renders `[glyph][title]` there (an editor tab with a path shows that file's icon). The glyph is `aria-hidden`, so the chip's accessible name is unchanged.
|
||||
- 🛠 **CI fix 1: real Windows-lane timeouts**. `ci-windows` went red eight times in the 2026-09-09/10 window; six of those were process-spawning cases hitting vitest's 5000 ms default: `tests/agent-pty.spec.ts` (real PowerShell + ConPTY per terminal) and `tests/install-powershell.spec.ts` (a cold `powershell.exe` start measured 12.1 s) now declare a 30 s budget each, vitest.config.ts raises the global `testTimeout` from its 5000 ms default to **15 s** (the first real Windows run then lost a *third* file to the same default — `tests/git.spec.ts:85` at 9607 ms; three files, one cause, so per-file patches were whack-a-mole), and `waitForTranscript`'s inner poll budget dropped from 5000 ms to 15 s — **the inner budget must sit below the outer one** (they used to be the same 5000 ms, a structurally guaranteed timeout). `ci-windows`'s `Test` step now runs `pnpm test:windows` (`--maxWorkers=2`) so the process-spawning specs stop starving each other on a 2-core runner.
|
||||
- 🛠 **CI fix 2: the mount lane's npm install**. `plugin-mount`'s `npm install -g @deepseek-ai/dsh@<ver>` failed four times (two `ETARGET`, two `JavaScript heap out of memory` / exit 134): the pinned version declares its transitives as floating `^` ranges, so while upstream publishes a prerelease in stages (rc.2 appeared package by package between 14:43 and 14:57 UTC on 09-10) npm resolved a mixed rc.1/rc.2 peer graph (3062 ERESOLVE lines). The lane now pins an **already-complete** rc.2 (fixes ETARGET) and passes `NODE_OPTIONS=--max-old-space-size=4096` (fixes the OOM). `--legacy-peer-deps` was tried on the way and rejected by real CI: it skips exactly the peers a global install must supply, and `@deepseek-ai/cordis-plugin-group` — required at boot by `dsh-app-boot` — is a peer rather than a dependency, so the CLI died with `ERR_MODULE_NOT_FOUND`.
|
||||
|
||||
### v0.19.0
|
||||
|
||||
> 📌 **Stable release** (npm `latest`, no prerelease suffix): supports **DSH 0.1.5-rc.1+** only (peer floor `^0.1.5-rc.1`, CI pins `@deepseek-ai/dsh@0.1.5-rc.1`). Hosts on DSH 0.1.5-alpha.2 should stay on **v0.19.0-alpha.1** (the npm `alpha` tag still points there); the 0.1.2-rc.1 stable line keeps using **v0.18.1**.
|
||||
|
||||
**✨ New**
|
||||
|
||||
- 📝 **The new-tab list gets an optional description back**: DSH 0.1.5-rc.1 restored `SidebarRightGuideEntry.description` (optional), so the plugin restores `TabDescriptor.description` — each of the six built-in types declares a line again (all six guide rows — Files / Changes / Tasks / Side chat / Terminal / Browser — carry one), and the `guideDesc*` keys return to all 20 dictionaries. **The host's native guide renders descriptions only while the listed entries are ≤ 4** (a longer list drops every description rather than truncating), and the plugin contributes six guide entries by default — so in the default composition no description is rendered; they appear once the reader disables enough tab types (per-type switches live on the plugin's settings page) to bring the guide down to four entries or fewer. An entry that declares none stays a single icon + title line (the plugin ships no generic fallback sentence). (#613)
|
||||
|
||||
**🐛 Fixes**
|
||||
|
||||
- None. The rc.1 delta over alpha.2 is small (373 changed files, overwhelmingly upstream package version bumps, native-sidebar preview UI polish, and test snapshots); apart from the restored description field (plus upstream's `files` type now using its own coloured folder glyph as the guide glyph) nothing needed adapting.
|
||||
|
||||
**🧰 CI & internals**
|
||||
|
||||
- Baseline moved to **DSH 0.1.5-rc.1+** (#613): peer floor, devDependency pins, the CI mount lane, and `dsh.plugin.json`'s `engines.dsh` all follow (rc.1 is both the `latest` and `next` dist-tag on npm).
|
||||
- Explicitly unchanged, no need to re-check: the global main-panel model (the `main` slot / `sidebar.panellist` / `ctx.layout` / root-scoped `rightbar` + `rightbar.session`, still not adopted), the file address grammar (`packages/util/workspace-path` only had its version bumped), the native tab-body host contract (`.paneBody` is still a block scroller with a definite height), the core / agent / session / subagent host APIs, and the `ui-primitives` export surface (only CodeBlock rendering changed).
|
||||
- `@deepseek-ai/dsh-client-ui-primitives@0.1.5-rc.1` still declares no `dependencies` while its bundle still bare-imports `anser` / `shiki` / `@shikijs/langs/*` / `mdast-util-*` / `micromark-*` / `katex` — the third-party devDependencies hoisted in the previous release therefore stay, and must not be reverted.
|
||||
- Real-host verification (DSH 0.1.5-rc.1 + plugin 0.19.0): `typecheck` / `lint` / `check:consumer-types` green; unit suite **124 files · 1296 passed · 9 skipped**; `pnpm peers check` clean; the mount smoke lane against real rc.1 **7 passed** (including the tab-body fill assertion and the new "descriptions render only at ≤4 guide entries" assertion); on the local 3080 instance the guide still shows six icon+title capsules (6 > 4, so upstream renders no descriptions — upstream also raised the capsule `min-height` from 48px to 56px), clicking `AGENTS.md` in the file tree lands in the plugin editor (CodeMirror mounted), the side-chat composer sits on the pane floor (host box 962px == pane body 962px, composer 8px off the bottom), the bottom workbench's edges coincide with the centre column, and `pageerror` is 0 (the only console noise is the third-party `dsh-tauri-worktree` `/api/dsh-worktree/attach` 500, unrelated to this change).
|
||||
|
||||
### v0.19.0-alpha.1
|
||||
|
||||
> 🧪 **alpha channel** (npm dist-tag `alpha`, install `dsh-apes-sidebar@alpha`): supports **DSH 0.1.5-alpha.2+** only (peer floor `^0.1.5-alpha.2`, CI pins `@deepseek-ai/dsh@0.1.5-alpha.2`). Stay on **v0.19.0-alpha.0** for 0.1.5-alpha.1; the 0.1.2-rc.1 stable line keeps using **v0.18.1** (npm `latest`).
|
||||
|
||||
**✨ New**
|
||||
|
||||
- 🪟 **Every tab body now fills its pane** (#609): the native sidebar's tab-body host is a block scroller with a definite height, not a flex container, and the plugin's tab roots declared only `flex: 1` — so they collapsed to content height and the side-chat composer sat right after the transcript instead of at the pane bottom (a long transcript pushed it out of view). The native adapter now wraps every tab body in a `height: 100%` column flex host, restoring the same fill semantics the bottom workbench has for all plugin tabs, third-party `registerTab` descriptors included.
|
||||
|
||||
**🐛 Fixes**
|
||||
|
||||
- 🧭 **File address grammar follows DSH 0.1.5-alpha.2**: `fileAddressFor` always produces a session-scoped address and absolute paths keep their leading `/`; `parseFileAddress` is prefix-based and ignores `?`/`#` suffixes.
|
||||
- 🪟 **Center-column anchor follows alpha.2's global panels**: the `conversation` slot became `main.conversation` under the root-scoped keyed `main` slot, so the locator now resolves the new key and skips `display: contents` slot hosts (alpha.1's old key still works).
|
||||
|
||||
**🧰 CI & internals**
|
||||
|
||||
- Baseline moved to DSH 0.1.5-alpha.2 (#609): peer floor, 22 devDependency pins, the CI mount lane, and `dsh.plugin.json` engines all follow; `pnpm peers check` is clean (hoisted the `dsh-session-persistence` transitive peer per §3-9).
|
||||
- **`TabDescriptor.description` removed**: alpha.2's native guide entries no longer render a second line (they are icon + title capsules), so the field and the six `guideDesc*` keys (20 dictionaries) are gone; a new pane's default page is now selected from the registry (exactly one guide entry opens that page directly).
|
||||
|
||||
### v0.19.0-alpha.0
|
||||
|
||||
> 🧪 **alpha channel** (npm dist-tag `alpha`, install `dsh-apes-sidebar@alpha`): supports **DSH 0.1.5-alpha.1+** only (peer floor `^0.1.5-alpha.1`, CI pins `@deepseek-ai/dsh@0.1.5-alpha.1`). The 0.1.2-rc.1 stable line keeps using **v0.18.1** (npm `latest`).
|
||||
|
||||
**✨ New**
|
||||
|
||||
- 🖥️ **DSH's native right sidebar** (#604): the right column is now DSH's own sidebar — all seven plugin tab types register as native tab types with native tab bodies; every file open from the chat goes through `ctx.sidebarRight.openResource(dsh-resource://file/…)`; the `editor` type claims file resources at `extension` priority (outranking the built-in text preview) and takes over the built-in Files page kind (restored on unregister); cross-session opens queue until the target session is on screen.
|
||||
- 🧩 **Own right panel and free windows retired** (#605): with the right column handed back to DSH, the plugin keeps only its bottom workbench (single split tree, per-session persistence) and registers its toggle into DSH's session-header utilities slot; the float API (`floats` / `floatTab` / `dockFloat` / `raiseFloat` / the "Move to Free Window" menu entry), the `'floatWindows'` feature string, and the `openByDefault` / `defaultWidthPercent` / `changesDiffFloat` settings are gone (a persisted `floats` field is ignored, so old documents still load).
|
||||
- 🔗 **DSH 0.1.5 host contracts** (#603): the `assistant/chunk` event is gone — live deltas now fold from `agent/assistant-stream` frames (the side chat transcript's `live` field); `sessionPersistence.inspect` is gone — cold reads go through `open(id,'read')`; session headers use `SESSION_FORMAT_VERSION`.
|
||||
|
||||
**🐛 Fixes**
|
||||
|
||||
- Eight session-event read sites follow the 0.1.5 contracts (side-chat transcript, `jobs.output` replay, fork inheritance); custom seeds now carry the fork marker pair, so they no longer inherit the parent's unclaimed inbox input.
|
||||
|
||||
**🧰 CI & internals**
|
||||
|
||||
- The real-host mount smoke lane pins 0.1.5-alpha.1 and sweeps the native sidebar's guide page to open every plugin tab type; typecheck / lint / unit tests / mount lane all green.
|
||||
|
||||
### v0.18.1
|
||||
|
||||
> 📌 **Stable release** (npm `latest`): the DSH baseline is unchanged (**0.1.2-rc.1+**, peer floor `^0.1.2-rc.1`) — this is the incremental cut after v0.18.0: richer op previews in the changes panel, a writable file tree, and five fixes.
|
||||
|
||||
**✨ Features**
|
||||
|
||||
- 📄 **Op-preview upgrades in the changes panel** (#499): markdown reading mode with mermaid fences, plus inline `.html` and `.pdf` render previews; diff syntax highlighting extended to mjs/cjs/mts/cts, CSS/SCSS/Less, HTML/XML/SVG/Vue, GraphQL, JSONC/JSON5; new **secret redaction** layer (on by default for previews, toggleable in the pane header)
|
||||
- 🗂️ **File-tree rename / delete** (#550): inline rename plus confirmed delete, slimmer context menus and viewport-clamped submenus
|
||||
- 🧩 **Plugin catalog names and shell preset text localized** (#535): they now follow the host language
|
||||
|
||||
**🐛 Fixes**
|
||||
|
||||
- 🔀 **Git diff gap folds really expand** (#576, fixes #577): the fold row promised "n lines… click to expand" but did nothing (`-U3` leaves gap segments without row text); the hidden rows now load on demand through `git.show`, sliced from both sides' contents, with loading / failure states and request deduplication — plus a fix to that route's `rev:path` addressing (it always returned empty before)
|
||||
- 💬 **Side-chat seeds no longer inherit the parent's unclaimed inbox messages** (#562): the seed carries the fork markers, so the "side chat sends earlier User messages first on long contexts" ghost message is gone
|
||||
- 🖼️ **Local images in the split markdown renderer** (#569): rewritten to reachable URLs instead of 404ing
|
||||
|
||||
**🧰 CI and internals**
|
||||
|
||||
- ESLint flat config wired into CI and the Makefile (#536), Makefile command surface (#526), hardened e2e scripts (#527), shared component-test utilities (#524)
|
||||
- Refactors: Sidebar.tsx split by concern (#542), four polling idioms converged onto `use-polling` (#541), rc.7 `__DSH_MODULES__` fallback removed (#540), One Dark/Light syntax palettes single-sourced (#534), duplicated implementations converged and dead code removed (#525)
|
||||
|
||||
### v0.18.0
|
||||
|
||||
> 📌 **Stable release** (npm `latest`): this release targets **DSH 0.1.2-rc.1+ only** (peer floor `^0.1.2-rc.1`); 0.1.0-rc.8 ~ 0.1.1-rc.2 are not supported — stable-DSH users should stay pinned to `dsh-better-sidebar@0.17.1`, hosts on 0.1.2-alpha.x keep `dsh-better-sidebar@alpha` (v0.18.0-alpha.0).
|
||||
> 📌 **Stable release** (npm `latest`): this release targets **DSH 0.1.2-rc.1+ only** (peer floor `^0.1.2-rc.1`); 0.1.0-rc.8 ~ 0.1.1-rc.2 are not supported — stable-DSH users should stay pinned to `dsh-apes-sidebar@0.17.1`, hosts on 0.1.2-alpha.x keep `dsh-apes-sidebar@alpha` (v0.18.0-alpha.0).
|
||||
|
||||
All changes since v0.17.1 (the two intermediate version numbers v0.18.1-alpha.0 / v0.19.0-alpha.0 were never published; their content is folded into this release):
|
||||
|
||||
@@ -300,23 +391,23 @@ All changes since v0.17.1 (the two intermediate version numbers v0.18.1-alpha.0
|
||||
|
||||
**🌐 Ecosystem**
|
||||
|
||||
- 10+ new curated plugins: dsh-better-sidebar-icons (#441), dsh-sidenote (#451, formerly dsh-sidechat #470), dsh-github-workbench (#410), dsh-bilingual-reader (#379), dsh-server-deck (#413), dsh-md-export (#405), dsh-code-nav (#404), dsh-suhuang-scroll (#392), dsh-better-overleaf (#370), and more (each with 18+ language i18n coverage)
|
||||
- 10+ new curated plugins: dsh-apes-sidebar-icons (#441), dsh-sidenote (#451, formerly dsh-sidechat #470), dsh-github-workbench (#410), dsh-bilingual-reader (#379), dsh-server-deck (#413), dsh-md-export (#405), dsh-code-nav (#404), dsh-suhuang-scroll (#392), dsh-better-overleaf (#370), and more (each with 18+ language i18n coverage)
|
||||
|
||||
### v0.19.0-alpha.0
|
||||
### v0.19.0-alpha.0 (0.1.2-alpha.5 adaptation, unpublished, folded into v0.18.0)
|
||||
|
||||
> 🧪 **Alpha track**: this release targets **DSH 0.1.2-alpha.x only** (peer floor `^0.1.2-alpha.5`, npm dist-tag `alpha`, install `dsh-better-sidebar@alpha`). This version number was never published separately; its content is folded into the **v0.18.0** stable release.
|
||||
> 🧪 **Alpha track**: this release targeted **DSH 0.1.2-alpha.x only** (peer floor `^0.1.2-alpha.5`, npm dist-tag `alpha`). This number was never published separately — its content shipped in the **v0.18.0** stable release, and the same number was later reused for the 0.1.5 adaptation line (see above).
|
||||
|
||||
- 🔗 **Adapted to DSH 0.1.2-alpha.5 (published to npm, `alpha` dist-tag)**: the CI mount gate's pin, the `dsh.plugin.json` engines floor, and the `@deepseek-ai/*` peer / devDependencies baseline moved up to 0.1.2-alpha.5 (verified by a real-host mount smoke 14/14). `dsh-client-locale` has no alpha.5 upstream (newest is 0.1.2-alpha.3), so its peer floor / devDep pin trail the baseline and already accept the alpha.5 runtime (`pnpm peers check` reports zero mismatches — no new transitive peers to hoist). The code adapts to alpha.4's compatibility-flagged change — the `Session.events` property was removed, migrated to the on-demand `snapshotEvents()` API (8 sites: sidechat transcript live reads, fork inheritance, `jobs.output` replay, subagent activity), and the `seedLength` meta field the host dropped was removed from thread creation; alpha.4's remaining changes (bidirectional `send_message`, custom-model discovery reusing Profile headers, `SessionSeq`/`SessionLogOffset` strong typing) and alpha.5's upgrade-startup fix were verified not to touch any other plugin surface.
|
||||
|
||||
### v0.18.1-alpha.0
|
||||
|
||||
> 🧪 **Alpha track**: this release targets **DSH 0.1.2-alpha.x only** (peer floor `^0.1.2-alpha.3`, npm dist-tag `alpha`, install `dsh-better-sidebar@alpha`). This version number was never published separately; its content is folded into the **v0.18.0** stable release.
|
||||
> 🧪 **Alpha track**: this release targets **DSH 0.1.2-alpha.x only** (peer floor `^0.1.2-alpha.3`, npm dist-tag `alpha`, install `dsh-apes-sidebar@alpha`). This version number was never published separately; its content is folded into the **v0.18.0** stable release.
|
||||
|
||||
- 🔗 **Adapted to DSH 0.1.2-alpha.3 (published to npm, `alpha` dist-tag)**: the CI mount gate's pin, the `dsh.plugin.json` engines floor, and the `@deepseek-ai/*` peer / devDependencies baseline moved up to 0.1.2-alpha.3 (verified by a real-host mount smoke 14/14). All 117 commits between alpha.2 and alpha.3 were audited point by point: every host contract this plugin relies on (token auth, slash RPC, `MarkdownText` labels, the event-stream and persistence APIs behind `sidechat.events`, `SettingsNamespaceInput`, `SUBAGENT_DESCRIPTOR_VERSION` (still 3), `dsh-client-store`, the profile loader, the node-pty pin) is unchanged, so no code adaptation was needed; alpha.3's breaking changes (the required `BeginSubmissionInput.mode`, the renamed `attachment-invalid` subagent error, the removed SQLite persistence backend, the identity-gated projection change feed) were all verified not to touch this plugin.
|
||||
|
||||
### v0.18.0-alpha.0
|
||||
|
||||
> 🧪 **Alpha track**: this release targets **DSH 0.1.2-alpha.x only** (peer floor `^0.1.2-alpha.2`, npm dist-tag `alpha`, install `dsh-better-sidebar@alpha`); 0.1.0-rc.8 ~ 0.1.1-rc.2 are no longer supported — stable-DSH users should stay on v0.17.1 (npm `latest`).
|
||||
> 🧪 **Alpha track**: this release targets **DSH 0.1.2-alpha.x only** (peer floor `^0.1.2-alpha.2`, npm dist-tag `alpha`, install `dsh-apes-sidebar@alpha`); 0.1.0-rc.8 ~ 0.1.1-rc.2 are no longer supported — stable-DSH users should stay on v0.17.1 (npm `latest`).
|
||||
|
||||
- 🔗 **Adapted to DSH 0.1.2-alpha.2 (published to npm, `alpha` dist-tag)**: the CI mount gate's pin and the `@deepseek-ai/*` devDependencies baseline moved up to it (verified by a real-host mount smoke 14/14). Adaptation points: `dsh-settings` dropped the runtime `settingsNamespace` export (namespaces are now validated at compile time — the host passes the constant directly); the `dsh-subagent` descriptor version went 2→3 (stamped by the host package; the test assertion follows the `SUBAGENT_DESCRIPTOR_VERSION` constant); the restored `SessionEvent.ignorable` and the Remote gateway's unified `RemoteError` wrapping were verified to not affect this plugin.
|
||||
- 🐛 **Fixed blank Side Chat transcripts on DSH 0.1.2-alpha.1+**: transcript polling still called the `ctx.connection.api` face removed in alpha.1 (the error was silently swallowed, so the tab rendered an empty transcript forever). Transcripts now come from the plugin's own `sidechat.events` route (live threads read the in-memory event log, cold threads read session persistence, with `afterSeq` delta pulls, [sidechat-routes.ts](./src/sidechat-routes.ts)).
|
||||
@@ -354,7 +445,7 @@ All changes since v0.15.2:
|
||||
**🐛 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
|
||||
- 🔤 **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-apes-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)
|
||||
@@ -506,19 +597,20 @@ All changes since v0.14.0:
|
||||
|
||||
Since v0.4.0 the plugin exposes the `ctx.betterSidebar` service — other plugins can register sidebar pages and file viewers (the 8 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 (complete fields, matching algorithm, HMR pitfalls, declarative settings, version detection, float windows and the skinning contract): **[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)**; repository rules (hard constraints / CI / release) live in [`AGENTS.md`](./AGENTS.md).
|
||||
Full integration docs (complete fields, matching algorithm, HMR pitfalls, declarative settings, version detection, the native-sidebar surface and the skinning contract): **[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)**; repository rules (hard constraints / CI / release) live in [`AGENTS.md`](./AGENTS.md).
|
||||
|
||||
### ➕ 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.
|
||||
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-apes-sidebar`](https://github.com/topics/dsh-apes-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`.
|
||||
**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-apes-sidebar` topic; data integrity is guarded by `tests/plugin-list.spec.ts`.
|
||||
|
||||
## 🛠️ Development & Build
|
||||
|
||||
```sh
|
||||
pnpm install # @deepseek-ai/* devDependencies resolve (baseline 0.1.2-rc.1, next dist-tag) — no token needed
|
||||
pnpm typecheck # tsc --noEmit
|
||||
pnpm lint # eslint . (flat config: js + typescript-eslint + react-hooks recommended)
|
||||
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
|
||||
@@ -571,7 +663,7 @@ WeChat / QQ group QR codes will live here. After uploading the QR images (drag t
|
||||
## 🤝 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)
|
||||
- **Curate an ecosystem plugin**: tag your repo with `dsh-apes-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` (or `make check` for the one-shot aggregate; CI additionally gates on npm-pack → real-mount → headless-render via `pnpm test:mount`, plus the aggregate double-mount regression `pnpm test:mount:aggregate`)
|
||||
- See [`AGENTS.md`](./AGENTS.md) for the repository rules (hard constraints, CI lanes, release flow)
|
||||
|
||||
@@ -602,5 +694,5 @@ Thanks to everyone who contributed:
|
||||
---
|
||||
|
||||
<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>
|
||||
<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-apes-sidebar">dsh-apes-sidebar topic</a></sub>
|
||||
</div>
|
||||
|
||||
+190
-37
@@ -1,13 +1,39 @@
|
||||
# 外部插件接入指南:基于 dsh-better-sidebar 实现新页面
|
||||
# 外部插件接入指南:基于 dsh-apes-sidebar 实现新页面
|
||||
|
||||
> 面向 **消费插件开发者**:如何让你的插件向 better-sidebar 注册新的侧边栏页面(tab)和文件类型预览器。
|
||||
>
|
||||
> 适用版本:**v0.4.0+**(`ctx.betterSidebar` 服务);声明式设置 **v0.4.1+**;text/number 设置行 **v0.11.0+**;badge/生命周期/定向打开/插件设置/版本探测 **v0.12.0+**;select 设置行(`settingSelect`)与外链认领(`urlTarget`)**v0.13.0+**;统一 `@deepseek-ai/cordis` 类型基底 **v0.15.2+**;自由窗口(`floatWindows`)**v0.16.0+**;终端固定(pin)**v0.17.0+**。当前版本 **v0.18.0**(正式版,仅支持 DSH 0.1.2-rc.1+;旧宿主 stable 线为 v0.17.1)。
|
||||
> 适用版本:**v0.4.0+**(`ctx.betterSidebar` 服务);声明式设置 **v0.4.1+**;text/number 设置行 **v0.11.0+**;badge/生命周期/定向打开/插件设置/版本探测 **v0.12.0+**;select 设置行(`settingSelect`)与外链认领(`urlTarget`)**v0.13.0+**;统一 `@deepseek-ai/cordis` 类型基底 **v0.15.2+**;终端固定(pin)**v0.17.0+**。当前版本 **v0.19.1**(正式版,npm `latest`,仅支持 DSH **0.1.5-rc.1+**,已在 **0.1.5-rc.2** 上完成真机挂载验证;**0.1.5-alpha.2 及更早不再支持**——alpha.2 用户请用 v0.19.0-alpha.1,那是它的最后一版;0.1.2-rc.1 稳定线请用 v0.18.x)。**v0.19.0 移除了自绘右侧面板与自由窗口**(见 §0、§11)。
|
||||
> 权威代码:`src/client/service.ts`(服务实现)、`src/client/builtins/`(内置 8 tab + 6 viewer 参考实现)、`lib/types/client/service.d.ts`(类型声明)。
|
||||
> 仓库开发规则(硬约束 / CI / 发版)见 [AGENTS.md](../AGENTS.md)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 承载面:DSH 原生右侧栏 + 插件底部工作台(v0.19.0-alpha.0 起)
|
||||
|
||||
从 v0.19.0-alpha.0 起,**右列完全属于 DSH**:你的 tab 渲染在 **DSH 自己的右侧栏**里(`ctx.sidebarRight` / `ctx.sidebarRightTabs`),插件把每个 `TabDescriptor` 注册成原生 tab 类型(`kind = descriptor.id`)+ 一个原生 tab 体。插件自己只保留**底部工作台**(分栏树、终端、会话内持久化)。对你的接入代码**没有影响**——仍然只调用 `ctx.betterSidebar`:
|
||||
|
||||
- `registerTab` / `registerFileViewer` 签名不变;
|
||||
- `openTab` / `openFile` 默认落到原生右侧栏;新增可选 `OpenTabSeed.target`(`'right'` 默认 / `'bottom'` 落插件的底部工作台);
|
||||
- `updateTab` / `closeTab` / `activateTab` 认识原生 tab id(插件为每个原生 tab 维护一条合成 `SidebarTab` 记录,`tab.meta` / `tab.path` 的写入照旧生效)。
|
||||
|
||||
行为差异(写在这里以免踩坑):
|
||||
|
||||
| 事项 | 说明 |
|
||||
|---|---|
|
||||
| 生命周期回调 | 原生面只有「一次打开」,不区分新建/聚焦,因此只触发 `onOpen`(`onActivate` 仅在插件自己的底部工作台里触发) |
|
||||
| 去重 | 原生按 `(kind, 地址)` 去重:有 `createTab` 的类型每次新开一个 tab(terminal / browser / sidechat / diff),其余聚焦已有 tab;`dedupeKey` 的自定义语义不参与原生面 |
|
||||
| 布局持久化 | 原生栏的布局**只在内存**(刷新后回到折叠默认),插件自己的底部工作台仍然持久化 |
|
||||
| 跨会话打开 | 目标会话的右侧栏 store 未挂载时,打开会排队到该会话上屏后重放 |
|
||||
| 内置类型接管 | 插件的 `editor` 类型以 `extension` 优先级认领 `dsh-resource://file/**`(压过内置 `ui-sidebar-documentpreview` 的 `text` 预览——即 `fallback` 带),并接管内置 `files` 页面 kind(`openTab('files')` 打开插件的文件树);插件卸载/禁用时内置实现自动复位 |
|
||||
| 终端上限 | 终端 tab 的数量上限只统计插件自己底部工作台里的终端;原生栏里的终端不计入 |
|
||||
| 底部工作台的开合 | 落到底部工作台的打开一律展开它(新建与聚焦都算),因此 `openTab` 的落点永远可见;开合按钮注册在 DSH 会话头的 utilities 槽(`conversation.session.header.utilities`),不在插件自己的宿主里 |
|
||||
| 新建标签页列表 | 每个 tab 类型在原生 guide 里占一行:标题取 `title` + 图标取 `icon`(缺图标时宿主补一个方块占位),说明取可选的 `description`——**宿主只在 guide 列出的条目 ≤ 4 条时渲染说明**(上游 `MAX_DESCRIBED_ENTRIES = 4`),更长的列表整列丢掉所有说明;未声明 `description` 的条目渲染成单行「图标 + 标题」(rc.1 起 `description` 回到宿主契约,但**宿主与插件都没有兜底句**,所以插件恢复字段而不恢复旧的通用句);`hidden: true` 的类型不占行。插件的 `editor` 类型不再单独占行(它认领的文件资源由 `files` 接管页承载同一视图)。**注意默认组合看不到说明**:插件贡献 6 个 guide 条目(文件 / 文件变动 / 任务管理 / 侧边对话 / 终端 / 浏览器),已超过 4 条上限——要让说明出现,需在插件设置页关掉足够多的 tab 类型把 guide 压到 ≤ 4 条 |
|
||||
| 新建面板的种子(alpha.2) | 在新会话打开原生新面板时,宿主从已注册的 guide 条目里播种:恰好 1 个条目 → 直接打开那一页;0 或 ≥2 个条目 → 打开指南。`revealIfOpened` 打开的「页面」在**同一 pane 内**强制去重(已在该 pane 就不再新建);由已有 tab 地址驱动的打开不受该去重影响 |
|
||||
| alpha.2 全局面板(不接入) | 插件**不采用** alpha.2 引入的全局主面板模型——根级 keyed `main` 槽(预留 key `conversation`,由 ui-conversation 注册为 `main.conversation`)、根级 `sidebar.panellist` 列表槽(`SidebarPanelMetadata` / `SidebarPanelIconOwnerProps`)、`ctx.layout.selectPanel(MainPanelId|null)` / `beginNavigation()` / `dispose()`、全局标准 prop `usePanelInfo`,以及改根级并新增会话级 `rightbar.session` 子槽的 `rightbar`——这些只作兼容保留,不向其迁移 |
|
||||
| 已移除 | 插件自绘右侧面板(含宽度拖拽 / 新会话默认宽度)与**自由窗口**(`features` 里的 `'floatWindows'` 已删除,v0.18.x 及更早版本的消费者请勿再 gate 该能力);`openByDefault` / `defaultWidthPercent` / `changesDiffFloat` 三个设置项同步删除(旧文档里的键会被忽略) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 总览:你能扩展什么
|
||||
|
||||
better-sidebar 从 v0.4.0 起把自己改造成一个**注册表服务**:
|
||||
@@ -30,7 +56,7 @@ better-sidebar 从 v0.4.0 起把自己改造成一个**注册表服务**:
|
||||
DSH 运行时和生态的类型正主是 vendored `@deepseek-ai/cordis`。你的插件解析到它的 `Context` 时,`ctx.betterSidebar` 不会自动出现——由 better-sidebar 用 `declare module '@deepseek-ai/cordis'` 补上:
|
||||
|
||||
```ts
|
||||
import type {} from 'dsh-better-sidebar' // 触发 declare module '@deepseek-ai/cordis' 类型合并
|
||||
import type {} from 'dsh-apes-sidebar' // 触发 declare module '@deepseek-ai/cordis' 类型合并
|
||||
```
|
||||
|
||||
这个 **type-only import** 在编译时被擦除,不产生任何运行时依赖,也不会触发构建纯度门(见 §10)。
|
||||
@@ -42,15 +68,15 @@ import type {} from 'dsh-better-sidebar' // 触发 declare module '@deepseek-ai
|
||||
"name": "my-plugin",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "^4.0.1",
|
||||
"dsh-better-sidebar": "workspace:*"
|
||||
"dsh-apes-sidebar": "workspace:*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"dsh-better-sidebar": { "optional": true }
|
||||
"dsh-apes-sidebar": { "optional": true }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `dsh-better-sidebar` 必须是 **peerDependency**(不是 dependency),避免两份实例;
|
||||
- `dsh-apes-sidebar` 必须是 **peerDependency**(不是 dependency),避免两份实例;
|
||||
- `optional: true`:better-sidebar 未安装时你的插件照常加载,注册代码因为 `ctx.betterSidebar` 为 undefined 而安全跳过。
|
||||
|
||||
> ⚠️ **计划上架 DSH 插件市场**的插件还须遵守市场 manifest 约束(`dependencies`/`peerDependencies`/`optionalDependencies` 一律不得出现 `cordis`、`scripts` 不得含 install 类钩子),详见 [AGENTS.md](../AGENTS.md) 硬约束一节。
|
||||
@@ -66,10 +92,10 @@ import type {
|
||||
FileViewerDescriptor,
|
||||
FileViewerProps,
|
||||
FileFetchStrategy,
|
||||
} from 'dsh-better-sidebar'
|
||||
} from 'dsh-apes-sidebar'
|
||||
|
||||
// 方式二:子路径(与主入口等价)
|
||||
import type { TabDescriptor } from 'dsh-better-sidebar/client/service' // 别名 ./client/api
|
||||
import type { TabDescriptor } from 'dsh-apes-sidebar/client/service' // 别名 ./client/api
|
||||
```
|
||||
|
||||
v0.12.0 起,服务模块还 re-export 了完整的状态词汇表,消费者可以直接命名(不再只能靠推断):
|
||||
@@ -78,10 +104,10 @@ v0.12.0 起,服务模块还 re-export 了完整的状态词汇表,消费者
|
||||
import type {
|
||||
SidebarTab, SidebarState, SidebarStore, SidebarSnapshot, SidebarDiffRef, TabType,
|
||||
SessionScope, SidebarPrefs, OpenTabSeed, SidebarSettingsRenderProps,
|
||||
} from 'dsh-better-sidebar/client/service'
|
||||
} from 'dsh-apes-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` 也能编译。
|
||||
> 💡 **类型合并触发路径**:`import type {} from 'dsh-apes-sidebar/client/service'` 同样会加载 `Context` 的 augmentation(`declare module '@deepseek-ai/cordis'` 在 context-types.d.ts 中)——**纯浏览器侧插件建议走 `client/service` 路径**,避免拉进宿主半的 Node 类型图(主入口 `dsh-apes-sidebar` 的声明面含宿主代码;宿主消费者本就处于 Node 环境则无所谓)。client 可达声明图(`client/*` + context-types + html-route + prefs-shared)自 v0.12.0 起**零 Node 依赖**(`scripts/check-consumer-types.sh` 守护),没有 `@types/node`、`skipLibCheck: false` 也能编译。
|
||||
|
||||
---
|
||||
|
||||
@@ -89,7 +115,7 @@ import type {
|
||||
|
||||
```ts
|
||||
// my-plugin/src/client/index.ts
|
||||
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
|
||||
import type {} from 'dsh-apes-sidebar' // 触发 ctx.betterSidebar 类型合并
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
|
||||
export const inject = ['betterSidebar', 'slots'] // 声明服务依赖(slots 可选,按需)
|
||||
@@ -155,7 +181,15 @@ interface TabDescriptor {
|
||||
id: string
|
||||
/** 标题(i18n 友好:传字符串或返回字符串的函数) */
|
||||
title: string | (() => string)
|
||||
/** 图标:ReactNode 或 (size: number) => ReactNode */
|
||||
/**
|
||||
* 一行说明,渲染在标题下方(DSH 原生右侧栏的新建标签页 / guide 列表)。
|
||||
* **宿主只在 guide 列出的条目 ≤ 4 条时渲染它**(上游 `MAX_DESCRIBED_ENTRIES = 4`),
|
||||
* 更长的列表整列丢掉所有说明——也就是说这是一行「锦上添花」,别把关键信息只放这里。
|
||||
* 不声明就不发 `description` 字段:宿主没有兜底句,插件也不补(通用句在所有页面上
|
||||
* 长得一样,纯噪音),条目就渲染成单行「图标 + 标题」。函数形式在渲染时求值,跟随语言。
|
||||
*/
|
||||
description?: string | (() => string)
|
||||
/** 图标:ReactNode 或 (size: number) => ReactNode(不声明时宿主补一个方块占位) */
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
/** + 菜单排序(升序);默认 100。内置:editor=10, git=20, subagent=30, sidechat=35, terminal=40, browser=50 */
|
||||
order?: number
|
||||
@@ -362,7 +396,7 @@ ctx.effect(() => {
|
||||
| id | order | single | hidden | 用途 |
|
||||
|---|---|---|---|---|
|
||||
| `editor` | 10 | 否(按 path 去重) | 否 | 唯一「文件窗口」(编辑/预览 + 资源管理)。chrome 恒合并形态:路径输入框 + 编辑器控件 + 可开关内嵌文件树(全局搜索 `fs.search`;状态存 `tab.meta.treeOpen/treeWidth`)。`editorExplorer`:关(默认)= 按 path 新开,无路径窗口 = 纯资源管理器;开 = 树点击/Enter 经 `updateTab` 原地切换(id/meta 不变),无路径窗口 = 带 chrome 空窗口。树右键「在新 Tab 中打开」「在侧边打开」(pane 右侧 split)。新会话 seed 空文件窗口(`title:'Files'`);旧 `explorer` tab 经 `sanitizeState` 迁移 |
|
||||
| `git` | 20 | 是 | 是(本轮文件操作数) | 「文件变动」统一 tab(id 保留 `git` 以兼容持久化布局):**Git 视角**(原 Git 面板:staged/unstaged / 提交 / 历史 / worktree·子仓库选择)+ **本轮文件视角**(原 file-trace:模型读/写/编辑实时折叠,按文件分组、类型筛选);会话事件经插件自有宿主路由 `changes.ops` 供给(live 日志优先、冷会话回放持久化记录,`afterSeq` 增量),badge 读 tab 轮询写入的同步缓存。两视角共用底部可拖拽预览面板(`tab.meta.lens/previewH` 持久化),diff 渲染统一走 `src/client/diff/`(`DiffRows`/`DiffFiles`:mod 配对 + 行内高亮 + 语法着色 + 上下文折叠);Git 目标可展开为独立 diff tab——落点由二级设置 `changesDiffFloat` 决定(默认**自由浮窗**居中弹出,可选面板下半 split),设置项见 tab descriptor 的 `settings.toggles` select |
|
||||
| `git` | 20 | 是 | 是(本轮文件操作数) | 「文件变动」统一 tab(id 保留 `git` 以兼容持久化布局):**Git 视角**(原 Git 面板:staged/unstaged / 提交 / 历史 / worktree·子仓库选择)+ **本轮文件视角**(原 file-trace:模型读/写/编辑实时折叠,按文件分组、类型筛选);会话事件经插件自有宿主路由 `changes.ops` 供给(live 日志优先、冷会话回放持久化记录,`afterSeq` 增量),badge 读 tab 轮询写入的同步缓存。两视角共用底部可拖拽预览面板(`tab.meta.lens/previewH` 持久化),diff 渲染统一走 `src/client/diff/`(`DiffRows`/`DiffFiles`:mod 配对 + 行内高亮 + 语法着色 + 上下文折叠);Git 目标可展开为独立 diff tab(落进工作台的 diff 分栏) |
|
||||
| `subagent` | 30 | 是 | 否 | 子代理拓扑 |
|
||||
| `sidechat` | 35 | 否(`sidechat:<uuid>`,按 `meta.threadId` 去重) | 否 | 侧边对话(每对话一 Tab):打开即建空线程(首条消息赢得标签并同步标题);线程 = 插件自建子会话(种子继承父会话上下文,进行中回合以 `interrupted` 闭合;种子带合法 `subagent/descriptor`,SubagentView 按 `Side: ` 前缀过滤),`origin:'subagent'` 隐藏于主列表;走 `/sidebar/api/sidechat.*` 路由;头部菜单切换/重开(`parkSidechatReopen` + 确定性 id),关 Tab 释放 live agent;重开经 `collectOwnEvents` 回源到种子边界;「保存为新会话」= `session.fork`(`this` 敏感)。[设计文档](plans/2026-08-20-sidechat-tab-design.md) |
|
||||
| `terminal` | 40 | 否(`terminal:<n>`) | 否 | 终端。v0.17.0+ 右键「固定到工作区/全局」:跨会话不消失,TabBar 内联虚拟 Tab(`pinned:<homeSessionId>:<tabId>`),就地按 home scope 连 PTY;global 全会话可见、workspace 仅同 cwd;`tab.pin = { scope, homeCwd? }` 随会话持久化,渲染期解析(`collectPinnedTabs` → `createPinnedVirtualTab` → `injectPinnedIntoTree`) |
|
||||
@@ -549,10 +583,31 @@ interface BetterSidebarService {
|
||||
registerTab(descriptor: TabDescriptor): () => void
|
||||
/** 注册文件预览器;返回 disposer */
|
||||
registerFileViewer(descriptor: FileViewerDescriptor): () => void
|
||||
/** 注册自定义文件树/文件 tab 图标(v0.19.0+,features 含 'fileIcons');返回 disposer */
|
||||
registerFileIcon(descriptor: FileIconDescriptor): () => void
|
||||
/** 当前已注册的 tab 描述符快照(同步,供 useSyncExternalStore 用;含被设置页禁用的类型) */
|
||||
getTabs(): readonly TabDescriptor[]
|
||||
/** 当前已注册的 file viewer 描述符快照(含被设置页禁用的 viewer) */
|
||||
getFileViewers(): readonly FileViewerDescriptor[]
|
||||
/** 当前已注册的文件图标描述符快照(v0.19.0+) */
|
||||
getFileIcons(): readonly FileIconDescriptor[]
|
||||
/** 按 path 匹配**具体**注册(priority 降序、注册序):先 names 文件名,再
|
||||
* 具体扩展名;不查 catch-all 与 folder 保留值。消费方一般直接用
|
||||
* fileIcon/folderIcon 全链解析器。 */
|
||||
matchFileIcon(path: string): FileIconDescriptor | undefined
|
||||
/** 匹配目录行注册:先按 folderNames 匹配目录名(name 传 basename,可省),
|
||||
* 再按 'folder'/'folder-open' 保留扩展名;priority 降序、注册序;
|
||||
* 返回 undefined = 回退内置 VscFolder/VscFolderOpened */
|
||||
matchFolderIcon(open: boolean, name?: string): FileIconDescriptor | undefined
|
||||
/** 文件图标权威解析器(v0.19.0+),完整回退链:
|
||||
* ① 具体 names/扩展名注册 → ② 内置 glyph(md/媒体/pdf/json/代码/配置/数据库/lock/压缩包)
|
||||
* → ③ 最优 catch-all 注册(exts: [],即全局默认)→ ④ 通用 VscFile。
|
||||
* 任一注册工厂抛错都会被吞(console.error 后跳下一级),永远返回有效 ReactNode。 */
|
||||
fileIcon(path: string, size: number): ReactNode
|
||||
/** 目录图标解析器:注册的 folderNames/'folder'(闭合)/'folder-open'(展开)图标 →
|
||||
* 内置 VscFolder/VscFolderOpened;path 为目录自身路径(主题可按目录变化),
|
||||
* open 会传给工厂,一条注册即可渲染开/合两态。 */
|
||||
folderIcon(path: string, open: boolean, size: number): ReactNode
|
||||
/** 按 id 查 tab 描述符 */
|
||||
getTab(id: string): TabDescriptor | undefined
|
||||
/** 某个 tab 类型是否在 Side card 设置中启用(v0.4.1+;缺省 = 启用) */
|
||||
@@ -584,9 +639,10 @@ interface BetterSidebarService {
|
||||
// ── v0.12.0+ ──────────────────────────────────────────────────────────
|
||||
/** 插件版本(如 '0.17.1';与 package.json 同步,测试守护) */
|
||||
readonly version: string
|
||||
/** 单调能力清单(只增不删):'badge' | 'tabLifecycle' | 'updateTab' |
|
||||
* 'openFile' | 'targetedOpen' | 'stateSubscription' | 'tabMeta' |
|
||||
* 'pluginSettings' | 'urlTarget' | 'settingSelect' | 'floatWindows'
|
||||
/** 能力清单(只增不删,唯一例外:v0.19.0 删除了 'floatWindows'):
|
||||
* 'badge' | 'tabLifecycle' | 'updateTab' | 'openFile' | 'targetedOpen' |
|
||||
* 'stateSubscription' | 'tabMeta' | 'pluginSettings' | 'urlTarget' |
|
||||
* 'settingSelect' | 'fileIcons'
|
||||
* ——用 `features.includes('xxx')` 按能力 gate。 */
|
||||
readonly features: readonly string[]
|
||||
/** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。
|
||||
@@ -618,8 +674,99 @@ interface OpenTabSeed {
|
||||
* undefined = 不改,null = 显式清除 */
|
||||
meta?: unknown
|
||||
}
|
||||
|
||||
/** 文件图标注册描述符(v0.19.0+,features 含 'fileIcons')。 */
|
||||
interface FileIconDescriptor {
|
||||
/** 唯一 id(如 'my-plugin:icons') */
|
||||
id: string
|
||||
/** 小写扩展名、不带前导点(如 ['csv','tsv'])。两个**保留值**认领目录行
|
||||
* 而非文件扩展名:'folder'(闭合目录)、'folder-open'(展开目录)——
|
||||
* 它们不会匹配真实文件(名为 x.folder 的文件不受影响)。
|
||||
* [] = catch-all 全局默认:只兜内置 glyph 没认领的扩展名(注册的具体
|
||||
* names/扩展名与内置 glyph 永远优先于它)。
|
||||
* **省略** = 完全没有扩展名规则(只有 names 的注册不是 catch-all)。 */
|
||||
exts?: readonly string[]
|
||||
/** 精确**文件名**(basename,大小写不敏感,如 ['package.json','Dockerfile'])——
|
||||
* 图标主题的 fileNames 半边;命中优先于扩展名。省略/[] = 无文件名规则。 */
|
||||
names?: readonly string[]
|
||||
/** 精确**目录名**(basename,大小写不敏感,如 ['node_modules','src'])——
|
||||
* 图标主题的 folderNames 半边;命中优先于保留扩展名,且**只认领列出的
|
||||
* 目录**(要接管所有目录请用 'folder'/'folder-open')。省略/[] = 无规则。 */
|
||||
folderNames?: readonly string[]
|
||||
/** priority 高者胜,缺省 0(同级按注册先后) */
|
||||
priority?: number
|
||||
/** 尺寸感知的图标工厂(文件树/文件 tab 当前以 size=14 渲染)。
|
||||
* 与内置图标(currentColor 单色,遵循皮肤契约)不同,注册图标可以是
|
||||
* 任意 ReactNode——包括彩色图标;颜色在皮肤间的表现由注册方自行负责。
|
||||
* open:目录行的展开态(文件行为 undefined),一条注册即可渲染开/合两态。 */
|
||||
icon: (path: string, size: number, open?: boolean) => ReactNode
|
||||
}
|
||||
```
|
||||
|
||||
**图标注册示例**(v0.19.0+;一次注册可同时覆盖具体扩展名、目录与全局默认):
|
||||
|
||||
```ts
|
||||
if (ctx.betterSidebar.features.includes('fileIcons')) {
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerFileIcon({
|
||||
id: 'my-plugin:icons',
|
||||
exts: ['csv', 'tsv'],
|
||||
icon: (path, size) => <MyCsvIcon size={size} />, // 彩色也可以
|
||||
})
|
||||
)
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerFileIcon({
|
||||
id: 'my-plugin:names', // 精确文件名:package.json 与别的 .json 区分开
|
||||
names: ['package.json', 'Dockerfile'],
|
||||
icon: (path, size) => <MyBrandIcon size={size} />,
|
||||
})
|
||||
)
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerFileIcon({
|
||||
id: 'my-plugin:folders', // 只认领列出的目录名
|
||||
folderNames: ['node_modules', 'src'],
|
||||
icon: (path, size, open) => open === true ? <MyOpenFolderIcon size={size} /> : <MyFolderIcon size={size} />,
|
||||
})
|
||||
)
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerFileIcon({
|
||||
id: 'my-plugin:all-folders', // 接管所有目录行(保留扩展名)
|
||||
exts: ['folder', 'folder-open'],
|
||||
icon: (path, size, open) => open === true ? <MyOpenFolderIcon size={size} /> : <MyFolderIcon size={size} />,
|
||||
})
|
||||
)
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerFileIcon({
|
||||
id: 'my-plugin:default', // 全局默认:只兜内置 glyph 没认领的文件
|
||||
exts: [],
|
||||
icon: (path, size) => <MyGenericFileIcon size={size} />,
|
||||
})
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**消费表面与回退链**(由本插件内置消费,插件无需自己接线):
|
||||
|
||||
- 文件树文件行 / 编辑器文件 tab(每个文件独立窗口):`fileIcon(path, size)`
|
||||
——具体 `names`/扩展名注册 → catch-all 全局默认(`exts: []`)→ **DSH 官方图标**。
|
||||
- 文件树目录行(含根行):`folderIcon(path, open, size)`——`folderNames` 命中
|
||||
→ `'folder'`/`'folder-open'` 保留扩展名 → DSH 官方的文件夹图形。
|
||||
|
||||
注册/注销即时生效(文件树与 tab 栏订阅注册表变化自动重渲染);图标工厂抛错会被吞掉
|
||||
(console.error 后跳到回退链下一级),不会空白行。
|
||||
|
||||
**内置图标 = DSH 官方图形**(v0.19.0+,插件不含任何图标数据):
|
||||
|
||||
- 回退链末端是 `FileTypeIcon` / `CodeFileIcon`(`@deepseek-ai/dsh-client-ui-primitives`,
|
||||
DSH 0.1.5-rc.2+):48 个代码/配置类目的官方全彩图形 + markdown / 图片 / PDF / Word /
|
||||
Excel / PPT / 视频 / 文件夹 / 通用文档的类目色板图形,分类器是宿主的
|
||||
`classifyFileType`(精确文件名 → 前缀/后缀 → 项目上下文 → 扩展名)。本插件因此**没有**
|
||||
自己的扩展名表、**没有**图标 chunk、**没有**图标主题开关——彩色是唯一形态。
|
||||
- **语义后果(重要)**:宿主分类器覆盖任意路径,所以「插件自己已经能画这个扩展名」不再是
|
||||
拦住 catch-all 的理由——**注册了 `exts: []` 的插件会接管全部未具体命中的行**(优先级降序、
|
||||
同优先级按注册序)。只想补几个扩展名就照常用 `exts`/`names`,别用 catch-all 兜底。
|
||||
- 插件自己注册的图标颜色由注册方负责(见 §12):品牌色是内容标识而非 chrome。
|
||||
|
||||
**版本与能力探测**(v0.12.0+):消费插件先查能力再使用新 API,老版本(或旧 DSH)下优雅降级:
|
||||
|
||||
```ts
|
||||
@@ -719,44 +866,49 @@ ctx.effect(() =>
|
||||
| 陷阱 | 说明 |
|
||||
|---|---|
|
||||
| **构建纯度门** | client bundle 禁止 value-import `@dsh-external/*` 或非白名单的 `@deepseek-ai/*`(`tsdown.config.ts` 的 `dsh-client-bundle-purity` 插件);**类型 `import type {}` 会被擦除,不触发门禁**——类型可自由共享,运行时符号不行。所有跨插件交互走 `ctx.betterSidebar` 方法调用 |
|
||||
| **统一 cordis 分支(v0.15.2+)** | 类型基底与 augmentation 全部在 DSH 运行时正主 `@deepseek-ai/cordis` 上(`src/context-types.ts`:真实 cordis `Context` 与结构化服务面做**交集**,不再重述 `declare module 'cordis'`——host/client 包对同名成员声明不同会 TS2717)。消费者 `import type {} from 'dsh-better-sidebar'` 即拿到 `ctx.betterSidebar`,或直接 `import type { Context } from 'dsh-better-sidebar'`。公开版 `cordis` 不再被依赖 |
|
||||
| **统一 cordis 分支(v0.15.2+)** | 类型基底与 augmentation 全部在 DSH 运行时正主 `@deepseek-ai/cordis` 上(`src/context-types.ts`:真实 cordis `Context` 与结构化服务面做**交集**,不再重述 `declare module 'cordis'`——host/client 包对同名成员声明不同会 TS2717)。消费者 `import type {} from 'dsh-apes-sidebar'` 即拿到 `ctx.betterSidebar`,或直接 `import type { Context } from 'dsh-apes-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 环境) |
|
||||
| **不要 value-import `dsh-apes-sidebar`** | 即使是 `./client/service` 子路径,运行时落点也是 client bundle;只做 type-only import |
|
||||
| **client 声明图零 Node 依赖(v0.12.0+)** | `dsh-apes-sidebar/client/service` 的可达声明面(含 `Context`)不引用 `node:*` / `Buffer`——纯浏览器侧插件无需 `@types/node`,`skipLibCheck: false` 也能编译(`scripts/check-consumer-types.sh` 守护;主入口含宿主声明,宿主消费者本就处于 Node 环境) |
|
||||
| **家族右面板互斥(v0.13.0+)** | `aionui-panel` 设置的 `rightPanel` 解析为 `'aionui-panel'` 时整个侧边栏不挂载(`settings.get` 返回 `externalDisable: true`,挂载门 + 接管停用;`settings/document-updated` 推送实时生效,无 `remote` 服务回退启动时判定)。未安装 aionui 不受影响 |
|
||||
| **i18n 跟随** | 文案跟随 DSH `ctx.locale`(词典在 `betterSidebar` 命名空间;Host-backed `locale.preference` 优先于浏览器语言并实时切换;缺失回退浏览器)。消费插件**不要**依赖内部 `t()`——标题传字符串或 `() => string` |
|
||||
| **第三语言覆盖(ja 等)** | 可选 peer `@huanlin/dsh-plugin-better-locale`(optional)提供 ja/ko 覆盖,**借用 DSH 英文槽位**(仅 DSH=en 时生效,zh 下惰性)。经 `ctx.get('betterLocale')` 注入 `t()`;未安装整段 no-op |
|
||||
| **懒加载 chunk** | 重依赖(xterm/CodeMirror)在独立 bundle(`lib/client-<name>.js`),经 `/sidebar/bundle` 按需下发;factory 赋到 `globalThis.__dshChunks__[<name>]`,由 `src/client/chunk-loader.ts` 物化,**不经** `__ModuleLoader__`。对消费插件透明 |
|
||||
| **聊天文件打开漏斗(alpha 宿主)** | 聊天里一切文件打开(工具行 / 产物行 / 正文提及 / 行内代码路径)汇入 `ctx.remote.session.openWorkspacePath`(Typert remote 命名空间,cordis 服务 key `remote.session`,方法为 **accessor 属性**、异步挂载)。better-sidebar 的「聊天区文件在侧边栏打开」即在 `ctx.inject(['remote.session'], …)` 内以 defineProperty 遮蔽该方法(`src/client/openpath-intercept.ts`)。你的插件若要观测/旁路聊天文件打开,走同一服务;不要假设 pre-alpha 的 `ctx.workspaces.openPath` 存在(alpha 的 `IWorkspaces` 已无此方法) |
|
||||
| **聊天文件打开漏斗(0.1.5 宿主)** | 聊天里一切文件打开(工具行 / 产物行 / 正文提及 / 行内代码路径)统一走 DSH 原生右侧栏的 `ctx.sidebarRight.openResource(fileAddressFor(sessionId, cwd, path))`(`packages/client/ui-chat/src/client/apply.ts` 是唯一调用点);`remote.session.openWorkspacePath` 在 0.1.5 客户端**已无调用者**,better-sidebar 的 openpath 拦截随之删除。你的插件若要观测/旁路聊天文件打开,注册原生 tab 类型认领 `dsh-resource://file/**`(见 [AGENTS.md §3](AGENTS.md) 第 10 条);不要假设 `ctx.workspaces.openPath`(已删除)或旧 `remote.session` 漏斗存在 |
|
||||
| **原生 tab 体的高度契约** | 你的 tab 组件被挂在**全高列 flex 宿主**里:native 适配层给每个 tab 体(含 orphaned 回退)包一层 `height:100%` 的 `sidebar.module.css` `.nativeTabHost`(带 `data-dsh-native-tab-host`)——根用 `flex: 1` / `height: 100%` 并加 `min-height: 0` 填满面板。宿主的面板体 `.paneBody`(`dsh-client-ui-dockkit`)是**有确定高度的块级滚动容器,不是 flex 容器**,因此只靠外层 flex 容器支撑的根会塌成内容高度(sidechat 的输入框就贴不到面板底);DSH 自己的 tab 体(`ui-sidebar-documentpreview` 的文档预览、`ui-sidebar-files` 的 FilesBody)正是因此在根上声明 `height: 100%` |
|
||||
|
||||
---
|
||||
|
||||
## 11. 自由窗口(v0.16.0,`features` 含 `'floatWindows'`)
|
||||
## 11. 自由窗口(v0.16.0 – v0.18.x,**v0.19.0 已移除**)
|
||||
|
||||
任意 tab(含你注册的)可拖出侧边栏成为悬浮**自由窗口**——对你的组件基本透明,只需知道以下语义:
|
||||
自由窗口(tab 拖出侧边栏成为可移动/缩放的悬浮窗,`SidebarState.floats`、`floatTab`/`dockFloat`/`raiseFloat`、`features` 里的 `'floatWindows'`、右键「移动到自由窗口」、`src/client/FreeWindow.tsx`)在 **v0.19.0-alpha.0 整体删除**:右列交给 DSH 原生右侧栏后,插件只剩底部工作台,浮窗既没有语义归属也没有可用手势(拖出目标区域现在是原生栏的属地)。
|
||||
|
||||
- **拖出**:tab 拖到主会话区域(conversation 列)松开即浮动(`floatTab`:移入 `SidebarState.floats`,默认 390×780 按视口钳制居中,清空 pane 折叠)。检测在 `Sidebar.tsx`(`body[data-dsh-tab-dragging]` 门控);窄视口禁用。右键「移动到自由窗口」始终可用。
|
||||
- **窗口操作**(`src/client/FreeWindow.tsx`,`[data-dsh-panel-host]` 内、z-42):头部拖动移动;拖到 pane(`[data-dsh-pane]`)松开**停靠**(`dockFloat`);右下角缩放(最小 320×200);点击置顶(层叠 = `floats` 数组序);X = `closeTab`(触发 `onClose`、释放终端)。
|
||||
- **持久化**:`floats` 随会话进 localStorage(`dsh-sidebar:v1:<sessionId>`);`sanitizeState` 宽容校验(非法条目单独丢弃、几何钳入视口、diff/ephemeral 不持久化)。
|
||||
- **服务语义**:`openTab` 聚焦命中浮动 tab = 置顶窗口(不重复开);`closeTab`/`activateTab` 关窗/置顶并触发回调;agent 终端 reconcile 移除已消失的浮动窗口。**浮窗内你的组件 `visible` 恒 true**。
|
||||
- **稳定寻址面**:`[data-dsh-float-window]` / `[data-dsh-float-id]` / `[data-dsh-pane]` / `[data-dsh-float-dock-over]` / `[class*='floatDropHint']`;全令牌驱动,头部 `-webkit-app-region: no-drag`。
|
||||
- **⚠️ portal 事件劫持陷阱**:拖拽表面子树含 portal 覆盖层时(如你的组件在 tab 头部区域渲染弹层),portal 后代合成事件**沿 React 树冒泡**回 `onPointerDown`,会被误判为拖拽开始(吞点击 + 抢 pointer,菜单/X 失灵;jsdom 不走此路径,只有 e2e 能抓)。拖拽起点必须带**同源守卫**:`event.currentTarget.contains(event.target)` 为假或目标在 `button` 内时直接返回(回归:`tests/free-window.spec.tsx` portaled-menu 用例)。
|
||||
- 升级到 v0.19.x 后请**不要**再 gate `features.includes('floatWindows')`,也不要引用 `floatTab` 等符号(类型声明里已不存在);
|
||||
- 旧文档里持久化的 `floats` 字段会被 `sanitizeState` 直接忽略,会话不会因此加载失败;
|
||||
- 需要「同一个 tab 在别处看」的场景,改用原生栏打开(`openTab` 默认落点)或底部工作台分栏(`OpenTabSeed.target: 'bottom'`)。
|
||||
|
||||
---
|
||||
|
||||
## 12. 皮肤兼容(令牌驱动)
|
||||
|
||||
> better-sidebar 所有视觉值消费 DSH 的 `--dsw-alias-*` / `--dsw-font-*` / `--ds-*` 令牌(无硬编码颜色),**不做每皮肤适配**。已与 dsh-web-ui 皮肤中心兼容(10 款皮肤全覆盖 `--dsw-alias-*` 层;`tests/theme.spec.ts` 守护)。你的 tab/viewer 组件遵循同样的令牌规则即可自动兼容全部皮肤。
|
||||
>
|
||||
> **没有任何豁免面**:文件与文件夹图标是 **DSH 官方的 `FileTypeIcon` 图形**(宿主自己
|
||||
> 的调色板,插件不画像素也不存数据),插件画的每个 glyph(含内置 tab 的彩色图标)颜色
|
||||
> 都来自 `--dsw-alias-*`——`tests/theme.spec.ts` 同时守护「图标模块零颜色字面量」与
|
||||
> 「样式表里每条 `color` 都解析到令牌」。插件自己注册的图标(`registerFileIcon`)颜色由
|
||||
> 注册方负责,不受本节令牌约束,但**不要**把彩色图标数据塞进核心 bundle 的常驻渲染路径
|
||||
> (要按需加载就照 `src/client/chunk-loader.ts` 的懒加载 chunk 走)。
|
||||
|
||||
### 12.1 规则
|
||||
|
||||
- **面板表面**:右/底面板背景 = `var(--dsw-alias-bg-layer-1)`。**绝不消费 `--dsw-specific-sidebar-fill`**(宿主左导航专属,皮肤按左导航语义覆盖它,面板消费会失去填充)。换面板表面 = 覆写 `--dsw-alias-bg-layer-1`。
|
||||
- **终端/编辑器表面**:`effectiveTokenValue` 读 `--dsw-alias-bg-base`——`transparent` 与 alpha < 0.9 的半透明值回退不透明底色(文字不叠背景画,issue #90);≥ 0.9 放行。
|
||||
- **根锚点**:宿主 div 带 `data-dsh-better-sidebar`(append 到 body);其内**面板宿主层** `[data-dsh-panel-host]`(`fixed; inset:0; z-25; pointer-events:none; overflow:hidden+clip`,v0.13.1+),面板/开关簇 absolute 定位,免疫中间层 transform 劫持;页面级 transform 触发 `data-dsh-panel-host-degraded` 降级。`overflow` 级联是**契约**(`hidden` 兜底 + `clip` 收尾,`tests/panel-host-css.spec.ts` 守护):`hidden` 盒子仍是滚动容器,脚本滚动或浏览器 scroll-into-view 修正(焦点移入视口外区域、嵌套 iframe/工作台加载时抢焦点、面板滑出动画中 focus() 落点)会沿最近可滚祖先滚走整层——面板与开关簇集体偏离视口角(computed left/right 仍"正确",偏移藏在盒子自身 scroll offset 里);`clip` 裁剪语义相同但不产生滚动盒,任何路径都滚不动这层。皮肤作用域覆盖限定在 `[data-dsh-better-sidebar]` 内。
|
||||
- **布局变量**(`<html>` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`。右面板宽度 = AppFrame 的 `padding-right` 预留(新版 `#root [data-dsh-frame]` / rc.8 `#root > [data-slot="root"] > div` 双锚点),AppFrame border box 保持完整桌面视口宽度(Harness 以此判定桌面/窄屏布局,避免插件面板展开误入窄屏);AppFrame 的 details 拖拽手柄按同一变量向左平移贴合列边缘。底部面板仍走 centerCol `margin-bottom`;centerCol 锚点 = **JS 标注**(禁止 `nth-child`):侧栏 shell 的定位器给测得的 centerCol 节点打 `[data-dsh-center-col]` 标签(`Sidebar.tsx` locate,节点更换/HMR 时随 ref 迁移),`layout.css` 用 `#root [data-dsh-center-col]` 选中(`drag-layout.e2e.ts` 断言恰一节点且为 `[data-slot="conversation"]` 的父级;frame 宽度与桌面 Session Log 由 `desktop-layout.e2e.ts` 断言)。
|
||||
- **根锚点**:宿主 div 带 `data-dsh-apes-sidebar`(append 到 body);其内**面板宿主层** `[data-dsh-panel-host]`(`fixed; inset:0; z-25; pointer-events:none; overflow:hidden+clip`,v0.13.1+),面板/开关簇 absolute 定位,免疫中间层 transform 劫持;页面级 transform 触发 `data-dsh-panel-host-degraded` 降级。`overflow` 级联是**契约**(`hidden` 兜底 + `clip` 收尾,`tests/panel-host-css.spec.ts` 守护):`hidden` 盒子仍是滚动容器,脚本滚动或浏览器 scroll-into-view 修正(焦点移入视口外区域、嵌套 iframe/工作台加载时抢焦点、面板滑出动画中 focus() 落点)会沿最近可滚祖先滚走整层——面板与开关簇集体偏离视口角(computed left/right 仍"正确",偏移藏在盒子自身 scroll offset 里);`clip` 裁剪语义相同但不产生滚动盒,任何路径都滚不动这层。皮肤作用域覆盖限定在 `[data-dsh-apes-sidebar]` 内。
|
||||
- **布局变量**(`<html>` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`。右面板宽度 = AppFrame 的 `padding-right` 预留(新版 `#root [data-dsh-frame]` / rc.8 `#root > [data-slot="root"] > div` 双锚点),AppFrame border box 保持完整桌面视口宽度(Harness 以此判定桌面/窄屏布局,避免插件面板展开误入窄屏);AppFrame 的 details 拖拽手柄按同一变量向左平移贴合列边缘。底部面板仍走 centerCol `margin-bottom`;centerCol 锚点 = **JS 标注**(禁止 `nth-child`):侧栏 shell 的定位器给测得的 centerCol 节点打 `[data-dsh-center-col]` 标签(`Sidebar.tsx` locate,节点更换/HMR 时随 ref 迁移),`layout.css` 用 `#root [data-dsh-center-col]` 选中(`drag-layout.e2e.ts` 断言恰一节点且为对话槽宿主的父级——alpha.2 起 shell 把 `#root [data-slot="main.conversation"]` 解析进列并跳过 `display: contents` 祖先(`center-column.ts` 的 `CENTER_COLUMN_SELECTOR` 同时认 `main.conversation` 与 alpha.1 的 `conversation`),定位器从槽宿主向上取第一个非 `contents` 的祖先;frame 宽度与桌面 Session Log 由 `desktop-layout.e2e.ts` 断言)。
|
||||
- **桌面信号与标题栏**(v0.14.1+ 四方案模型 `SidebarPrefs.titleBarScheme`,唯一决策点 `src/client/titlebar-strip.ts` 纯函数):
|
||||
- 壳信号(只读,不自动触发修改):URL `dsh-desktop-mode` / `dsh-desktop-platform` / 可选 `dsh-desktop-titlebar-inset`(0–120 clamp)。
|
||||
- **strip 取值链**:⓪ `web` 方案强制 0;① `navigator.windowControlsOverlay` 真实几何(`wco.ts` 订阅 `geometrychange`,**为 0 也权威**,`visible=false` 幽灵 API 视为缺失);② URL inset;③ 壳预设 `stripFor`(仅 `preset`);④ 手动 `titleBarStripPx`(仅 `custom`);⑤ 0。驱动 `body[data-dsh-title-bar-compat]` + `--dsh-title-bar-strip`。
|
||||
@@ -768,7 +920,7 @@ ctx.effect(() =>
|
||||
|
||||
### 12.2 注意事项
|
||||
|
||||
- 类名是 CSS Modules 哈希,**不是契约**;精确命中用 `[data-dsh-better-sidebar]` + 子串类名(`[class*='panel']`)或 DOM 结构。
|
||||
- 类名是 CSS Modules 哈希,**不是契约**;精确命中用 `[data-dsh-apes-sidebar]` + 子串类名(`[class*='panel']`)或 DOM 结构。
|
||||
- 改动本契约必须同步本文档、设计文档与 `tests/theme.spec.ts`。
|
||||
|
||||
---
|
||||
@@ -790,11 +942,11 @@ ctx.effect(() =>
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "^4.0.1",
|
||||
"dsh-better-sidebar": "workspace:*",
|
||||
"dsh-apes-sidebar": "workspace:*",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"dsh-better-sidebar": { "optional": true }
|
||||
"dsh-apes-sidebar": { "optional": true }
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -803,7 +955,7 @@ ctx.effect(() =>
|
||||
|
||||
```tsx
|
||||
import { createElement } from 'react'
|
||||
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
|
||||
import type {} from 'dsh-apes-sidebar' // 触发 ctx.betterSidebar 类型合并
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
|
||||
export const inject = ['betterSidebar']
|
||||
@@ -861,8 +1013,9 @@ better-sidebar 的内置 tab 和 viewer 就是参考实现("吃狗粮"),
|
||||
|
||||
- **`src/client/builtins/`**:7 个内置 tab(tabs.tsx)+ 6 个内置 viewer(viewers.tsx)的注册代码 + 聚合与 disposer 生命周期(index.ts);Office 预览见 plugins-viewers.ts
|
||||
- **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现(含匹配算法、dedupe、createTab、启用态 gating)
|
||||
- **`src/client/Sidebar.tsx`**:`TabContent` 分发(查 `getTab` → 调 descriptor.component;未注册 → `<OrphanedTab/>`)、`+` 菜单构建(order 排序 + available disabled + 禁用过滤)、自由窗口拖拽检测
|
||||
- **`src/client/FreeWindow.tsx`**:自由窗口(移动/停靠/缩放/置顶,§11)
|
||||
- **`src/client/Sidebar.tsx`**:底部工作台外壳 + `TabContent` 分发(查 `getTab` → 调 descriptor.component;未注册 → `<OrphanedTab/>`)、`+` 菜单构建(order 排序 + available disabled + 禁用过滤)
|
||||
- **`src/client/native/`**:原生右侧栏接入(tab 类型注册、合成 `SidebarTab` 适配、资源地址、跨会话打开排队)
|
||||
- **`src/client/sidebar/bottom-toggle.tsx`**:底部工作台开合按钮(注册进 DSH 会话头 utilities 槽)
|
||||
- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + 嵌套设置行 + 开关持久化)
|
||||
- **`src/client/api.ts`**:`/sidebar` API 的封装(复制其 fetch 模式到你的插件)
|
||||
- **`src/client/plugins-tabs.ts`** / **`plugins-viewers.ts`**:推荐插件目录(「添加插件」弹窗数据源;加一条数据即上架,`tests/plugin-list.spec.ts` 守护)
|
||||
@@ -880,7 +1033,7 @@ better-sidebar 的内置 tab 和 viewer 就是参考实现("吃狗粮"),
|
||||
|
||||
- **接入方式**:可选软依赖——client half 本地重述最小服务契约(`registerTab`),未安装 better-sidebar 时注册静默跳过,插件原有表面不受影响;
|
||||
- **注册内容**:`dsh-sentinel:watches` tab(order 60,单实例):全服务器监控表 + 最近触发历史;
|
||||
- **类型处理**:未 value-import `dsh-better-sidebar`,构建零耦合;与 §2 的 `import type {}` 方案可互换;
|
||||
- **类型处理**:未 value-import `dsh-apes-sidebar`,构建零耦合;与 §2 的 `import type {}` 方案可互换;
|
||||
- **实测**:v0.3.0 起,真实 web profile 验证通过。
|
||||
|
||||
通过 `ctx.betterSidebar` 的三方插件 [dsh-sidebar-qa](https://github.com/ChenRuoT/dsh-sidebar-qa) —— 基于 better-sidebar 的划选提问:对话划选 → 右侧面板提问 → 同工作区独立追问会话(❓追问·主题);快速无思考模型压缩主对话上下文后与引文一起注入,不打断主对话;追问可嵌套、可继续、可归档。
|
||||
|
||||
@@ -96,3 +96,5 @@
|
||||
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 的标题前缀过滤兜底隐藏。
|
||||
- **转录传输迁至自有路由(DSH 0.1.2-alpha.1 适配)**:§6 原设计的时间线轮询走通用 `connection.api.sessions.history` RPC——该客户端面(`ctx.connection.api`)在 0.1.2-alpha.1 的 Remote-gateway 迁移中被整体移除,且其继任者(`session/follow`/`session/page`)对 `origin:'subagent'` 会话强制 subagent 地址寻址(普通 `{kind:'session'}` 地址被 `agent-busy` 拒绝),纯分页也拿不到最新尾页(`throughSeq` 不得超当前游标)。修复 = 新增 **`sidechat.events`** 路由(`src/sidechat-routes.ts`):host 侧读线程日志(live 走 `agent.session.events`,冷线程走 `ctx.get('sessionPersistence').inspect`——两版 DSH 同形,alpha.1 的 inspect 返回展开后的逻辑日志而非磁盘上的打包 chunk-row),服务端切 `session/end-seed`(`threadOwnLogEvents`,`collectOwnEvents` 的 walk 语义下沉),`afterSeq` 增量 + 8000 事件尾部帽(对齐旧 walk 上限 40×200)。客户端 `fetchThread` 单路径化,删除 `collectOwnEvents` 与 `ctx.connection` 镜像/inject。种子切割仍由 `transcriptRows` 客户端二次防御。挂载冒烟补 live+冷读两条断言。
|
||||
- **种子缺 fork 标记 → 子会话继承父会话未领取的 inbox 消息(2026-09-05,用户实测「上下文很长时侧边对话会把之前的 User msg 先发送出去」)**:`sidechat.start` 原只传 `seed` 不传 `meta.isSeeded: true` + `inheritedEventCount`。dsh-session 契约:**只给 seed 不标 isSeeded,种子算重放历史而非继承前缀** → `Session.ownEvents()` 含整个种子 → 子会话 `Inbox` 构造器(重放 `ownEvents()` 中的 `agent/inbox/spliced`)继承父会话切割时刻**任何未领取的 inbox 输入**。两个触发窗口(均以真实 0.1.2-rc.1 包 `Session.create` + `Inbox` 实验证实):① next-turn——父会话排队中的用户消息(长回合运行中补发);② **next-step(用户实测场景:父会话运行中、用户队列已清空)**——长回合里工具结果上下文/steering/运行时快照在步骤边界之间插入 next-step、尚未领取;上下文越长回合越久窗口越宽。后果:首条 `sidechat.prompt` 的 claim 顺序为 `[幽灵消息, boundary, question]`——幽灵消息排在 boundary 之前最先发给模型(工具上下文常为「工具完成,接下来做 X」,模型先继续父会话任务),并在子会话日志落成 `user/message`。修复 = create 补 `meta.isSeeded: true` + `inheritedEventCount: SessionLogOffset(seed.length)`(宿主自己的 `session.fork`——api-session-controller——的规范调用形态;`createAgent` 工厂逐字段透传 `sessions.prepare`)。`threadOwnLogEvents` 按 end-seed 标记切割,标记位置不变,转录行为不变;冷恢复按持久化 header 切割,一致。回归测试进 `tests/sidechat-seed-validation.spec.ts`(真实 Session + Inbox 双向锁定:旧形态继承幽灵 / 新形态两队列皆空)。用户拍板:不迁移既有线程、不加 inbox.clear() 兜底(修复后新线程为无操作;旧「从未发过消息」空线程首次使用可能仍先答一次旧消息,之后自愈)。
|
||||
- **「更优雅 API」复评(2026-09-05,0.1.2-rc.1 = npm latest)**:无 sidechat/sidechain 宿主 API;`ContinuableStartSpec`(`ctx.subagents.startContinuable`)仍无 seed 字段;`session/create` RPC 只建全新会话。§3 的自建结论继续成立,`AgentRegistry.create` 接缝即宿主 fork 自用的公开接缝——补齐 fork 标记即为规范用法。
|
||||
|
||||
@@ -89,6 +89,8 @@ Cursor (SSH)
|
||||
|
||||
**实施偏差(2026-09,#517 / PR #522)**:SSH 远程编辑器链接**不再经宿主路由执行**。DSH 部署在无头远端服务器时,宿主侧 `xdg-open` 无 DISPLAY/无编辑器,`vscode://` 静默失败。`api.openExternal`(`src/client/api.ts`)现把 `<scheme>://vscode-remote/ssh-remote+…` 形态的 URL 在浏览器客户端同步触发 `window.location.assign`(处于用户点击链内,外部协议交给本机编辑器经 Remote-SSH 打开远端文件);reveal 与本地编辑器 URL 仍走本节宿主路由。普通浏览器可处理自定义协议;禁止/未处理 `vscode://` 的 WebView 壳客户端需各自适配(见 #517 补充信息)。
|
||||
|
||||
**实施偏差(2026-09,右键菜单减重与子菜单视口钳制)**:本菜单(及 TabBar / GitLens / FreeWindow 右键菜单、「+」菜单)整体切到 primitives `Menu` 的 **`compact` 密度**(行高 26px / 12px 字 / 164px 卡片 / 7px 圆角),菜单图标随槽位 16→14、子菜单图钉 20→16。同时发现宿主子菜单**不受视口钳制**(`bottom:-4px` 从父行向上生长、`left:calc(100%+10px)` 向右展开,主列表的钳制与 `max-height` 收缩均不覆盖它):树顶部右键时本子菜单伸出视口上沿,窄面板(280–400px)时伸出右沿。修复为插件侧方案 `src/client/menu-flip.ts`——菜单打开期间在 `<body>` 发布 `data-dsh-sidebar-submenu="down|left"` 方向 token(`y < vh/2` → down;`x + 400 > vw` → left,400 镜像宿主几何 218 卡片 + 10 间隙 + 165 子菜单 + 12 边距),`layout.css` 据此翻转子菜单生长方向与 `::before` 悬停桥;**属性的存续期即作用域**(仅我们的菜单打开时存在),宿主自有菜单不受影响。**不做** `max-height` + 滚动钳制:overflow 裁剪会切掉子菜单 `::before` 悬停桥(跨 10px 间隙会触发 mouseLeave 关卡)。残留见「已知限制」末两条。
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 路径含 `#`/`?` 的文件名经 URL 打开可能被浏览器当作 fragment/query(不处理,注释说明)。
|
||||
@@ -96,13 +98,18 @@ Cursor (SSH)
|
||||
- 自定义编辑器仅支持 URL 模板式,不支持 CLI 命令式;无 `{dir}` 占位符。
|
||||
- Linux reveal 退化为打开所在目录(精确 select 需要各文件管理器私有协议)。
|
||||
- SSH 模式为**全局**(非每会话):DSH 无远程会话概念,文件树路径即宿主路径;该模型对应"DSH 跑在远端开发机上"的场景。
|
||||
- 子菜单钳制为方向翻转而非高度裁剪:约 >15 个打开方式目标 + 矮视口 + 不利光标位仍可能溢出(compact 密度已把常规 4–8 目标场景收进安全区)。
|
||||
- 翻转阈值镜像宿主 `Menu.module.css` 几何常量(218/10/165/12):宿主未来改尺寸时阈值偏保守(多翻一次方向),不会产生新越界;菜单打开期间窗口 resize 不重算 token,该次打开最坏退化为原始行为。
|
||||
|
||||
## 测试
|
||||
|
||||
- `tests/open-with.spec.ts`:解析容错、目标解析与 SSH 过滤、URL 构建(本地/SSH/custom/坏模板)、校验器。
|
||||
- `tests/open-external.spec.ts`:三平台命令表、URL 校验、spawn 前校验。
|
||||
- `tests/open-external-client.spec.ts`(#522):SSH remote URL 客户端分流(不触 fetch)、本地/reveal/http(s) 留宿主、导航抛错 reject。
|
||||
- `tests/file-tree-open-with.spec.tsx`(jsdom):右键 → 子菜单/图钉;pin 不选中不关闭;选子项回调关闭菜单;SSH 标签后缀;未接线隐藏。
|
||||
- `tests/file-tree-open-with.spec.tsx`(jsdom):右键 → 子菜单/图钉;pin 不选中不关闭;选子项回调关闭菜单;SSH 标签后缀;未接线隐藏;翻转向量(down / down left / 卸载清理)。
|
||||
- `tests/menu-flip.spec.tsx`:方向 token 纯函数(四象限、边界、窄面板)+ hook 属性生命周期 + layout.css 选择器与属性名的跨文件一致性。
|
||||
- `tests/tab-bar-context-menu.spec.tsx`:追加翻转向量(顶部条 down、右缘 down left、下半空 token、卸载清理)。
|
||||
- `tests/e2e/mount.e2e.ts`:「+」菜单首行 computed 高度 ≤30px(compact 密度的布局级证据)。
|
||||
- `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`。
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# 2026-09-03 全方位代码防腐 / 质量优化审查报告
|
||||
|
||||
> 三路探索子代理对 src / tests / scripts / CI / 文档做全方位摸底后,经用户批准按「P0 速修 + P1 行为不变去重 + P2 深度项全做」执行,共 10 个变更流、11 个 PR,全部走分支 + PR + 三 job CI 门禁合入。本文记录审查结论、落地清单、过程中的重要发现与遗留 backlog。
|
||||
|
||||
## 1. 审查结论总览
|
||||
|
||||
底子健康,无大面积腐化:
|
||||
|
||||
- 类型纪律严格:全仓 0 `as any`、0 `@ts-ignore`、0 TODO/FIXME。
|
||||
- 模块边界零违规:懒加载 chunk 隔离、client externals 白名单、tsdown purity gate 全部干净。
|
||||
- 错误 envelope 一致:宿主侧 `wire.ts` / 客户端侧 `api.ts` 双层归一。
|
||||
- 测试规模:118 个 vitest spec(约 3300 断言)+ 6 条 Playwright e2e lane。
|
||||
|
||||
压力点集中在:少数巨型组件、若干字面重复、脚本间复制粘贴、以及缺 lint / task-runner 基础设施。
|
||||
|
||||
## 2. 已落地的变更流
|
||||
|
||||
| PR | 主题 | 要点 |
|
||||
|---|---|---|
|
||||
| #524 | feat/test-utils | 共享 `tests/test-utils.ts`(`setupReactAct` + `renderRoot`),迁移 28 个 spec 的重复样板;`chunk-artifact` / `manifest-consistency` 加 lib 缺失 skipIf 守卫 |
|
||||
| #525 | feat/dedup-core | api envelope 解析、`extOf` 双实现、lineage-walk 四份、GitLens 错误归一化六处收敛;删零引用死代码 `sidechatSeed`;console 分级统一。测试零改动 |
|
||||
| #526 | feat/dev-commands | Makefile(11 目标薄封装,`check` 聚合对齐 CI);package.json 补 `test:mount:aggregate` / `clean` / `packageManager: pnpm@11.8.0`;release.yml 补 consumer-types 并统一步骤序;.editorconfig;双语 README 补文档 |
|
||||
| #527 | fix/scripts-hardening | aggregate 脚本 tarball 改 mtime 选取(修真 bug);DSH_CMD 默认值统一;package-registry.mjs 补 mermaid chunk;抽 `scripts/e2e-common.sh` 消除两 e2e 脚本约 40% 重复(函数级等价性探针 + 双 lane 真机验证) |
|
||||
| #534 | feat/palette-single-source | One Dark/Light 语法色板单源化(`one-dark-palette.ts`),迁移前后映射机器证明逐字节相等 |
|
||||
| #535 | feat/i18n-catalog-names | 目录项 name / shell 预设 desc 词典化(20 key × 21 词典文件,`string | (() => string)` 镜像 desc 模式);兜底错误条改 `var(--dsw-alias-*, 原hex)` 皮肤令牌链 |
|
||||
| #536 | feat/eslint | ESLint 10 flat config(typescript-eslint recommended + 经典 react-hooks 规则),存量 141 错误/21 警告清零(含 24 处真实死代码删除、`_` 前缀 mock 约定入 config);`pnpm lint` / `make lint` / CI 三 job 全部接入;`make check` 同步含 lint |
|
||||
| #540 | feat/drop-rc7-fallback | 删 rc.7 宿主 `window.__DSH_MODULES__` 回退(peer 基线 rc.1 下不可达);测试 fixture 切换到与生产同路径;附带修复 main 上 #535/#536 语义合并冲突导致的 add-plugin-modal spec 断裂 |
|
||||
| #541 | feat/unify-polling | `use-polling.ts` 原语(83 行)统一四处轮询习语(SubagentView 自排程 3s / SideChatView fixed 2s / GitLens fixed 2s / ChangesTab fixed 2.5s),守护 spec 断言零改动 |
|
||||
| #542 | feat/split-sidebar | Sidebar.tsx 1831 → 1176 行,抽 `src/client/sidebar/` 五模块(TabContent 74 / use-center-column 171 / use-host-feeds 269 / use-pinned-tabs 153 / free-windows 167);面板拖拽机约 330 行因 `sidebar-layout-push.spec` 的源码文本计数断言钉住而有意保留主文件 |
|
||||
|
||||
过程中同步发生的外部事件:DSH 0.1.2-rc.1 适配(PR #537)合入后,Wave 2 三个 PR 全部 rebase 到新 main 并重跑门禁再合并。
|
||||
|
||||
## 3. 过程中的重要发现(记录在案)
|
||||
|
||||
### 3.1 Windows agent-pty PTY 超时 flaky(既有,非本轮引入)
|
||||
|
||||
`tests/agent-pty.spec.ts` 的真实 PTY spawn 用例在 ci-windows 上间歇性 5s 超时。证据链:main@`ceed6b2`(Wave 1 首个合并的 push run)挂 agent-pty ×4 + git.spec ×1;#535、#536、#541 的 ci-windows 各挂过 1-2 个同类用例,**重跑均绿**;失败组合在多次运行间漂移。建议后续对该 describe 块在 win32 上提升 testTimeout 或加有界重试。
|
||||
|
||||
### 3.2 desktop-layout.e2e.ts 间歇 flaky(上游时序敏感)
|
||||
|
||||
该用例测「右面板保持桌面 session 头部位置」,区域与上游适配提交 `ab6f960`(toggle cluster 重定位动画)重叠。本机(钉版 rc.1 CLI)稳定因宿主 onboarding 浮层 / workspace 选择对话框不可达而失败,且 main 原样产物 A/B 对照同样失败;CI 上 aa01140 的 push run 也挂过一次(同内容分支 run 与后续 main 验证 run 均绿)。属宿主时序敏感的既有 flaky,建议上游动画稳定后加等待收敛。
|
||||
|
||||
### 3.3 跨分支语义合并冲突的流程教训
|
||||
|
||||
#535 新增了 `builtinTabPlugins` 的使用(依赖既有 import),#536 的 eslint 清理在其各自基线上把当时未使用的同名 import 删除——两个各自全绿的 PR 文本合并成功但语义断裂(typecheck TS2304 + spec 加载即挂),且被并发 run 取消掩盖后带病合入 main,由 Wave 3 两个子代理独立发现并热修(#540 `8820efd`)。教训:**并行分支触碰同一文件时,每合入一个后其余分支需重验(rebase + typecheck/test),不能只信各自 PR 的绿勾**;unused-import 类清理与功能分支并行时尤其如此。
|
||||
|
||||
### 3.4 GitHub push run 丢触发一例
|
||||
|
||||
#541 合并产生的 main push run(`68839aa`)从未被创建(workflows 均 active,按 SHA 查询 0 runs),经 `workflow_dispatch` 手动补跑三 job 全绿。原因不明,遇到「merge 后 main 无 push run」时可按此法补验。
|
||||
|
||||
## 4. 基础设施变化速查
|
||||
|
||||
- **Makefile**:`help`(默认目标)/ `install` / `build` / `typecheck` / `lint` / `test` / `check`(typecheck+build+test+consumer-types+lint 聚合)/ `clean` / `pack` / `mount` / `mount-aggregate` / `registry`。package.json scripts 仍是唯一事实源,Makefile 只做薄封装与依赖链。
|
||||
- **Lint**:`pnpm lint` = `eslint .`(flat config,`reportUnusedDisableDirectives: error`);CI 三处(ci / ci-windows / release)在 Typecheck 后执行。
|
||||
- **本地工具钉版**:`packageManager: pnpm@11.8.0`(CI workflow 的显式 `version:` 输入已移除,统一由该字段供版)。
|
||||
- **e2e 本地跑法**:`make mount` / `make mount-aggregate`;本机无 rc.1 CLI 时 `npm install --prefix /tmp/dsh-rc1-bin @deepseek-ai/dsh@0.1.2-rc.1` 后置 PATH。
|
||||
|
||||
## 5. Backlog(本次仅记录)
|
||||
|
||||
1. mount.e2e 的固定 `waitForTimeout` 与英文文案选择器(`[title="Files"]` 等)——宿主文案/语言变更即碎。
|
||||
2. CI 无 Playwright 浏览器缓存(每 run 重装 Chromium)。
|
||||
3. `state.spec`(1498 行)/ `smoke.spec`(1208)/ `service.spec`(1124)三个千行 spec 拆分。
|
||||
4. FreeWindow / TreePanel / split-pane / DiffPane / DiffRows 等组件缺直接渲染 spec(现靠 state 纯函数 spec + e2e 间接守护)。
|
||||
5. coverage 工具引入评估(当前零覆盖率度量)。
|
||||
6. CHANGELOG / CONTRIBUTING 文件(README「🆕 最近更新」小节手工充当 changelog)。
|
||||
7. Sidebar.tsx 剩余拖拽机(~330 行)拆分——前置条件是先重构 `sidebar-layout-push.spec.tsx` 的源码文本计数断言为行为断言。
|
||||
8. `JobOutputPane` 的 2s 轮询未迁移 `use-polling`(独立形状,见 #541 PR body)。
|
||||
9. install.sh / install.ps1 / check-consumer-types.sh 内部的 say/warn/die 重复——install.sh 必须自包含随 npm 包分发,属有意设计,除非改分发方式否则不动。
|
||||
10. §3.1 / §3.2 两个 flaky 的根治。
|
||||
|
||||
## 6. 验证基线(全部 PR 合并后于 main 复核)
|
||||
|
||||
- `pnpm lint` / `pnpm typecheck` / `pnpm build` / `pnpm test`:119 files / 1243 passed / 9 skipped。
|
||||
- `pnpm check:consumer-types` 通过。
|
||||
- 真机挂载 lane:mount 5/5、drag-layout 7/7、float-window 2/2、toggle-layout 1/1、perf 2/2(desktop-layout 见 §3.2)。
|
||||
- aggregate 双挂载回归 lane 通过。
|
||||
@@ -0,0 +1,62 @@
|
||||
# 文件树重命名 / 删除设计(2026-09-04)
|
||||
|
||||
> 状态:已实现。本文档记录功能设计、关键取舍与已知限制。
|
||||
|
||||
## 背景
|
||||
|
||||
文件树右键菜单此前只有打开/复制/上传下载等只读+导出操作,缺少资源管理器最基本的两个变更操作。本次补全:
|
||||
|
||||
- **重命名**:行内编辑(VS Code/Finder 惯例,用户已确认)——菜单点「重命名」后行内名字变输入框(预选中、Enter/失焦提交、Esc 取消)。
|
||||
- **删除**:弹窗确认(用户指定)——复用 git lens 的 Cancel/Confirm Modal 范式,文件/目录文案区分,确认后永久删除。
|
||||
|
||||
同批修复二级菜单的视觉关系(见下节)。
|
||||
|
||||
## 服务端
|
||||
|
||||
- `src/fs-operations.ts` 新增 `renameWorkspaceEntry` / `removeWorkspaceEntry`(与 `writeWorkspaceUpload` 同层):
|
||||
- **链路感知(link-aware)**:存在性与围栏校验对**完全解析后的目标**进行(fence 开启时指向工作区外的 symlink 一律拒绝),但操作本身作用于**词法行路径**——重命名/删除一个 symlink 行只改/删**链接本身**,绝不动目标(lstat 判定,link-to-dir 不会递归进目标)。与 VS Code 语义一致:行显示什么就操作什么。
|
||||
- `renameWorkspaceEntry`:`name` 必须单段(非空、非 `.`/`..`、无分隔符——重命名不是移动);目标已存在直接 409 拒绝(POSIX rename 会静默覆盖);工作区根本身不可重命名;同名 no-op 直接成功(不触发 409)。返回**规范路径**(realpath 解析后的祖先链)供 tab 改址。
|
||||
- `removeWorkspaceEntry`:目录递归删除、文件/symlink 解链;根拒绝。无回收站(宿主无此 API;等同 git lens discard 的永久语义)。
|
||||
- `src/index.ts` 路由 `'fs.rename'`(`{path, name}`)/ `'fs.remove'`(`{path}`),走 `cwdOf` + `fenceEnabledOf(getSettings)`,与 `fs.write` 同一围栏开关。
|
||||
- `src/client/api.ts`:`fsRename` / `fsRemove`。
|
||||
|
||||
## 客户端(FileTree)
|
||||
|
||||
- 菜单尾部(复制项之后):`separator + 重命名(IconEditOutline16) + 删除(IconTrashOutline16, danger)`;**根行**(`rowMenu.path === cwd`)不渲染这两项(服务端双保险)。
|
||||
- 行内编辑:命中的行换渲染为编辑行(同缩进/图标/行高),**无 role="button"**(编辑器不是点击目标,也避免 button 嵌套 interactive 的非法结构)。裸 `<input>`(primitives `Input` 不转发 ref,而 focus+select 正是要点);ref 回调 `useCallback([])` 稳定身份——内联箭头每渲染重跑会把正在输入的缓冲区重新全选。Enter/失焦提交、Esc 取消;`isImeComposition` 守卫(IME 组合期的 Enter/Esc 属于输入法,不提交不取消——顺手把该守卫的入参 `isComposing` 放宽为可选,React 合成事件类型未声明该字段)。
|
||||
- 删除确认:primitives `Modal`(git lens 同款 outline Cancel + primary Confirm)。
|
||||
- 失败面:树顶一条可点消失的错误条(`role="alert"`),展示服务端原文(与 FenceErrorNotice 同策略);客户端单段校验失败也走这里(`renameInvalid`)。
|
||||
- **落定收尾 `pruneTree(prefix)`**:清掉旧路径前缀的所有缓存层 → `onToggle` 收起 stale 的 expanded 项 → `retryDir(parent)` 单层重载(无需整树 refresh tick)。重命名已展开目录后新目录折叠展示(expanded 不迁移,VS Code 同样)。
|
||||
|
||||
## tab 调解(tree-mutations.ts + EditorHost)
|
||||
|
||||
- `retargetPathTabs(ctx, store, old, new)`:`tab.path === old` → `service.updateTab(id, {path: new, title: baseName(new)})`(编辑内容存活,后续保存落到新路径)。
|
||||
- `closePathTabs(ctx, store, target)`:`tab.path === target || isWithinWorkspace(target, tab.path)` → `service.closeTab(id)`(目录连整个子树的打开文件一起关)。
|
||||
- tab 枚举覆盖 `state.splits` / `state.bottomSplits` / `state.floats`(浮动窗口的 tab 同样算打开)。走 service 路径(而非直改 state)使注册的生命周期回调照常触发。
|
||||
- FileTree/TreePanel 透传 `onPathRenamed?` / `onPathDeleted?`(可选,测试可不接线);EditorHost 两处 `<TreePanel>` 接线。
|
||||
|
||||
## 二级菜单视觉关系(layout.css,同批)
|
||||
|
||||
针对「二级菜单没有视觉关系」的反馈,两条纯 CSS 规则(仍以 `data-dsh-sidebar-submenu` body 属性限定作用域,宿主默认不受影响):
|
||||
|
||||
1. **父行持续高亮**:宿主 Menu 在子菜单打开期间给父行按钮标 `aria-expanded="true"`(宿主已渲染、稳定),用同一 hover token 重新着色——指针移入侧卡后父行不再熄灭。
|
||||
2. **卡片贴近**:子菜单与父卡间距 10px → 4px,`::before` 悬停走廊同步收窄;翻转规则(left/down)的间距一并 4px。规则排序:tether 基础规则在前、翻转覆盖在后(同特异性下源序决胜)。
|
||||
|
||||
`tests/menu-flip.spec.tsx` 的 layout.css 契约断言扩展覆盖这三条规则。
|
||||
|
||||
## i18n
|
||||
|
||||
新增 7 key(rename / renameInvalid / delete / deleteTitle / deleteDescFile / deleteDescDir / dismiss),zh/en/ja 手写;其余 18 个已发第三语言按 `locales.spec.ts` 的 key-set-parity 门禁同步补齐。
|
||||
|
||||
## 测试
|
||||
|
||||
- `tests/fs-operations.spec.ts`:rename(文件/目录/no-op 同名/单段违规/目标已存在 409/根拒绝/缺源/symlink 行改链接不改目标)、remove(文件/目录递归/symlink 只解链目标存活/根拒绝与缺路径)。
|
||||
- `tests/file-tree-rename-delete.spec.tsx`(jsdom,mock api):菜单项出现条件(文件/目录有、根无);重命名流(预填→Enter 提交→`fsRename`+`onPathRenamed`、Esc 取消、非法名走错误条);删除流(弹窗文案文件/目录区分、确认→`fsRemove`+`onPathDeleted`、取消、服务端失败进错误条)。
|
||||
- 真机挂载冒烟(`pnpm test:mount`)不触达这些交互,由 CI 门禁与手动验收覆盖。
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 无回收站:删除即永久(fence 开关不改变这一点,只改变越界拒绝)。
|
||||
- 重命名不迁移 expanded/revealed 状态:改名已展开目录后新目录折叠、reveal 高亮不跟随(一次性视觉状态,成本/收益不划算)。
|
||||
- 并发窗口竞争:存在性检查与 rename/remove 之间有微小 TOCTOU 窗口(单用户侧边栏场景可忽略;服务端错误会进错误条)。
|
||||
- 「在其他编辑器里改名后本树 stale」既有行为不变(refresh tick / focus 刷新机制管)。
|
||||
@@ -0,0 +1,61 @@
|
||||
# 变更页操作预览增强设计(2026-09-06)
|
||||
|
||||
> 状态:已实现(PR #499)。本文档记录六个特性的设计、关键取舍与已知限制,含 2026-09-06 规范化审查后的整改记录。特性移植自 dsh-file-trace v0.2.0/v0.2.1 会话(PDF 渲染为作者后续追加)。
|
||||
|
||||
## 背景
|
||||
|
||||
变更页(Changes tab)底部预览面板此前只渲染统一 diff / 逐行读取 / 错误文本。本批扩展为:markdown 文档可切「阅读模式」、mermaid 围栏随阅读模式渲染、预览载荷做「敏感内容脱敏」、`.html` / `.pdf` 操作可切「渲染模式」,并把 diff 引擎的语法着色表扩到此前无色/错配的一批语言。全部特性共用同一预览面板与头部 toggle 交互。
|
||||
|
||||
## 1. Markdown 阅读模式
|
||||
|
||||
- 适用:`.md / .markdown / .mdx` 的 read / write / edit 操作(非错误)→ 头部「阅读 / 原文」toggle。
|
||||
- 渲染走共享 `MarkdownText`(与编辑器预览、Side Chat 同一栈,labels 契约经 `markdownTextProps()`,见 AGENTS §3.3);本地图片目的地经 `rewriteLocalImageUrls` 改写到会话域 `/sidebar/file` 媒体路由。
|
||||
- 内容来源:read → `parseReadContent()`(见下);write → 写入内容;edit → 已知 prior 时 `prior.replace(oldString, newString)` 重建结果文档,否则仅展示 newString(设计上「prior 已知才重建」)。
|
||||
- `parseReadContent`(`src/client/changes/ops.ts`):与 `parseReadLines` 共享 `contentLines()` envelope 核心(`<content>` 提取 + "(Showing lines …)" 过滤);区别在前者剥 `<n>: ` 前缀后**保留空行**(markdown 块结构依赖空行)并修剪首尾空行(渲染不可见),后者解析行号、丢弃空行。
|
||||
|
||||
## 2. Mermaid 围栏渲染
|
||||
|
||||
- 阅读模式检测到 ```mermaid 围栏时,走编辑器同款懒加载渲染器(`LazyMermaidMarkdown`,mermaid chunk 经 `/sidebar/bundle` 按需下发);无围栏文档保持单次 MarkdownText 直渲染(字节不变)。
|
||||
- `LazyMermaidMarkdown` 的定义从 `MarkdownHtml.tsx` 移到轻量模块 `src/client/mermaid-lazy.tsx`:变更页(核心 bundle)要引用这个 stub 而不把 `MarkdownHtml` 的 DOMPurify/HTML 分析机械拖进核心 bundle;`MarkdownHtml.tsx` 改为 re-export。
|
||||
- 已知边界:无引号节点标签内含被遮密钥(`[REDACTED]` 字面)时 mermaid 解析失败,回落到「错误条 + 已遮源码」——该回落是 `mermaid.tsx` 既有机制(catch → 错误 + 源码),本批未新增代码,亦未为该路径加测试(README 已注明规避方式:标签加引号)。
|
||||
|
||||
## 3. 语法着色扩展(`src/client/diff/highlight.ts`)
|
||||
|
||||
- `mjs/cjs → JS_WORDS`、`mts/cts → TS_WORDS`(此前无映射 → 纯文本);`tsx/jsx` 同用共享词表(规范化整改:原先保留内容相同的内联副本)。
|
||||
- `css/scss/less → CSS_LANG`:slash-star 块注释跨行、连字符属性词(`background-color` 整词着色)、字符串、十六进制数;此前误用 `#` 行注释配置导致不着色。
|
||||
- `html/htm/xml/svg/vue → MARKUP_LANG`:`<!-- -->` 注释 + 常见标签/属性;`graphql/gql`、`jsonc/json5` 映射。
|
||||
- 着色是启发式(词表驱动单遍扫描);CSS/MARKUP 词表混排选择器/属性/标签是刻意为之——`LangConfig` 只有一个 keywords 集,再分类属过度设计。
|
||||
|
||||
## 4. 敏感内容脱敏(`src/client/redact.ts`)
|
||||
|
||||
- 动机:追踪器把 read/write 工具结果重放进 DOM;模型读取凭据文件时明文不应上屏。**仅显示层**:会话日志与 fs 层字节不动;不是对同上下文恶意插件的安全边界。
|
||||
- 两层启发式:
|
||||
1. **路径层**:文件名含凭据形态(`.env`、`secret(s)`、`credential(s)`、`token`、`api-key`、`password(s)`、私钥/证书扩展名…)→ 整文件逐行 `[REDACTED]`。匹配带**右边界**(模式后不得继 `[a-z0-9]`),且复数只收「密钥仓库」义(credentials/secrets/passwords),不收 `tokens`(api-tokens.md 通常是文档)——避免 `tokenizer.ts` / `dev.environment.ts` / `style.keys.ts` / `api-tokens.md` 被整文件误伤。
|
||||
2. **内容层**:普通文件按行遮值——赋值形(`api_key:` / `TOKEN=` / `"password":`,**字段名保留**)、`Bearer` 头、`sk-`/`AKIA`/`ghp_`/`gho_`/`xox*`/`ya29.` 前缀 token、PEM 私钥块头。字段表**不含裸词 `key`/`auth`/`token`/`pass`**(规范化整改:这些词标注的普通内容远多于密钥——键盘键名、CSS 自定义属性、词法 token;Bearer 场景由 `BEARER_RE` 单独覆盖)。
|
||||
- 开关:默认开,头部一键切换,localStorage 记忆(key `dsh-sidebar:v1:redaction`,仓库既有前缀)。所有载荷消费方(diff 行、读取行、阅读模式源、错误文本、prior)统一从**遮蔽后**形状渲染;命中时头部显示「已脱敏」条。
|
||||
|
||||
## 5. HTML 渲染模式
|
||||
|
||||
- 适用:`.html / .htm` 操作(与编辑器 html viewer 的扩展集对齐;规范化整改:去掉越权的 `.xhtml`)→ 头部「渲染 / 原文」toggle。
|
||||
- 实现:`HtmlRenderPreview`(`DiffPane.tsx` 导出,供沙箱 spec 钉契约)以 route-src iframe 加载 `/sidebar/html`——相对资源(`./style.css`、`img/x.png`)在路由内解析;分段读取也能渲染完整文档。
|
||||
- **语义边界(刻意)**:路由服务的是**盘上已保存文件**而非操作快照——失败/未落盘的写入会渲染旧内容(README 已声明)。共享常量 `HTML_IFRAME_SANDBOX` 提到 `src/client/html-preview.ts`(编辑器与变更页共用);此面**恒定沙箱**(opaque origin + CSP 头),编辑器 tab 才持有带警告的去沙箱逃生门。
|
||||
|
||||
## 6. PDF 渲染模式(作者 2026-09-06 追加)
|
||||
|
||||
- 适用:`.pdf` 操作(非错误)→ 头部「渲染 / 原文」toggle,复用编辑器 `PdfView`(媒体路由字节流 + 显式类型 Blob → 浏览器原生 PDF 查看器内嵌,附下载入口)。规范化整改:扩展名正则补 `\.` 转义(原 `/.pdf$/i` 会误命中任何以 pdf 结尾的路径)、toggle 并入 `PaneToggle`。无独立单测(PdfView 行为由编辑器侧既有测试覆盖)。
|
||||
|
||||
## 规范化整改(2026-09-06 审查后)
|
||||
|
||||
- **皮肤契约**:`.htmlFrame` 背景硬编码 `#fff` → `var(--dsw-alias-bg-base)`(对齐 `.editorHtml`);`.redactBanner` 原用的 `--dsw-alias-state-warning-primary` 在 DSW 词表中**不存在**(实际为 `state-warn-*`),var() 恒回落到硬编码 `#b8860b` → 改用真实令牌 `var(--dsw-alias-state-warn-primary)`。
|
||||
- **约定对齐**:localStorage key 并入 `dsh-sidebar:v1:` 前缀;`tests/redact.spec.ts` 源码 import 由 `.js` 改 `.ts`(仓库惯例);DiffPane 三份同形 toggle 按钮提取 `PaneToggle`,阅读视图提取 `MdReadingView`(吸收 mermaid 分支与 codeLabels 计算);遮蔽 useMemo 单遍化(每字段一次 `redactText`,此前 hit 扫描 + mask 双遍)。
|
||||
- **去重**:`tsx/jsx` 共享 `TS_WORDS`/`JS_WORDS`;`parseReadContent`/`parseReadLines` 共享 `contentLines()`;CSS 词表去重(`hidden`/`inherit` 重复项)并去掉 CSS 没有的 `true false` 常量。
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 脱敏是启发式:右边界规则放行 `mytoken.yaml`(命中遮蔽)而拒绝 `api-tokens.md`(文档);字段级遮蔽不覆盖未知命名的密钥字段——按 token 形态(`sk-` 等前缀)兜底。一键关闭可在受信任环境看原文。
|
||||
- HTML 渲染读盘上文件(见 §5);mermaid 无引号标签含遮蔽密钥时回落源码(见 §2)。
|
||||
- 阅读模式 edit 重建依赖窗口内已知 prior;窗口外旧内容不可知时仅展示新串(面板本就以「已知内容」为限)。
|
||||
|
||||
## 测试
|
||||
|
||||
`tests/redact.spec.ts`(两层遮蔽 + 边界/裸词误伤回归)、`tests/diff-highlight.spec.ts`(后缀映射、CSS 注释穿行/属性、markup 注释/标签、mjs 关键字)、`tests/changes-ops.spec.ts`(`parseReadContent` 空行保留)、`tests/changes-tab.spec.tsx`(阅读模式端到端:h1/GFM 表格/本地图片改写/切回原文)、`tests/sandbox-views.spec.tsx`(HTML 渲染 iframe 恒定沙箱 + toggle 出现条件)。
|
||||
@@ -0,0 +1,217 @@
|
||||
# Git 折叠行真实展开——修复「点击展开」无反应
|
||||
|
||||
> 日期:2026-09-07 · 状态:计划已定,待实施 · 分支(拟):`fix/diff-fold-expand`
|
||||
> 关联:上游 issue(待提)、上游 PR(待提,Closes issue)· 上游仓库 `omdsh-dev/DSH-better-sidebar`,本地 fork `LaoYueHanNi/DSH-better-sidebar`(main `6fbfeed` 与上游零差异)
|
||||
|
||||
## 1. 背景与 bug 确认(已核实)
|
||||
|
||||
**现象**:changes 组件点击 git 提交(或工作区 diff)后,diff 视图中 hunk 之间的折叠行显示「n 行…点击展开」,点击无反应(无 pointer/hover)。session(会话文件操作)lens 的折叠行可正常展开——仅 git lens 永远不可展开。
|
||||
|
||||
**证据链**:
|
||||
- `src/client/diff/rows.ts` L407-433(`unifiedSegments`):git `-U3` 不输出 hunk 之间的未改动行,gap fold 段**只有行号范围、没有 `rows` 字段**(注释明确 "rows are unknown (non-expandable)");
|
||||
- `src/client/diff/DiffRows.tsx` L193-228:`expandable = segment.rows !== undefined && segment.count >= MIN_FOLD`;`rows === undefined` 走静态分支(L197-202,**无 onClick、无 data-expandable**),但文案复用 `t('changesFold')` = zh「{count} 行…点击展开」/ en「{count} lines…click to expand」——承诺了不存在的交互;
|
||||
- `src/client/diff/diff.module.css` L50-51:仅 `[data-expandable='true']` 有 pointer/hover,静态分支视觉上也不可点,唯独文字撒谎;
|
||||
- session 路径(`diffLines` + `buildDiffSegments` → `foldOf`)的 fold 总有 `rows`,可正常 toggle 展开。
|
||||
|
||||
**上游调研结论**(2026-09-07,9 组中英文关键词 + PR 列表 + `git fetch` 源码比对):无 issue 报告过此问题(最接近的 #346 是「文件级默认折叠要多点一下」,方向相反),无已合并/进行中的修复——**全新发现**。
|
||||
|
||||
**已排除的轻量方案**:
|
||||
- 纯改文案(原方案 A):不解决「看不到被折叠内容」的本质,用户拍板不做;
|
||||
- 调大 `-U` 上下文:大 gap 依旧折叠且依旧不可展开,治标且 diff 变吵。
|
||||
|
||||
## 2. 目标与成功标准
|
||||
|
||||
点击 git 折叠行时**按需拉取两侧完整内容、按行号切片填充真实上下文行**,让「点击展开」名副其实。
|
||||
|
||||
1. git 提交/工作区 diff 折叠行点击后展开未改动上下文行(带双侧行号),再点可折叠;
|
||||
2. session lens 行为完全不变;二进制/untracked 文件不受影响;
|
||||
3. 内容拉取失败降级为无点击暗示的中性文案;
|
||||
4. `pnpm typecheck` / `pnpm test` 全绿,CI 三 job(挂载冒烟/typecheck/test)绿;
|
||||
5. 上游 issue(问题+预计方案)与 PR(实际修复,`Closes #N`)按仓库规范挂接。
|
||||
|
||||
## 3. 已确认的实现地基
|
||||
|
||||
- **服务端 `git.show` 路由已存在**(`src/index.ts` L474-480):payload `{rev, path}`(rev 可为 `:0` / `HEAD` / `<hash>^` / `<hashFull>`),返回 `{content: string | null}`,失败(null)内建于 `git.ts show()` 的 try/catch → **服务端零改动**;
|
||||
- `src/git.ts` `diff()`(L382-388):unstaged = `git diff -- path`(**index vs worktree**),staged = `git diff --cached`(**HEAD vs index**)——决定展开时两侧 rev 取法;
|
||||
- 渲染链:`DiffPane`(底部预览)/ `DiffTab`(独立 tab)→ `DiffFiles`(per-file `unifiedSegments`)→ `DiffRows`;op 路径直接 `DiffRows`(`buildDiffSegments`)。客户端 `api.ts` 尚无 `git.show` 封装(需补一行 call);
|
||||
- `SidebarDiffRef`(`src/client/state.ts` L24-26):`worktree{path, staged, untracked?, worktree?, repoRoot?}` | `commit{hash, hashFull, subject, worktree?, repoRoot?}`;
|
||||
- spawn 数组参数不经 shell,`<hash>^` 的 `^` 无需转义;`git show :0:<path>` 取 index 版本合法。
|
||||
|
||||
## 4. 实现设计(按子系统)
|
||||
|
||||
### 4.1 `src/client/api.ts` — gitShow 客户端封装
|
||||
仿 `gitCommitDiff`(L317-318):
|
||||
```ts
|
||||
gitShow: (scope: SessionScope, rev: string, path: string, worktree?: string, signal?: AbortSignal) =>
|
||||
call<{ content: string | null }>('git.show', gitPayload(scope, worktree, { rev, path }), signal)
|
||||
```
|
||||
走既有 RPC 通道,不触构建纯度门。
|
||||
|
||||
### 4.2 `src/client/diff/rows.ts` — 切片纯函数
|
||||
```ts
|
||||
export function foldRowsFromContents(fold: FoldSegment, oldContent: string, newContent: string): readonly DiffRow[]
|
||||
```
|
||||
- 以 old 侧驱动 `[oldStart..oldEnd]` 逐行生成 `{kind:'context', oldLine, newLine?, text}`,`newLine = oldLine - oldStart + newStart`(仅当落在 `[newStart..newEnd]` 内才带);再以 new 侧驱动补纯新增段(old 映射不到的行,只带 `newLine`);
|
||||
- 内容 `split('\n')` 切;空内容 = 空数组;行尾 `\r` 保留(与 git diff ctx 行行为一致)。
|
||||
|
||||
### 4.3 `src/client/diff/DiffRows.tsx` — 异步展开状态机
|
||||
- 新 prop:`resolveFold?: (segment: FoldSegment) => Promise<readonly DiffRow[]>`(不传 = 现状,op 路径不变);
|
||||
- 新 state:`foldData: Map<number, readonly DiffRow[]>`、`foldLoading: Set<number>`、`foldFailed: Set<number>`,随现有 useEffect(L119-123)在 segments 变化时整体重置;
|
||||
- expandable 判定放宽:`(segment.rows !== undefined || resolveFold !== undefined) && segment.count >= MIN_FOLD`;
|
||||
- 点击未填充的 git fold:置 loading → `resolveFold(segment)` → 成功存 `foldData`(渲染优先于 `segment.rows`)、失败标 `foldFailed`;
|
||||
- 渲染三态:加载中 `t('changesFoldLoading')`;失败 `t('changesFoldUnavailable')`(无 onClick/pointer);正常沿用 `changesFold`(此时承诺为真);已展开的 fold 渲染 rows,再点折叠回。
|
||||
|
||||
### 4.4 `src/client/diff/DiffFiles.tsx` — loader 下传 + Promise 缓存
|
||||
- 新可选 prop `resolveFold?: (file: DiffFile, segment: FoldSegment) => Promise<readonly DiffRow[]>`,转发 `DiffRows`;
|
||||
- 实例级 `useRef<Map<string, Promise<readonly DiffRow[]>>>`,key = `displayPath(newPath)|oldStart|newStart`,同文件多 fold 共享一次内容拉取、并发点击天然去重;`parsed` 变化时重置。
|
||||
|
||||
### 4.5 `src/client/changes/DiffPane.tsx` / `src/client/DiffTab.tsx` — lens loader
|
||||
按 `SidebarDiffRef` 构造传给 `DiffFiles`:
|
||||
- **commit lens**:旧侧 `gitShow(scope, '<hashFull>^', displayPath(file.oldPath))`(root commit → null → ''),新侧 `gitShow(scope, hashFull, displayPath(file.newPath))`;与 `commitDiff` 的 `-m --first-parent` 语义一致;rename 由 `oldPath/newPath` 天然覆盖;
|
||||
- **worktree lens**:`staged=true` → 旧 `HEAD` / 新 `:0`;`staged=false` → 旧 `:0` / 新侧 `api.fsRead` 工作区(`resolveSidebarPath(repoRoot ?? worktree ?? cwd, path)`,同现有 untracked fallback L196/L68 的写法);
|
||||
- **staged 侧漂移修正(关键边缘)**:DiffPane L182-188 / DiffTab L49-57 在请求侧 diff 为空时 fallback 渲染另一侧,但 `gitRef.staged` 不变——把实际生效侧记为 state(如 `effectiveStaged`),loader 依它取数,否则切片行号错位;
|
||||
- untracked 文件走 `untrackedFile` 全加渲染,无 fold,loader 不涉及。
|
||||
|
||||
### 4.6 i18n — 两个新 key,18 个语言文件全补
|
||||
- `changesFoldLoading`:zh `展开中…` / en `Expanding…`
|
||||
- `changesFoldUnavailable`:zh `上下文未加载` / en `Context unavailable`
|
||||
- `locales.ts` 为基准;`locales-ja.ts` 同步是 AGENTS 硬性底线;其余 15 个 `locales-*.ts` 照 `changesFold` 现状全量补译;纯 `t()` 通道,不涉 `markdownTextProps()`。
|
||||
|
||||
### 4.7 测试(vitest,沿用现有模式)
|
||||
- `tests/diff-rows.spec.ts` 增补 `foldRowsFromContents`:对称区间 / 纯删除 gap(old 有 new 无)/ 纯新增 gap / `\r\n` 行尾 / 空内容 / 行号越界截断;
|
||||
- 新组件测试(参照 `tests/diff-files-collapse.spec.tsx`):mock `api.gitShow` → 点击 git fold 断言展开行渲染、再点折叠、失败降级文案、session fold 路径不受影响;
|
||||
- 回归:`diff-rows` / `diff-files-collapse` / `diff-highlight` / `builtins` 全绿。
|
||||
|
||||
## 5. 边缘 case 清单(实现时逐条对照)
|
||||
|
||||
| 场景 | 处置 |
|
||||
|---|---|
|
||||
| root commit 无父 | `git.show` 返 null → 空串(旧侧视为空) |
|
||||
| rename | `parseUnifiedDiff` 保留 oldPath/newPath,两侧各取各的 |
|
||||
| 二进制文件 | 无 hunks,不渲染 DiffRows,不涉及 |
|
||||
| untracked | `untrackedFile` 全加渲染,无 fold |
|
||||
| 无换行文件 | `count` 与切片行数可差 1,展开后以实际行为准 |
|
||||
| `\r\n` 行尾 | blob 行与 diff ctx 行同样保留 `\r`,一致 |
|
||||
| staged fallback 侧漂移 | `effectiveStaged` state(§4.5) |
|
||||
| 并发/重复点击 | Promise 缓存去重 |
|
||||
| refresh / 切目标 | tick + segments 变化重置展开态 |
|
||||
| 大文件 | 按需拉取不预取,失败降级兜底 |
|
||||
|
||||
## 6. 流程步骤(issue / PR 全文草稿)
|
||||
|
||||
### 步骤 1:向上游提 issue
|
||||
|
||||
```bash
|
||||
gh issue create --repo omdsh-dev/DSH-better-sidebar \
|
||||
--title "[Bug] Git 提交/工作区 diff 折叠行显示「n 行…点击展开」,点击无反应" \
|
||||
--body-file <下方正文>
|
||||
```
|
||||
|
||||
模板字段:环境 = `DSH Web(浏览器访问)`;类别 = `🐛 Bug`;DSH 版本可留空;插件版本 = `main(6fbfeed)`。
|
||||
|
||||
**描述字段正文**:
|
||||
|
||||
> **现象**:在「变更」面板点击历史提交(或查看工作区 diff)后,diff 视图中 hunk 之间的折叠行显示「n 行…点击展开」,但点击无任何反应(无 pointer 光标、无 hover);而会话文件操作(文件追踪)lens 的折叠行可以正常展开——仅 git lens 永远不可展开。
|
||||
>
|
||||
> **期望行为**:要么点击真实展开被折叠的上下文行,要么不显示「点击展开」字样。
|
||||
>
|
||||
> **复现步骤(Bug 必填)**:打开侧边栏 → 变更 → 历史 → 点击任一含多个 hunk 的提交 → 观察文件 diff 中 hunk 之间的折叠行 → 点击。
|
||||
>
|
||||
> **根因(代码定位)**:git 的 unified diff(`-U3`)不输出 hunk 之间的未改动行,`unifiedSegments`(`src/client/diff/rows.ts`)为此产生的 fold 段只有行号范围、没有 `rows`;`DiffRows`(`src/client/diff/DiffRows.tsx`)对 `rows === undefined` 渲染静态分支(无 onClick),但复用了含「点击展开」的 `changesFold` 文案,形成「承诺了不存在的交互」。
|
||||
|
||||
**补充信息字段正文**:
|
||||
|
||||
> **预计修正方案**:不采用纯改文案或调大 `-U` 上下文(治标),按需真实展开——点击折叠行时经既有 `git.show` 服务路由拉取该文件两侧完整内容(commit:`<hash>^` 与 `<hash>`;worktree staged:`HEAD` 与 `:0`;unstaged:`:0` 与工作区文件),按 fold 已知行号范围切片为 context 行填充渲染;per 文件 Promise 缓存,失败降级为中性文案。将在 fork 修好后提 PR(Closes 本 issue)。
|
||||
>
|
||||
> 截图:<用户提供的截图,展示「11 行…点击展开」行>
|
||||
|
||||
### 步骤 2:分支与实现
|
||||
|
||||
```bash
|
||||
git checkout -b fix/diff-fold-expand # 基于与上游一致的 main(6fbfeed)
|
||||
```
|
||||
|
||||
按 §4 落地;同时新增本设计文档的正式版(本文件即底稿,文末补「实施偏差」节,惯例参照 `docs/plans/2026-09-06-changes-op-previews-design.md`)。
|
||||
|
||||
**提交拆分**(oyw-commit-style:`type(scope): 中文概括` + 无序列表 body + 背景动机段 + 决策记录伴随行;禁 AI 署名、禁 `--no-verify`):
|
||||
1. `docs(diff): git 折叠上下文按需展开设计文档`
|
||||
2. `feat(api): 暴露 gitShow 单文件版本内容读取`
|
||||
3. `feat(diff): 折叠行异步展开状态机与内容切片`(rows.ts + DiffRows + DiffFiles + 测试)
|
||||
4. `fix(diff): git lens 折叠行接入按需展开,修复点击无反应`(DiffPane/DiffTab + i18n + 组件测试)
|
||||
|
||||
每个 commit 保持 typecheck/test 可绿。
|
||||
|
||||
### 步骤 3:验证
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm test # 全量门禁
|
||||
pnpm build && pnpm pack && pnpm test:mount # 挂载冒烟(CI 同款)
|
||||
```
|
||||
|
||||
真机点开含多 hunk 提交,验证展开/折叠/失败降级,截图用于 PR。
|
||||
|
||||
### 步骤 4:发 PR
|
||||
|
||||
```bash
|
||||
git push -u origin fix/diff-fold-expand
|
||||
gh pr create --repo omdsh-dev/DSH-better-sidebar --head LaoYueHanNi:fix/diff-fold-expand \
|
||||
--title "fix(diff): git 折叠上下文按需展开,修复「点击展开」无反应" --body-file <下方正文>
|
||||
```
|
||||
|
||||
**PR body**:
|
||||
|
||||
> ## 问题
|
||||
> Closes #<N>。git 提交/工作区 diff 的折叠行显示「n 行…点击展开」但点击无反应:`-U3` 裁剪使 `unifiedSegments` 的 gap fold 无行文本,渲染分支复用了含点击承诺的文案。
|
||||
>
|
||||
> ## 方案
|
||||
> 点击时按需经 `git.show` 拉取两侧完整内容,按 fold 行号切片为 context 行填充;不预取(大 lockfile 场景零开销),per 文件 Promise 缓存去重并发,失败降级为「上下文未加载」中性文案。session lens 行为不变,服务端零改动(`git.show` 路由已存在)。
|
||||
>
|
||||
> ## 改动清单
|
||||
> - `src/client/api.ts`:gitShow 封装
|
||||
> - `src/client/diff/rows.ts`:`foldRowsFromContents` 纯函数
|
||||
> - `src/client/diff/DiffRows.tsx`:`resolveFold` prop + 展开/加载/失败状态机
|
||||
> - `src/client/diff/DiffFiles.tsx`:loader 下传 + Promise 缓存
|
||||
> - `src/client/changes/DiffPane.tsx` / `src/client/DiffTab.tsx`:两种 lens 的取数映射(含 staged fallback 侧修正)
|
||||
> - `src/client/locales*.ts` × 18:`changesFoldLoading` / `changesFoldUnavailable`
|
||||
> - `tests/diff-rows.spec.ts` + 新组件测试
|
||||
> - `docs/plans/…`:设计文档
|
||||
>
|
||||
> ## 测试
|
||||
> - 新增:切片纯函数单测(对称/纯删/纯增/\r\n/越界);组件测试(mock gitShow:展开、折叠、降级)
|
||||
> - 回归:`pnpm typecheck`、`pnpm test` 全绿;`pnpm test:mount` 冒烟通过
|
||||
> - 截图:<展开前后对比>
|
||||
|
||||
### 步骤 5:review 跟进
|
||||
|
||||
按上游惯例(#499 经验)预判 review 关注点:皮肤令牌(本改动无颜色,不涉)、约定对齐(prop 命名/状态机形状)、i18n 完整性;整改意见在同分支追加 commit,重跑门禁。
|
||||
|
||||
## 7. 明确不做
|
||||
|
||||
- 不做纯文案拆分的「方案 A」单独 PR(失败降级文案已含在 B 内);
|
||||
- 不调大 `-U` 上下文;
|
||||
- 不改服务端;
|
||||
- 不动 `changesFold` 现有文案(B 落地后「点击展开」对 git fold 也为真)。
|
||||
|
||||
## 8. 仓库规范备忘(实施时遵守)
|
||||
|
||||
- 代码改动必须走 PR(AGENTS §1);fork 侧开发,push 到 `LaoYueHanNi` 后向上游发 PR;
|
||||
- 风格对齐:prop 命名、状态机形状、组件拆分与 CSS 模块组织以 `src/client/diff/` 现有实现为准,不引入新依赖/新工具/新交互模式;有歧义时参照上游最近合入的同类改动;
|
||||
- i18n:zh 新 key 必须同步 ja(其余语言全补);
|
||||
- 皮肤契约:无硬编码颜色(本改动无颜色);
|
||||
- 提交信息遵循 oyw-commit-style,正文无序列表列改动点 + 背景动机段;
|
||||
- 仅在用户明确说「提交」时才 commit;PR 创建前等用户批准。
|
||||
|
||||
---
|
||||
|
||||
## 实施偏差记录
|
||||
|
||||
> 实施过程中与本文设计的任何偏离,逐条记录在此(惯例:文档以本节为准)。
|
||||
|
||||
1. **语言文件数量**:§4.6 / §6 的「18 个语言文件」计数有误——实际变更点为 **20 个文件 21 处**(`locales.ts` 内 zh/en 双块 + 19 个 `locales-*.ts`:ar/de/fr/hi/id/ko/it/ja/pl/nl/pt/ru/sv/th/tr/vi/zh-HK/zh-MO/zh-TW),已全量补齐,PR body 计数随之更正。
|
||||
2. **缓存拆为两层**:§4.4 把「同文件多 fold 共享一次内容拉取」归给 DiffFiles 的 fold 级 Promise 缓存,但 fold 级 key 含行号(`path|oldStart|newStart`),无法去重内容请求;实施时把内容拉取缓存上移到 lens 层(`DiffPane`/`DiffTab` 的 `foldContents`,key = `displayPath`),DiffFiles 的 fold 级缓存保留(价值:文件头折叠再展开导致 DiffRows 重挂载后秒开、并发点击去重,reject 时摘除)。两层各司其职。
|
||||
3. **loading 态交互与竞态防护**:§4.3 三态渲染之外,加载中的 fold 不响应点击且无 pointer(`data-expandable` 移除),防 loading 期间重复触发;另以 `foldEpoch` ref 使 segments 重置后 still-in-flight 的 resolve 回调失效,防「旧 fold 的迟到结果写进新 segments 同下标」竞态(§4.3 未展开)。
|
||||
4. **unstaged 新侧读取失败的宽容降级**:§4.5 未细化——工作区侧 `fsRead` 失败(如文件已删除)或返回二进制时降级为空串(catch → null → `''`)而非整体 failed,让旧侧(index)上下文依然可展开。
|
||||
5. **DiffPane 组件级 gitScope**:fold loader 需要 SessionScope,新增组件级 `gitScope`(useMemo);load effect 的局部 `paneScope` 保持原样不动,最小侵入。
|
||||
6. **本机测试基线**:Windows 本机全量跑 `tests/git.spec.ts` / `tests/git-worktree.spec.ts` 中 3 个真实 git 子进程集成用例会并发抖动失败(stash 后基线复现同样失败,与本次改动无关;CI Linux 绿)。diff 相关 18/18 全绿(新增 `foldRowsFromContents` 6 用例 + 组件测试 4 用例)。
|
||||
7. **本地挂载冒烟未执行**:Windows 本地 Git Bash 下 `e2e-mount.sh` 将 tarball 规范为 MSYS 风格 `/d/...`,`file:` scheme 前缀使 MSYS 自动参数转换失效,Windows node 解析为 `C:\d\...` 而 ENOENT(经 `DSH_CMD` 注入 cygpath wrapper 可绕过,再因依赖下载网络受限放弃);`pnpm build` 产物构建已绿,挂载冒烟由 CI `plugin-mount` job 同款覆盖。
|
||||
8. **服务端零改动前提被证伪(真机修正)**:§3/§5 的「git.show 路由已存在、服务端零改动」不成立——该路由此前无真实调用方,其 path 经 `resolveGitPath` 解析为绝对文件系统路径(工作区命令语义),而 `git show <rev>:<path>` 只接受仓库相对路径,恒 fatal 返 null,两侧空串切片 0 行,展开表现为「marker 消失、零行渲染」(link 真机首测发现)。修正:路由透传仓库相对路径(diff 输出剥前缀的形态,暴露面与 git.diff/git.log 一致,仅本仓库版本树),另在两 lens 加「两侧皆空 → 降级『上下文未加载』」防御,取数异常不再静默空白展开。
|
||||
@@ -0,0 +1,79 @@
|
||||
# 终端 Shell 配置加固(引号解析 + 双平台预检 + 友好报错)设计
|
||||
|
||||
日期:2026-09-08 | 状态:已确认(用户选定方案二) | 分支:`feat/terminal-shell-quoted-args-probe`
|
||||
|
||||
## 背景与现状
|
||||
|
||||
配置启动终端命令的功能已存在于 main(`fd9544f` #95 部署级 `config.shell`/`shellArgs` + pwsh 探测;`c973d6e` #125 设置页 `terminalShell`/`terminalShellArgs` 用户偏好,覆盖顺序:设置页 > yaml > 平台自动,UI 标签页与模型 `terminal_*` 工具两终端面共用)。本次不是新增功能,而是加固三处已知缺口:
|
||||
|
||||
1. **参数解析硬切**:`shellOverridesOf`(`src/index.ts:257`)用 `args.split(/\s+/)`,引号内空格被切碎(`-File "C:\my script\init.ps1"` 得到 3 个参数)。
|
||||
2. **POSIX 无预检**:`resolveShellExecutable`(`src/pty-manager.ts:351`)仅 win32 做存在性探测(PATHEXT/PATH/System32/pwsh 目录,找不到抛 `SidebarError('pty-error', 'shell executable not found: …')` → WS catch `ws.close(1011, message)`);POSIX 直接透传 node-pty,配错路径只得到 `[process exited with code N]`。
|
||||
3. **报错不可操作**:客户端 `TerminalView.tsx:247` 对任意 `1011 + reason` 显示原始英文 reason 横幅,没有本地化说明与"去设置改"指引;粘贴带引号路径(`"C:\Program Files\…\pwsh.exe"`)会因引号参与路径解析而失败;`shell.get` 返回启动时解析的 shell,设置页覆盖后新终端标签标题不跟随。
|
||||
|
||||
## 方案取舍
|
||||
|
||||
- **方案一(最小)**:只修引号参数解析。POSIX 配错仍不可诊断,引号路径仍炸——否。
|
||||
- **方案二(选定)**:解析 + 双平台预检 + 本地化友好报错 + 标题跟随。不碰声明式设置契约。
|
||||
- **方案三(全家桶)**:方案二 + 设置页实时 `shell.probe` inline 校验。校验型输入行需扩展 declarative settings 契约(`service.ts` + `docs/external-plugin-guide.md`),收益/成本比低(保存后开终端立即可见)——YAGNI,不做。
|
||||
|
||||
## 设计
|
||||
|
||||
### 1. 引号感知参数解析 `splitShellArgs`(`src/pty-manager.ts`)
|
||||
|
||||
- 空白分词;`'…'` / `"…"` 分组,组内字符**全部字面**——`\` 不是转义符(Windows 路径场景直接可用)。代价:参数值本身含引号无法表达,注释注明该取舍。
|
||||
- 未闭合引号宽容处理:剩余输入并入当前 token(不抛错——设置页输入体验优先)。
|
||||
- 空引号对 `""` 丢弃,不产生空参数。
|
||||
- `shellOverridesOf` 中 `args.split(/\s+/).filter(Boolean)` 替换为 `splitShellArgs(args)`。
|
||||
|
||||
### 2. 路径去引号 `unquotePath`(`src/pty-manager.ts`)
|
||||
|
||||
- 长度 ≥2 且首尾为成对 `"` 或 `'` 时剥掉,否则原样返回。
|
||||
- 统一入口:`shellOverridesOf` 与 `resolveShellExecutable` 首行均走它(单一实现防两处漂移)。
|
||||
|
||||
### 3. POSIX 预检(泛化 `resolveShellExecutable`)
|
||||
|
||||
- POSIX 分支:配置值含 `/` → `existsSync` 直查;裸名 → 沿 `env.PATH`(`:` 分隔,缺省回退 `/usr/bin:/bin`)逐目录探测。
|
||||
- 找不到统一抛 `SidebarError('shell-not-found', 'shell executable not found: "…"')`(新错误码;消息保持英文技术文案——模型侧 `terminal_create` 工具报错复用同一消息,模型读英文更稳)。
|
||||
- `ShellExecutableResolutionOptions` 的 `platform/env/exists` 注入口径不变,win32 逻辑不动。
|
||||
|
||||
### 4. 友好报错(host ↔ client 线协议)
|
||||
|
||||
- `SidebarError` 增加可选 `meta` 载荷,`shell-not-found` 时携带配置的 shell 原文。
|
||||
- WS 升级 handler catch(`src/index.ts` 终端 upgrade)识别 `error.code === 'shell-not-found'` → close reason 发机器标记 `shell-not-found:<shellDisplayName(shell)>`(受 WS close reason 123 字节上限约束,超长截断显示名)。
|
||||
- `TerminalView.tsx` `onclose` 在通用 1011 分支**之前**识别 `reason.startsWith('shell-not-found:')` → 本地化 fatal 横幅:`terminalShellNotFound`(「未找到配置的 Shell:<名>」)+ `terminalShellNotFoundHint`(「请检查 设置 → 侧边卡片 → 终端 的 Shell 路径」)。
|
||||
- locale 新 key 落点:`locales.ts`(zh + en 两块)+ `locales-ja.ts`(仓库规则强制同步),其余语言文件走既有回退链。
|
||||
|
||||
### 5. 标题跟随
|
||||
|
||||
- `shell.get` 路由改为 `overrides.shell ?? 启动时解析值`,`shellDisplayName` 随之;仅影响新开标签的标题,不触碰已存在 pty。
|
||||
|
||||
## 错误流总览
|
||||
|
||||
```
|
||||
设置页 terminalShell/Args(或 yaml config.shell/Args)
|
||||
→ shellOverridesOf:unquotePath(shell) + splitShellArgs(args)
|
||||
→ ptyManager.open / registry.create
|
||||
→ resolveShellExecutable:win32 既有探测 ∥ POSIX 新探测
|
||||
→ 找不到:SidebarError('shell-not-found', msg, meta:{shell})
|
||||
├─ WS 路径:catch → close(1011, 'shell-not-found:<name>') → TerminalView 本地化横幅 + 设置指引
|
||||
└─ 工具路径:terminal_create 抛英文消息 → 模型可见,自行纠正
|
||||
```
|
||||
|
||||
## 测试计划
|
||||
|
||||
- `tests/pty-helpers.spec.ts`:`splitShellArgs` 语义矩阵(普通分词 / 单双引号 / 含空格路径 / 反斜杠字面 / 未闭合宽容 / 空引号丢弃 / 连续空白);`unquotePath`(成对剥、不成对留);POSIX 探测(fake `options.env.PATH` + `options.exists`:命中解析、绝对路径缺失抛 `shell-not-found`);win32 既有用例保持绿。
|
||||
- `shellOverridesOf` 引号行为与 `shell.get` 跟随:就近扩展现有 spec,无宿主则新建。
|
||||
- 客户端横幅为纯展示逻辑,已有 TerminalView spec 则扩展,否则不强行 e2e。
|
||||
- 门禁:`pnpm typecheck` + `pnpm test`;不改 chunk 边界与 exclude 配置,mount e2e 不受影响。
|
||||
|
||||
## 实施偏差记录
|
||||
|
||||
- **报错文案合并为单 key**:设计时的 `terminalShellNotFound` + `terminalShellNotFoundHint` 两 key 合并为单个 `terminalShellNotFound`(含 `{name}` 占位符,一句话内含指引),避免跨语言的句子拼接问题。
|
||||
- **POSIX 旧契约用例被有意改写**:`tests/pty-helpers.spec.ts` 原用例 `keeps POSIX bare shell resolution delegated to execvp` 锁定「POSIX 裸名直通 execvp」,与本设计冲突,已删除并由 `POSIX: resolves bare names along PATH…` 等新用例替代。
|
||||
- **locale 落点为全部 21 词典**:设计原计划只写 zh/en/ja,实施时受 `tests/locales.spec.ts` 的「第三方词典键集必须与 zh 相等」门禁约束,`terminalShellNotFound` 实际落入 `locales.ts`(zh+en 双块)+ 19 个 `locales-*.ts`(共 21 文件 22 处),`chunks/locale.tsx` 的 `Record<CopyKey, string>` 类型检查兜底。
|
||||
- **`resolveShellExecutable` 的 JSDoc 同步**:win32 错误码从 `pty-error` 变更为 `shell-not-found` 后,函数上方文档一并更新(commit `8704994`)。
|
||||
- **本机全量测试基线**:`pnpm test` 在本机(Windows)存在 29 个**预存**环境性失败(5 个 client spec 的 jsdom `reading 'clear'` ×24、smoke 的 git 身份 ×2、git.spec 截断用例、agent-pty 的 node-pty「Signals not supported on windows」unhandled error)。经与 `origin/main` 同法全量对比确认**零新增失败**(main 30 个,feat 29 个——main 上多出的 `git-worktree` 用例为 flaky)。CI 以 CI 环境为准。
|
||||
|
||||
## 交付
|
||||
|
||||
- AGENTS 硬约束:`feat/*` 分支开发,commit 后 `gh pr create`,review 合并进 main;不直推。
|
||||
@@ -0,0 +1,645 @@
|
||||
# 终端 Shell 配置加固 实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 设置页/yaml 配置的终端 shell 支持引号参数与带引号路径,POSIX 与 Windows 一致地预检可执行文件,配置错误时终端标签显示本地化的可操作报错,且终端标题跟随设置覆盖。
|
||||
|
||||
**Architecture:** 全部改动在插件仓库内:`pty-manager.ts` 新增两个纯函数(`unquotePath`/`splitShellArgs`)并把 `resolveShellExecutable` 的探测泛化到 POSIX(统一抛新错误码 `shell-not-found`,`SidebarError` 加可选 `meta`);`index.ts` 的 `shellOverridesOf` 改用新解析器、`shell.get` 返回覆盖后的有效 shell、`attachTerminal` 的 catch 把错误映射为机器可读 WS close reason `shell-not-found:<名>`;`TerminalView.tsx` 识别该前缀渲染本地化横幅(全部 21 个语言词典同步新 key)。
|
||||
|
||||
**Tech Stack:** TypeScript(tsdown 构建,vitest 测试,无新依赖)
|
||||
|
||||
**Spec:** `docs/plans/2026-09-08-terminal-shell-config-hardening-design.md`(本计划与其同读;偏差回填其「实施偏差记录」节)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 禁止修改 DSH 源码(`~/.dsh/source/current` 零写入)。
|
||||
- 代码改动走 `feat/*` 分支 + `gh pr create`,不直推 main。
|
||||
- 不新增任何依赖;不改 `dependencies`/`peerDependencies`/`scripts`。
|
||||
- **i18n 键集相等是硬门禁**:新 key 必须同时加进 `locales.ts`(zh + en 两块)与全部 19 个第三方词典(`locales-ja/-de/-fr/-pt/-ko/-ar/-hi/-id/-tr/-vi/-th/-ru/-it/-nl/-sv/-pl/-zh-HK/-zh-TW/-zh-MO.ts`);`chunks/locale.tsx` 的 `Record<CopyKey, string>` 类型检查 + `tests/locales.spec.ts` 双重拦截。
|
||||
- 不改 vitest `exclude`;不改 chunk 边界(不新增 core bundle 的静态 import)。
|
||||
- 测试命令一律 `pnpm exec vitest run <file>`(仓库用 vitest + tsconfig `allowImportingTsExtensions`)。
|
||||
- 每个任务以 commit 结束,conventional commits(`feat(terminal): …` / `test(terminal): …`),正文可中文。
|
||||
|
||||
---
|
||||
|
||||
### Task 0: 建分支 + 提交设计与计划文档
|
||||
|
||||
**Files:**
|
||||
- Create: 分支 `feat/terminal-shell-quoted-args-probe`
|
||||
- 已存在待提交: `docs/plans/2026-09-08-terminal-shell-config-hardening-design.md`、`docs/plans/2026-09-08-terminal-shell-config-hardening-plan.md`
|
||||
|
||||
- [ ] **Step 1: 确认工作树干净并建分支**
|
||||
|
||||
```bash
|
||||
git status --porcelain # 期望为空(main 已与 origin/main 同步)
|
||||
git checkout -b feat/terminal-shell-quoted-args-probe
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit 设计与实施计划**(文档随 feat 分支走 PR,保持 PR 自洽)
|
||||
|
||||
```bash
|
||||
git add docs/plans/2026-09-08-terminal-shell-config-hardening-design.md docs/plans/2026-09-08-terminal-shell-config-hardening-plan.md
|
||||
git commit -m "docs(plans): 终端 shell 配置加固设计与实施计划"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `unquotePath` + `splitShellArgs` 纯函数(TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pty-manager.ts`(文件末尾 `shellSpawnArgs` 之后追加)
|
||||
- Test: `tests/pty-helpers.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `unquotePath(value: string): string`、`splitShellArgs(input: string): string[]`(均导出,Task 3 的 `shellOverridesOf` 消费)
|
||||
|
||||
- [ ] **Step 1: 写失败测试** — `tests/pty-helpers.spec.ts` 顶部 import 补两个名字(第 19-25 行的现有 import 块):
|
||||
|
||||
```ts
|
||||
import {
|
||||
defaultShell,
|
||||
ensureSpawnHelper,
|
||||
resolveShellExecutable,
|
||||
shellDisplayName,
|
||||
shellSpawnArgs,
|
||||
splitShellArgs,
|
||||
unquotePath,
|
||||
} from '../src/pty-manager.ts'
|
||||
```
|
||||
|
||||
在 `describe('pty helpers', () => {` 内、`'restores the spawn-helper executable bit idempotently'` 用例之后追加:
|
||||
|
||||
```ts
|
||||
it('unquotes a paired surrounding quote from a configured shell path', () => {
|
||||
expect(unquotePath('"C:\\Program Files\\PowerShell\\7\\pwsh.exe"'))
|
||||
.toBe('C:\\Program Files\\PowerShell\\7\\pwsh.exe')
|
||||
expect(unquotePath("'/usr/bin/my shell'")).toBe('/usr/bin/my shell')
|
||||
// Unpaired or single characters stay verbatim.
|
||||
expect(unquotePath('"mismatched')).toBe('"mismatched')
|
||||
expect(unquotePath('pwsh.exe')).toBe('pwsh.exe')
|
||||
expect(unquotePath('"')).toBe('"')
|
||||
expect(unquotePath('')).toBe('')
|
||||
})
|
||||
|
||||
it('splits shell args with quote-aware grouping', () => {
|
||||
expect(splitShellArgs('-NoLogo -File "C:\\my init\\init.ps1"'))
|
||||
.toEqual(['-NoLogo', '-File', 'C:\\my init\\init.ps1'])
|
||||
// Single quotes group too, and preserve inner double quotes verbatim.
|
||||
expect(splitShellArgs("-c 'echo \"hi\"'")).toEqual(['-c', 'echo "hi"'])
|
||||
expect(splitShellArgs(' -l ')).toEqual(['-l'])
|
||||
expect(splitShellArgs('')).toEqual([])
|
||||
expect(splitShellArgs(' ')).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps backslashes literal inside quotes and tolerates an unclosed quote', () => {
|
||||
// Backslash is NOT an escape (Windows paths): "C:\a\" ends with a slash.
|
||||
expect(splitShellArgs('"C:\\a\\"')).toEqual(['C:\\a\\'])
|
||||
// An unclosed quote folds the remainder into one token instead of erroring.
|
||||
expect(splitShellArgs('"unclosed quote')).toEqual(['unclosed quote'])
|
||||
// Empty quote pairs produce no empty-string argument.
|
||||
expect(splitShellArgs('"" x')).toEqual(['x'])
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `pnpm exec vitest run tests/pty-helpers.spec.ts`
|
||||
Expected: FAIL — `splitShellArgs`/`unquotePath` 不是导出成员(`SyntaxError` 或 `undefined is not a function`)。
|
||||
|
||||
- [ ] **Step 3: 最小实现** — `src/pty-manager.ts` 末尾(`shellSpawnArgs` 函数之后)追加:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Strip ONE pair of surrounding quotes from a configured shell path. Users
|
||||
* paste Windows paths with spaces pre-quoted (`"C:\Program Files\…"`); the
|
||||
* quotes are shell-input syntax, not part of the path. Unpaired quotes and
|
||||
* shorter values stay verbatim.
|
||||
*/
|
||||
export function unquotePath(value: string): string {
|
||||
if (value.length >= 2) {
|
||||
const first = value[0]
|
||||
const last = value[value.length - 1]
|
||||
if ((first === '"' && last === '"') || (first === "'" && last === "'")) return value.slice(1, -1)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a settings-page shell-arguments string into argv with quote-aware
|
||||
* grouping: `'…'` / `"…"` group whitespace, and characters inside quotes are
|
||||
* LITERAL — a backslash is never an escape, so Windows paths survive intact
|
||||
* (`-File "C:\my init\init.ps1"` → three tokens, the last containing spaces).
|
||||
* The price is that an argument containing a literal quote character cannot
|
||||
* be expressed; shell startup arguments never need one. An unclosed quote
|
||||
* folds the remainder into the current token (settings input stays
|
||||
* forgiving); an empty quote pair yields no argument.
|
||||
*/
|
||||
export function splitShellArgs(input: string): string[] {
|
||||
const args: string[] = []
|
||||
let current = ''
|
||||
let quote: '"' | "'" | null = null
|
||||
let started = false
|
||||
for (const ch of input) {
|
||||
if (quote !== null) {
|
||||
if (ch === quote) quote = null
|
||||
else current += ch
|
||||
continue
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch
|
||||
started = true
|
||||
continue
|
||||
}
|
||||
if (/\s/.test(ch)) {
|
||||
if (started) {
|
||||
args.push(current)
|
||||
current = ''
|
||||
started = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
current += ch
|
||||
started = true
|
||||
}
|
||||
if (started) args.push(current)
|
||||
return args.filter(arg => arg !== '')
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过**
|
||||
|
||||
Run: `pnpm exec vitest run tests/pty-helpers.spec.ts`
|
||||
Expected: PASS(全部用例)。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pty-manager.ts tests/pty-helpers.spec.ts
|
||||
git commit -m "feat(terminal): 引号感知的 shell 参数解析与路径去引号纯函数"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: POSIX 预检 + `shell-not-found` 错误码(TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/wire.ts:10-36`(错误码联合 + 构造器 meta)
|
||||
- Modify: `src/pty-manager.ts:351-400`(`resolveShellExecutable` POSIX 分支)
|
||||
- Test: `tests/pty-helpers.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 的 `unquotePath`
|
||||
- Produces: `SidebarErrorCode` 新值 `'shell-not-found'`;`SidebarError` 构造器第 4 参 `meta?: Record<string, string>`;`resolveShellExecutable` 在 POSIX 找不到时抛 `SidebarError('shell-not-found', 'shell executable not found: "<configured>"', 400, { shell: configured })`(Task 3 的 WS catch 消费)
|
||||
|
||||
- [ ] **Step 1: 写失败测试** — `tests/pty-helpers.spec.ts` 追加(import 区补 `SidebarError`:`import { SidebarError } from '../src/wire.ts'`):
|
||||
|
||||
```ts
|
||||
it('POSIX: resolves bare names along PATH and names a missing configured shell', () => {
|
||||
const options = {
|
||||
platform: 'linux' as const,
|
||||
env: { PATH: '/usr/local/bin:/usr/bin' },
|
||||
exists: (path: string) => path === join('/usr/bin', 'zsh'),
|
||||
}
|
||||
expect(resolveShellExecutable('zsh', options)).toBe(join('/usr/bin', 'zsh'))
|
||||
const thrown = (() => {
|
||||
try {
|
||||
resolveShellExecutable('nope', options)
|
||||
return undefined
|
||||
} catch (error) {
|
||||
return error
|
||||
}
|
||||
})()
|
||||
expect(thrown).toBeInstanceOf(SidebarError)
|
||||
expect((thrown as SidebarError).code).toBe('shell-not-found')
|
||||
expect((thrown as SidebarError).message).toBe('shell executable not found: "nope"')
|
||||
expect((thrown as SidebarError).meta?.shell).toBe('nope')
|
||||
})
|
||||
|
||||
it('POSIX: checks an absolute path exists and passes it through verbatim', () => {
|
||||
expect(resolveShellExecutable('/explicit/zsh', { platform: 'linux', env: {}, exists: () => true }))
|
||||
.toBe('/explicit/zsh')
|
||||
expect(() => resolveShellExecutable('/missing/zsh', { platform: 'linux', env: {}, exists: () => false }))
|
||||
.toThrow('shell executable not found: "/missing/zsh"')
|
||||
})
|
||||
|
||||
it('unquotes the configured shell before resolving (Windows probe included)', () => {
|
||||
const options = {
|
||||
platform: 'win32' as const,
|
||||
env: { PATH: 'C:\\Tools' },
|
||||
exists: (path: string) => path.replaceAll('\\', '/') === 'C:/Tools/pwsh.exe',
|
||||
}
|
||||
expect(resolveShellExecutable('"pwsh.exe"', options).replaceAll('\\', '/')).toBe('C:/Tools/pwsh.exe')
|
||||
})
|
||||
```
|
||||
|
||||
**同时改写旧契约用例**(第 115-117 行 `'keeps POSIX bare shell resolution delegated to execvp'`)——该用例锁定的正是本任务**有意变更**的行为,整段替换为上面第一个新用例即可(删除旧用例)。
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `pnpm exec vitest run tests/pty-helpers.spec.ts`
|
||||
Expected: FAIL — `'nope'` 原样返回而非抛错(旧 POSIX 直通行为),且 `SidebarError` 无 `meta` 属性。
|
||||
|
||||
- [ ] **Step 3: wire.ts 加错误码与 meta** — `src/wire.ts`:
|
||||
|
||||
```ts
|
||||
/** Machine-readable error codes of the sidebar API. */
|
||||
export type SidebarErrorCode =
|
||||
| 'bad-request'
|
||||
| 'not-found'
|
||||
| 'forbidden'
|
||||
| 'method-error'
|
||||
| 'too-large'
|
||||
| 'fs-error'
|
||||
| 'git-error'
|
||||
| 'pty-error'
|
||||
| 'pty-deps-missing'
|
||||
| 'shell-not-found'
|
||||
| 'job-error'
|
||||
| 'sidechat-error'
|
||||
| 'subagents-unavailable'
|
||||
| 'settings-rejected'
|
||||
| 'settings-conflict'
|
||||
| 'internal'
|
||||
|
||||
/** One API failure with its wire code and HTTP status. */
|
||||
export class SidebarError extends Error {
|
||||
constructor(
|
||||
readonly code: SidebarErrorCode,
|
||||
message: string,
|
||||
readonly status = 400,
|
||||
/** Optional structured context (e.g. `{ shell }` for shell-not-found). */
|
||||
readonly meta?: Record<string, string>,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: pty-manager.ts 泛化探测** — `resolveShellExecutable` 整体替换为:
|
||||
|
||||
```ts
|
||||
export function resolveShellExecutable(
|
||||
shell: string,
|
||||
options: ShellExecutableResolutionOptions = {},
|
||||
): string {
|
||||
const configured = unquotePath(shell.trim())
|
||||
const platform = options.platform ?? process.platform
|
||||
if (configured === '') return configured
|
||||
|
||||
const env = options.env ?? process.env
|
||||
const exists = options.exists ?? existsSync
|
||||
const notFound = (): SidebarError =>
|
||||
new SidebarError('shell-not-found', `shell executable not found: "${configured}"`, 400, { shell: configured })
|
||||
|
||||
if (platform === 'win32') {
|
||||
const rawPathext = windowsEnv(env, 'PATHEXT')
|
||||
const executableExts = (rawPathext ?? '.COM;.EXE')
|
||||
.split(';')
|
||||
.map(extension => extension.trim())
|
||||
// node-pty ultimately calls CreateProcess; batch files need an
|
||||
// intermediate cmd.exe and therefore are not valid shell executables.
|
||||
.filter(extension => /^\.(?:com|exe)$/i.test(extension))
|
||||
if (executableExts.length === 0) executableExts.push('.EXE', '.COM')
|
||||
|
||||
const hasExtension = win32Path.extname(configured) !== ''
|
||||
const names = hasExtension
|
||||
? [configured]
|
||||
: executableExts.map(extension => configured + extension.toLowerCase())
|
||||
const hasPath = win32Path.isAbsolute(configured) || /[\\/]/.test(configured)
|
||||
const candidates: string[] = []
|
||||
if (hasPath) {
|
||||
candidates.push(...names)
|
||||
} else {
|
||||
const path = windowsEnv(env, 'PATH')
|
||||
if (path !== undefined) {
|
||||
for (const dir of path.split(';').map(entry => entry.trim()).filter(Boolean)) {
|
||||
for (const name of names) candidates.push(win32Path.join(dir, name))
|
||||
}
|
||||
}
|
||||
const systemRoot = windowsEnv(env, 'SystemRoot')
|
||||
if (systemRoot !== undefined && systemRoot.trim() !== '') {
|
||||
for (const name of names) candidates.push(win32Path.join(systemRoot, 'System32', name))
|
||||
}
|
||||
if (/^pwsh(?:\.exe)?$/i.test(configured)) {
|
||||
for (const dir of windowsPwshCandidateDirs(env)) {
|
||||
candidates.push(win32Path.join(dir, 'pwsh.exe'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of [...new Set(candidates)]) {
|
||||
if (exists(candidate)) return candidate
|
||||
}
|
||||
throw notFound()
|
||||
}
|
||||
|
||||
// POSIX: the previous pass-through delegated a wrong name to execvp and the
|
||||
// pty died with a bare "[process exited with code N]". Probe like Windows:
|
||||
// a path with a separator must exist; a bare name is searched along PATH
|
||||
// (the colon form is fixed by the platform). A miss is a clear, actionable
|
||||
// error instead of a cryptic exit code.
|
||||
if (configured.includes('/')) {
|
||||
if (!exists(configured)) throw notFound()
|
||||
return configured
|
||||
}
|
||||
const path = env.PATH ?? '/usr/bin:/bin'
|
||||
for (const dir of path.split(':').map(entry => entry.trim()).filter(Boolean)) {
|
||||
const candidate = join(dir, configured)
|
||||
if (exists(candidate)) return candidate
|
||||
}
|
||||
throw notFound()
|
||||
}
|
||||
```
|
||||
|
||||
确认 `src/pty-manager.ts` 已有 `join`(node:path)与 `SidebarError` import(`SidebarError` 来自 `./wire.ts`——若尚无此 import 则补 `import { SidebarError } from './wire.ts'`)。
|
||||
|
||||
- [ ] **Step 5: 跑测试确认通过**
|
||||
|
||||
Run: `pnpm exec vitest run tests/pty-helpers.spec.ts`
|
||||
Expected: PASS。随后跑全量确认无连带破坏:`pnpm exec vitest run tests/smoke.spec.ts tests/tools.spec.ts`(POSIX 预检改变了裸名缺失时的行为——若 smoke/tools 有依赖直通的用例,按新契约修正其期望值并在 commit 正文注明)。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/wire.ts src/pty-manager.ts tests/pty-helpers.spec.ts
|
||||
git commit -m "feat(terminal): POSIX 也预检 shell 可执行文件,统一 shell-not-found 错误码"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: host 接线——覆盖解析、shell.get 跟随、WS close reason(TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/index.ts:248-259`(`shellOverridesOf`)、`:562-566`(`shell.get`)、`:1306-1308`(`attachTerminal` catch)、新增导出函数 `wsCloseReasonOf`
|
||||
- Test: `tests/smoke.spec.ts:823-881`(fake settings)+ 新用例
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 `unquotePath`/`splitShellArgs`;Task 2 `SidebarError.meta` + `'shell-not-found'`
|
||||
- Produces: `shellOverridesOf` 返回 `{ shell?: string; shellArgs?: string[] }`(引号已处理);`wsCloseReasonOf(error: unknown): string`(导出,`shell-not-found` → `shell-not-found:<displayName≤100字符>`,其余 → message/String);WS close reason 线协议前缀 `shell-not-found:`(Task 4 客户端镜像)
|
||||
|
||||
- [ ] **Step 1: 写失败测试** — `tests/smoke.spec.ts`:
|
||||
|
||||
(a) fake settings 的 `register` 保留预置值(第 837-840 行)——替换为:
|
||||
|
||||
```ts
|
||||
register(ns: string, schema: unknown) {
|
||||
// Preserve a pre-seeded value: tests stage prefs through the `pre`
|
||||
// map before the plugin mounts and registers the same namespace.
|
||||
const existing = namespaces.get(ns)
|
||||
namespaces.set(ns, { schema, value: existing?.value ?? undefined, revision: 0 })
|
||||
return { get: () => ({}), watch: () => () => {}, update: async () => {}, replace: async () => {} }
|
||||
},
|
||||
```
|
||||
|
||||
(b) `'side card settings routes'` describe 内追加(`invoke`/`mountWithSettings` 复用):
|
||||
|
||||
```ts
|
||||
it('shell.get reflects the settings-page override with the quotes stripped', async () => {
|
||||
const route = mountWithSettings(createFakeSettings({
|
||||
'dsh-better-sidebar': {
|
||||
terminalShell: '"C:\\Program Files\\PowerShell\\7\\pwsh.exe"',
|
||||
terminalShellArgs: '-NoLogo',
|
||||
},
|
||||
}))
|
||||
const result = await invoke(route, 'shell.get', {})
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.value).toMatchObject({
|
||||
shell: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
|
||||
name: 'pwsh',
|
||||
})
|
||||
})
|
||||
|
||||
it('maps a shell-not-found failure to the machine-readable close reason', async () => {
|
||||
expect(wsCloseReasonOf(new SidebarError(
|
||||
'shell-not-found',
|
||||
'shell executable not found: "C:\\Program Files\\PowerShell\\7\\pwsh.exe"',
|
||||
400,
|
||||
{ shell: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' },
|
||||
))).toBe('shell-not-found:pwsh')
|
||||
expect(wsCloseReasonOf(new Error('boom'))).toBe('boom')
|
||||
expect(wsCloseReasonOf('plain')).toBe('plain')
|
||||
})
|
||||
```
|
||||
|
||||
import 区补:`wsCloseReasonOf` 取自 `'../src/index.ts'`(该文件已 import `apply`——并入同一 import 语句),`SidebarError` 取自 `'../src/wire.ts'`。
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `pnpm exec vitest run tests/smoke.spec.ts`
|
||||
Expected: FAIL — `shell.get` 返回启动时 shell 而非覆盖值;`wsCloseReasonOf` 未导出。
|
||||
|
||||
- [ ] **Step 3: 实现 index.ts 三处**
|
||||
|
||||
(a) `shellOverridesOf`(第 248-259 行)——解析走新函数:
|
||||
|
||||
```ts
|
||||
function shellOverridesOf(getSettings: () => SidebarSettingsFace | undefined): { shell?: string; shellArgs?: string[] } {
|
||||
const settings = getSettings()
|
||||
const value = settings?.get().value
|
||||
if (value === null || typeof value !== 'object') return {}
|
||||
const record = value as Record<string, unknown>
|
||||
const shell = typeof record.terminalShell === 'string' ? unquotePath(record.terminalShell.trim()) : ''
|
||||
const args = typeof record.terminalShellArgs === 'string' ? record.terminalShellArgs.trim() : ''
|
||||
return {
|
||||
shell: shell === '' ? undefined : shell,
|
||||
shellArgs: args === '' ? undefined : splitShellArgs(args),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(`unquotePath`/`splitShellArgs` 并入第 48 行现存的 `'./pty-manager.ts'` import。)
|
||||
|
||||
(b) `shell.get`(第 562-566 行)——注释与返回值更新:
|
||||
|
||||
```ts
|
||||
// The effective terminal shell and its display name: the settings-page
|
||||
// override when set (quotes stripped), else the boot-time resolution.
|
||||
// The client titles terminal tabs with the name, so a changed setting is
|
||||
// visible on the next opened tab without a plugin restart.
|
||||
'shell.get': () => {
|
||||
const effective = shellOverridesOf(getSettings).shell ?? terminalShell
|
||||
return { shell: effective, name: shellDisplayName(effective) }
|
||||
},
|
||||
```
|
||||
|
||||
(c) `attachTerminal` 的 catch(第 1306-1308 行)改用映射函数,并在 `attachTerminal` 上方新增导出:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* The WS close reason for a failed terminal attach. A missing configured
|
||||
* shell gets a SHORT machine-readable marker (`shell-not-found:<name>`,
|
||||
* capped — a WS close reason allows at most 123 bytes) that the client maps
|
||||
* to a localized, actionable banner; every other failure keeps the raw
|
||||
* message (the model-side tool errors read it verbatim).
|
||||
*/
|
||||
export function wsCloseReasonOf(error: unknown): string {
|
||||
if (error instanceof SidebarError && error.code === 'shell-not-found') {
|
||||
const name = shellDisplayName(String(error.meta?.shell ?? '')).slice(0, 100)
|
||||
return `shell-not-found:${name}`
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
```
|
||||
|
||||
catch 体改为:
|
||||
|
||||
```ts
|
||||
} catch (error) {
|
||||
ws.close(1011, wsCloseReasonOf(error))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过**
|
||||
|
||||
Run: `pnpm exec vitest run tests/smoke.spec.ts`
|
||||
Expected: PASS(含既有 `'serves the effective terminal shell and its display name'`——无设置时行为不变)。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/index.ts tests/smoke.spec.ts
|
||||
git commit -m "feat(terminal): shell 覆盖去引号进解析、标题跟随覆盖、close reason 机器标记"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 客户端本地化横幅 + 全语言词典(typecheck 守门)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/client/TerminalView.tsx:62`(镜像常量)、`:224-250`(onclose 分支)
|
||||
- Modify: `src/client/locales.ts`(zh 块 `:120` 后、en 块 `:569` 后各 1 key)
|
||||
- Modify: 19 个第三方词典各 1 key:`locales-ja.ts:108`、`locales-de.ts`、`-fr`、`-pt`、`-ko`、`-ar`、`-hi`、`-id`、`-tr`、`-vi`、`-th`、`-ru`、`-it`、`-nl`、`-sv`、`-pl`、`-zh-HK`、`-zh-TW`、`-zh-MO`(均插在各自 `terminalDepsProfile` 行后)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 3 的线协议前缀 `shell-not-found:`
|
||||
- Produces: locale key `terminalShellNotFound`(占位符 `{name}`);`TerminalView` 的 fatal 横幅文本
|
||||
|
||||
- [ ] **Step 1: TerminalView 识别前缀** — `src/client/TerminalView.tsx` 第 62 行 `PTY_DEPS_MISSING` 常量后追加:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* The WS close-reason prefix the host sends when the CONFIGURED shell was
|
||||
* not found (mirror of src/index.ts wsCloseReasonOf; wire contract, keep the
|
||||
* literal in lockstep). The view renders a localized, actionable banner.
|
||||
*/
|
||||
const SHELL_NOT_FOUND_PREFIX = 'shell-not-found:'
|
||||
```
|
||||
|
||||
`socket.onclose`(第 224 行起)在 `PTY_DEPS_MISSING` 分支之后、通用 1011 分支(第 247 行)之前插入:
|
||||
|
||||
```ts
|
||||
// The configured shell could not be found (settings page or yaml):
|
||||
// a localized banner beats the raw English close reason.
|
||||
if (event.code === 1011 && event.reason.startsWith(SHELL_NOT_FOUND_PREFIX)) {
|
||||
setFatal(t('terminalShellNotFound', { name: event.reason.slice(SHELL_NOT_FOUND_PREFIX.length) || '?' }))
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 加全语言 key**(21 个词典文件,各在 `terminalDepsProfile` 行后插 1 行;键集由 `chunks/locale.tsx` 的 `Record<CopyKey, string>` 与 `tests/locales.spec.ts` 双重把关,漏一个文件 typecheck 即红):
|
||||
|
||||
```ts
|
||||
// locales.ts zh 块:
|
||||
terminalShellNotFound: '未找到配置的 Shell:{name},请到 设置 → 侧边卡片 → 终端 检查 Shell 路径',
|
||||
// locales.ts en 块:
|
||||
terminalShellNotFound: 'Configured shell not found: {name} — check the shell path under Settings → Side card → Terminal',
|
||||
// locales-ja.ts:
|
||||
terminalShellNotFound: '設定されたシェルが見つかりません:{name}(設定 → サイドカード → ターミナル のシェルパスを確認してください)',
|
||||
// locales-de.ts:
|
||||
terminalShellNotFound: 'Konfigurierte Shell nicht gefunden: {name} — Shell-Pfad unter Einstellungen → Side card → Terminal prüfen',
|
||||
// locales-fr.ts:
|
||||
terminalShellNotFound: 'Shell configuré introuvable : {name} — vérifiez le chemin du shell dans Réglages → Side card → Terminal',
|
||||
// locales-pt.ts:
|
||||
terminalShellNotFound: 'Shell configurado não encontrado: {name} — verifique o caminho do shell em Configurações → Side card → Terminal',
|
||||
// locales-ko.ts:
|
||||
terminalShellNotFound: '설정된 셸을 찾을 수 없습니다: {name} — 설정 → 사이드 카드 → 터미널에서 셸 경로를 확인하세요',
|
||||
// locales-ar.ts:
|
||||
terminalShellNotFound: 'لم يتم العثور على الصدفة المُعدَّة: {name} — تحقق من مسار الصدفة في الإعدادات → Side card → Terminal',
|
||||
// locales-hi.ts:
|
||||
terminalShellNotFound: 'कॉन्फ़िगर किया गया shell नहीं मिला: {name} — Settings → Side card → Terminal में shell पथ जाँचें',
|
||||
// locales-id.ts:
|
||||
terminalShellNotFound: 'Shell yang dikonfigurasi tidak ditemukan: {name} — periksa path shell di Pengaturan → Side card → Terminal',
|
||||
// locales-tr.ts:
|
||||
terminalShellNotFound: 'Yapılandırılan kabuk bulunamadı: {name} — Ayarlar → Side card → Terminal altındaki kabuk yolunu denetleyin',
|
||||
// locales-vi.ts:
|
||||
terminalShellNotFound: 'Không tìm thấy shell đã cấu hình: {name} — kiểm tra đường dẫn shell trong Cài đặt → Side card → Terminal',
|
||||
// locales-th.ts:
|
||||
terminalShellNotFound: 'ไม่พบ shell ที่กำหนดไว้: {name} — ตรวจสอบพาธ shell ใน การตั้งค่า → Side card → Terminal',
|
||||
// locales-ru.ts:
|
||||
terminalShellNotFound: 'Настроенная оболочка не найдена: {name} — проверьте путь к оболочке в Настройки → Side card → Terminal',
|
||||
// locales-it.ts:
|
||||
terminalShellNotFound: 'Shell configurata non trovata: {name} — verifica il percorso della shell in Impostazioni → Side card → Terminal',
|
||||
// locales-nl.ts:
|
||||
terminalShellNotFound: 'Geconfigureerde shell niet gevonden: {name} — controleer het shell-pad onder Instellingen → Side card → Terminal',
|
||||
// locales-sv.ts:
|
||||
terminalShellNotFound: 'Konfigurerat skal hittades inte: {name} — kontrollera skalsökvägen under Inställningar → Side card → Terminal',
|
||||
// locales-pl.ts:
|
||||
terminalShellNotFound: 'Nie znaleziono skonfigurowanej powłoki: {name} — sprawdź ścieżkę powłoki w Ustawienia → Side card → Terminal',
|
||||
// locales-zh-HK.ts:
|
||||
terminalShellNotFound: '未找到配置的 Shell:{name},請到 設定 → 側邊卡片 → 終端 檢查 Shell 路徑',
|
||||
// locales-zh-TW.ts:
|
||||
terminalShellNotFound: '未找到配置的 Shell:{name},請到 設定 → 側邊卡片 → 終端 檢查 Shell 路徑',
|
||||
// locales-zh-MO.ts:
|
||||
terminalShellNotFound: '未找到配置的 Shell:{name},請到 設定 → 側邊卡片 → 終端 檢查 Shell 路徑',
|
||||
```
|
||||
|
||||
- [ ] **Step 3: typecheck 守门**
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: PASS —— 若漏改任何一个第三方词典,`chunks/locale.tsx` 的 `checked(dict: Record<CopyKey, string>)` 直接编译失败。
|
||||
|
||||
- [ ] **Step 4: 跑 locale 契约测试**
|
||||
|
||||
Run: `pnpm exec vitest run tests/locales.spec.ts`
|
||||
Expected: PASS(含 `keeps every shipped third-language dictionary key-set-equal to zh`)。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/client/TerminalView.tsx src/client/locales.ts src/client/locales-*.ts
|
||||
git commit -m "feat(terminal): 配置 shell 未找到时显示本地化可操作横幅(全语言)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 全量门禁 + 设计文档偏差回填 + PR
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/plans/2026-09-08-terminal-shell-config-hardening-design.md`(「实施偏差记录」节回填)
|
||||
|
||||
- [ ] **Step 1: 全量门禁**
|
||||
|
||||
Run: `pnpm typecheck && pnpm test`
|
||||
Expected: PASS。若 `tests/tools.spec.ts` / e2e 有依赖旧 POSIX 直通行为的用例失败,按新契约修期望值并单独 commit(`test(terminal): 跟随 POSIX 预检新契约`)。
|
||||
|
||||
- [ ] **Step 2: 文档核对与偏差回填**
|
||||
|
||||
Run: `rg -n "terminalShell|shellArgs" docs/ README.md`
|
||||
Expected: 仅设计/计划文档命中(`docs/external-plugin-guide.md` 不记载设置页 shell 行,无需同步;若出现命中则按 AGENTS §5 同步该文档)。回填设计文档「实施偏差记录」:① `terminalShellNotFoundHint` 并入单 key `terminalShellNotFound`(含 `{name}` 占位);② 旧用例 `keeps POSIX bare shell resolution delegated to execvp` 被有意改写;③ 新增 locale 键实际落点为全部 21 词典(不止 zh/en/ja)。Commit:
|
||||
|
||||
```bash
|
||||
git add docs/plans/2026-09-08-terminal-shell-config-hardening-design.md
|
||||
git commit -m "docs(plans): 回填终端 shell 配置加固实施偏差"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 推送并建 PR**
|
||||
|
||||
```bash
|
||||
git push -u origin feat/terminal-shell-quoted-args-probe
|
||||
gh pr create --title "feat(terminal): 终端 shell 配置加固——引号解析、POSIX 预检、本地化报错" --body-file - <<'EOF'
|
||||
## 概要
|
||||
配置启动终端命令的功能已存在(#95/#125),本 PR 加固三处缺口:
|
||||
1. 设置页 shell 参数支持引号分组(`-File "C:\my init\init.ps1"` 正确成 3 个 argv);shell 路径自动剥成对引号
|
||||
2. POSIX 预检可执行文件(对齐 Windows 既有探测),统一 `shell-not-found` 错误码
|
||||
3. 配置错误时终端标签渲染本地化可操作横幅(21 语言),终端标题跟随设置覆盖
|
||||
|
||||
## 测试
|
||||
- `tests/pty-helpers.spec.ts`:splitShellArgs/unquotePath 语义矩阵 + POSIX 探测
|
||||
- `tests/smoke.spec.ts`:shell.get 覆盖跟随 + wsCloseReasonOf 映射
|
||||
- `pnpm typecheck` + `pnpm test` 全绿
|
||||
EOF
|
||||
```
|
||||
|
||||
Expected: PR URL 返回。
|
||||
|
||||
- [ ] **Step 4: 汇报** — 向用户回报 PR 链接、改动文件清单、行为对照(改前/改后)。
|
||||
@@ -0,0 +1,90 @@
|
||||
# DSH 0.1.5-alpha.2 适配(v0.19.0-alpha.1 基线)
|
||||
|
||||
日期:2026-09-09 分支:`feat/dsh-0.1.5-alpha.2`
|
||||
|
||||
## 背景
|
||||
|
||||
DSH `dsh-v0.1.5-alpha.1..dsh-v0.1.5-alpha.2` 的宿主契约里有一批会实际波及本插件的变更。本计划记录哪些值得处理、哪些只做兼容、以及每一处的子系统级落点。插件对外的全部行为差异以 [docs/external-plugin-guide.md](../external-plugin-guide.md) 为准;仓库级适配要点(基线、CI 钉版、宿主契约)同步到 [AGENTS.md](../AGENTS.md) §3。
|
||||
|
||||
## 上游 delta(alpha.1 → alpha.2,影响本插件的六项)
|
||||
|
||||
| # | 上游变更 | 对插件的影响 |
|
||||
|---|---|---|
|
||||
| 1 | `SidebarRightGuideEntry` 去掉 `description` 字段;指南条目变为「图标 + 标题」胶囊 | 删除 `TabDescriptor.description` 与 6 个 `guideDesc*` 词典键(连同 `nativeGuideDesc` 兜底句——不再有第二行说明) |
|
||||
| 2 | 新增**全局面板**:根级 keyed `main` 槽(预留 key `conversation`,由 ui-conversation 注册为 `main.conversation`)、根级 `sidebar.panellist` 列表槽(`SidebarPanelMetadata` / `SidebarPanelIconOwnerProps`)、`ctx.layout.selectPanel(MainPanelId|null)` / `beginNavigation()` / `dispose()`、全局标准 prop `usePanelInfo`;`rightbar` 改根级并新增会话级 `rightbar.session` 子槽 | 插件**不接入**(兼容 only) |
|
||||
| 3 | 原生 tab 体宿主 `.paneBody`(`dsh-client-ui-dockkit`)是**有确定高度的块级滚动容器**,不是 flex 容器;DSH 自己的 tab 体(`ui-sidebar-documentpreview` 的文档预览、`ui-sidebar-files` 的 FilesBody)在根上声明 `height: 100%` | 原生适配层给每个 tab 体包一层 `height: 100%` 的列 flex 宿主(`sidebar.module.css` `.nativeTabHost`,带 `data-dsh-native-tab-host`),修复 sidechat 输入框贴不到面板底的根塌陷 |
|
||||
| 4 | 文件地址语法:`fileAddressFor` 一律返回 session 作用域地址(绝对路径保留前导 `/`);`parseFileAddress` 前缀解析并去 `?`/`#`;`absoluteFileAddress` 仍在 | 镜像 alpha.2 语法(插件自实现于 `src/client/resource-address.ts`——客户端 bundle 纯度门禁禁 value-import 未列名的 `@deepseek-ai/*`,不能直接引 DSH 工具包) |
|
||||
| 5 | `ui-sidebar-textpreview` 被 `ui-sidebar-documentpreview` 取代(同 `text` kind、`fallback` 带、`dsh-resource://file/**` 模式) | 插件 `editor` 仍以 `extension` 优先级认领 `dsh-resource://file/**`,压过其 `text` 的 `fallback` 带 |
|
||||
| 6 | 基线升到 0.1.5-alpha.2(peer 下限 / manifest / CI 钉版) | 插件基线 = alpha.2 only;alpha.1 用户留在 v0.19.0-alpha.0 |
|
||||
|
||||
> 后续(2026-09-10):`SidebarRightGuideEntry.description` 在 DSH 0.1.5-rc.1 回归(可选,且宿主只在 guide 列出的条目 ≤ 4 条时渲染),插件已在 **v0.19.0** 恢复该字段——见 [2026-09-10-dsh-0.1.5-rc.1-adaptation.md](./2026-09-10-dsh-0.1.5-rc.1-adaptation.md)。
|
||||
>
|
||||
> 后续(2026-09-10):退役自绘右侧面板(#605)时,`use-host-feeds.ts` 的三处自动激活被误改成 `target: 'bottom'`,任务页因此落到插件自己的底部工作台而非 DSH 原生右侧栏。修复见 [2026-09-10-auto-activation-native-landing-fix.md](./2026-09-10-auto-activation-native-landing-fix.md)。
|
||||
|
||||
## 决策
|
||||
|
||||
- **基线 = alpha.2 only**:peer 下限 `^0.1.5-alpha.2`、manifest `>=0.1.5-alpha.2`、CI 钉 `@deepseek-ai/dsh@0.1.5-alpha.2`;不再对 alpha.1 做运行时兼容。
|
||||
- **全局面板:兼容 only,不接入**——不迁移到 `main` 槽 / `sidebar.panellist` / `ctx.layout` / `usePanelInfo` / 根级 `rightbar`,仅保证在 alpha.2 宿主上不破坏。
|
||||
- **删除 `TabDescriptor.description`**:宿主不再渲染第二行,字段留着只会在类型上撒谎;同步删 6 个 `guideDesc*` 与 `nativeGuideDesc` 兜底句。
|
||||
- **修复 tab 体填充**:native 适配层为每个 tab 体(含 orphaned 回退)包 `height:100%` 列 flex 宿主 `.nativeTabHost`;tab 组件根遵循 `flex:1` / `height:100%` + `min-height:0` 契约。
|
||||
- **镜像 alpha.2 地址语法**:`fileAddressFor` 不再产生 `absolute` 作用域(但仍解析旧地址与第三方地址),`parseFileAddress` 前缀解析并去 `?`/`#`。
|
||||
- **中心列锚点跟随 alpha.2**:`center-column.ts` 的 `CENTER_COLUMN_SELECTOR` 改为 `#root [data-slot="main.conversation"], #root [data-slot="conversation"]`,`resolveCenterColumn` 从对话槽宿主向上跳过 `display: contents` 祖先取真实列;alpha.1 的 `[data-slot="conversation"]` 依旧认可。
|
||||
|
||||
## 改动清单(子系统级)
|
||||
|
||||
| 子系统 | 文件 | 变更 |
|
||||
|---|---|---|
|
||||
| 基线 | `package.json` | 版本 → `0.19.0-alpha.1`;dsh-* peer 全部 `^0.1.5-alpha.2`;devDeps 同步钉 `0.1.5-alpha.2` |
|
||||
| 基线 | `pnpm-lock.yaml` | 锁文件对齐 alpha.2 |
|
||||
| 基线 | `dsh.plugin.json` | `version` → `0.19.0-alpha.1`;`engines.dsh` → `>=0.1.5-alpha.2` |
|
||||
| 基线 | `.github/workflows/ci.yml` | mount lane 钉 `@deepseek-ai/dsh@0.1.5-alpha.2` |
|
||||
| 基线 | `package.json` | 按 §3-9 补提 `dsh-session-persistence`(`pnpm peers check` 唯一新失配项) |
|
||||
| 基线 | `package.json` | **上游打包缺陷**:`@deepseek-ai/dsh-client-ui-primitives@0.1.5-alpha.2` 的 manifest 不再声明任何 `dependencies`(alpha.1 声明了 19 个),但其 `lib/*.js` 仍裸 import `anser` / `shiki` / `@shikijs/langs/*` / `mdast-util-*` / `micromark-*` / `katex`。宿主自身由预构建前端 bundle 满足,独立安装的消费方(本仓库的 dev/test 树)必须自备,故把 alpha.1 声明的同一组版本提升进 devDependencies(`@shikijs/langs`、`@types/mdast`、`anser`、`katex`、`mdast-util-from-markdown/gfm/math`、`micromark-core-commonmark/extension-gfm/extension-math/factory-space/util-character/util-classify-character/util-sanitize-uri/util-symbol/util-types`、`shiki`;`clsx` 已在 dependencies) |
|
||||
| 基线/文档 | `AGENTS.md` | §2/§3 基线戳、§10 补 alpha.2 行为差异(本 workstream 回填) |
|
||||
| Tab 契约 | `src/client/service.ts` | `TabDescriptor` 删除 `description` 字段;`SIDEBAR_SERVICE_VERSION` → `0.19.0-alpha.1` |
|
||||
| Tab 契约 | `src/client/builtins/tabs.tsx` | 6 个内置 tab 删除 `description` 行 |
|
||||
| Tab 契约 | `src/client/native/index.ts` | native 注册的 guide 条目去掉 `description`(删 `descriptionOf` 与通用兜底);`guide` 条目只剩 `order`/`title`/`icon` |
|
||||
| 词典 | `src/client/locales.ts` + 各 `locales-*.ts` | 删除 `nativeGuideDesc` + 6 个 `guideDesc*` 词条(zh/en + 全部第三方语言) |
|
||||
| Tab 高度 | `src/client/native/tab-adapter.tsx` | `NativeTabBody` 把 live 组件与 orphaned 回退都包进 `css.nativeTabHost`(带 `data-dsh-native-tab-host`) |
|
||||
| Tab 高度 | `src/client/sidebar.module.css` | 新增 `.nativeTabHost`(`display:flex; flex-direction:column; height:100%; min-height:0`) |
|
||||
| 地址语法 | `src/client/resource-address.ts` | 重写为镜像 alpha.2 文件地址语法(`fileAddressFor` 不再产 `absolute` 作用域、`parseFileAddress` 前缀去 `?`/`#`、保留 `absolute` 解析) |
|
||||
| 中心列锚点 | `src/client/center-column.ts` | `CENTER_COLUMN_SELECTOR` 加 `main.conversation`;新增 `columnOfSlotHost` 跳过 `display: contents` 祖先 |
|
||||
| 中心列锚点 | `src/client/sidebar/use-center-column.ts` | 宿主查找逻辑对齐 alpha.2 |
|
||||
| 中心列锚点 | `src/client/layout.css` | 选择器/注释同步 `main.conversation` 与 `display: contents` 跳过 |
|
||||
| 测试 | `tests/builtins.spec.ts` | 指南断言从 `description` 改成 `title`(图标+标题胶囊) |
|
||||
| 测试 | `tests/native-surface.spec.ts` | 注册/guide 断言去 `description`;断言 guide 条目不再携带 `description` |
|
||||
| 测试 | `tests/center-column-locator.spec.ts` | 覆盖 `main.conversation` 解析与 `display: contents` 跳过 |
|
||||
| 测试 | `tests/resource-address.spec.ts` | 覆盖 alpha.2 地址语法 |
|
||||
| 测试 | `tests/e2e/mount.e2e.ts` | guide 页断言跟随胶囊形态;新增 tab 体填充断言(可见 tab 的 `[data-dsh-native-tab-host]` 盒高 == 其带盒祖先高 ±2px,且 sidechat composer 底边距面板底 ≤12px) |
|
||||
| 文档 | `docs/external-plugin-guide.md` | §0 行为差异表、§4.1 删 `description`、§10 加 tab 体高度契约、§12 中心列锚点、`ui-sidebar-documentpreview` |
|
||||
| 文档 | `docs/plans/2026-09-09-dsh-0.1.5-alpha.2-adaptation.md` | 本计划 |
|
||||
|
||||
## 已知差距
|
||||
|
||||
- **side-chat 线程仍只携带 `subagent/descriptor`,不携带 alpha.2 的 `subagent/catalog`**:DSH 自己的直接子代理发现现在优先读 `catalog`,而我们沿用的 `subagents.listDescendants`(`src/subagent-live-route.ts`)与转录种子的 `type: 'subagent/descriptor'`(`src/sidechat-routes.ts`)路径不变。catalog 只作为新增读面出现,不构成对现有路径的破坏,本期不跟进。
|
||||
|
||||
## 验证
|
||||
|
||||
- **门禁**(本分支 HEAD):`pnpm typecheck` / `pnpm lint` / `pnpm check:consumer-types` 全绿;`pnpm vitest run --exclude '**/*.e2e.ts'` = 124 files / 1295 passed / 9 skipped;`pnpm peers check` = No peer dependency issues found。
|
||||
- **真机挂载冒烟**(`DSH_CMD` 指向 npm 安装的 DSH 0.1.5-alpha.2,`scripts/e2e-mount.sh`):7 passed。新增的 tab 体填充断言(可见 tab 的 `[data-dsh-native-tab-host]` 盒高 == 其带盒祖先 ±2px,sidechat composer 底边距面板底 ≤12px)是这次修复的回归守护——它在修复前失败(`gap -862px`,因为断言最初漏跳过 `display: contents` 槽宿主)。
|
||||
- **真机 3080(web profile 升到 alpha.2 + 插件 0.19.0-alpha.1)**:
|
||||
- 原生 guide 六行均为 48px「图标+标题」胶囊,无第二行:文件 / 文件变动 / 任务管理 / 侧边对话(beta) / 终端 / 浏览器。
|
||||
- 文件树点击 `AGENTS.md` → tab 标题 `AGENTS.md` + CodeMirror 挂载(`editor` 接管成立)。
|
||||
- **sidechat 输入框贴底**:原生右侧栏 `hostHeight 962 == paneBodyHeight 962`(fillGap 0),composer 底边距面板底 8px(即它自己的 margin);底部工作台同样 8px。
|
||||
- 底部工作台中心列锚点:`[data-dsh-bottom-panel]` 与 `[data-dsh-center-col]` 左右边完全重合(gap 0)——证明 `main.conversation` + 跳过 `display: contents` 的解析在真机成立;真机 `[data-slot]` 清单同时确认 `main` / `main.conversation` / `rightbar.session` 已存在、旧 `conversation` 槽已消失。
|
||||
- `pageerror` 0 条;控制台仅 `POST /api/dsh-worktree/attach` 500(第三方 dsh-tauri-worktree 插件,与本次改动无关)。
|
||||
- 未覆盖:**聊天里的文件链接点击**——当前可见会话没有可解析的文件提及(内联 `code` 是纯文本,不是链接),该漏斗(`ctx.sidebarRight.openResource(fileAddressFor(...))`)由挂载车道与 `tests/resource-address.spec.ts` 覆盖,本实例未实测。
|
||||
- 仓库没有守护指南文档章节的测试;`tests/builtins.spec.ts` / `tests/native-surface.spec.ts` 守护的是 guide 行形态而非文档文本。
|
||||
- 早期本分支相关测试曾整体收集失败:`@deepseek-ai/dsh-client-ui-primitives@0.1.5-alpha.2` 在模块加载时裸 `import 'anser'`,而该包 manifest 已不再声明依赖。补齐 devDependencies 后恢复(见改动清单)。
|
||||
|
||||
## 不做
|
||||
|
||||
- 不接入 alpha.2 全局面板(`main` 槽 / `sidebar.panellist` / `ctx.layout` / `usePanelInfo` / 根级 `rightbar`)。
|
||||
- 不引入 `subagent/catalog` 读取面。
|
||||
- 不改 DSH 源码(仓库硬约束 §1)。
|
||||
|
||||
## 落地
|
||||
|
||||
- #603 → #604 → #605 → #609 依序合入 `main`(各自 retarget 到 main 后以 merge commit 合并);main 的 `package.json` 为 `0.19.0-alpha.1`、`engines.dsh` 为 `>=0.1.5-alpha.2`。main 的 CI 首轮 `ci-windows` 因既有的 pty/git 5s 超时抖动失败,重跑后三 job 全绿。
|
||||
- 发布 [v0.19.0-alpha.1](https://github.com/omdsh-dev/DSH-better-sidebar/releases/tag/v0.19.0-alpha.1):`release.yml` 通过全门禁后经 npm Trusted Publishing 发布,npm `alpha` dist-tag → `0.19.0-alpha.1`(带 provenance)。发布产物核对:`lib/index.js` 与本地已验证构建逐字节一致;`lib/client.js` 仅差打包非确定性(已知);`package.json` 仅少 dev-only 字段(`packageManager` / `prepare` / `prepublishOnly`,npm 发布时剥离)。
|
||||
- 注意:新版本在 registry 上可见有约 5 分钟传播延迟(版本元数据先到、tarball 后到),期间 `npm view`/tarball 可能 404——不是发布失败。
|
||||
@@ -0,0 +1,192 @@
|
||||
# 终端等待 banner(wait banner + 跳过)设计
|
||||
|
||||
> 2026-09-09 · 目标 v0.18.2
|
||||
|
||||
## 背景
|
||||
|
||||
`terminal_wait_for`(v0.18.1,PR #595 起 needle 支持 regex)会阻塞 agent 的工具调用直到
|
||||
needle 出现 / 超时 / 终端退出。等待期间用户在侧边栏看不到任何"agent 正在等什么"的信号,
|
||||
也无法干预——只能等 agent 自己超时(默认 10s,长等待可设到分钟级)。
|
||||
|
||||
需求:
|
||||
|
||||
1. agent 开始 `wait for needle` 时,在对应终端的顶部显示 banner:
|
||||
**「Agent 正在等待 {needle}」**,右侧「跳过等待」按钮;
|
||||
2. 点击跳过后,`terminal_wait_for` 立即返回,agent 明确知道是用户跳过(而非超时);
|
||||
3. 终端 tab 未激活时也能感知等待 → tab 标题加 ⏳ 徽章。
|
||||
|
||||
## 方案对比
|
||||
|
||||
| 方案 | 思路 | 取舍 |
|
||||
|---|---|---|
|
||||
| **A. 搭现有推送车(采纳)** | 等待状态并入 `AgentTerminalSnapshot`,经现有 `/sidebar/ws/agent-terminals` 推送;跳过走新 `/sidebar/api` 方法 | 改动集中;零新 WS 生命周期;推送时机由 registry `notify()` 天然覆盖 |
|
||||
| B. 独立 waits 推送 WS | 新开 `/sidebar/ws/agent-waits` | 重连/订阅/teardown 全套翻倍,YAGNI |
|
||||
| C. 客户端轮询 | 定时拉 terminal 列表 | 延迟与开销差,违背现有推送架构 |
|
||||
|
||||
已确认的两个决策:
|
||||
|
||||
- **skip 语义 = 新增第四种返回 `kind:'skipped'`**(不复用 timeout、不走 abort——abort
|
||||
语义属于工具调用取消,用户语义只是"不等了");
|
||||
- **banner 主体在终端卡片顶部 + tab 标题 ⏳ 徽章**(复用 v0.12+ 的 `TabDescriptor.badge`
|
||||
通道,git tab 有先例)。
|
||||
|
||||
## 设计
|
||||
|
||||
### 1. Host:`AgentPtyRegistry`(src/agent-pty.ts)
|
||||
|
||||
- `AgentTerminalHandle` 增活动等待记录数组 `waits: Array<{ needle, since, skipped }>`;
|
||||
并发 `wait_for` 同 uuid 时各登记一条。
|
||||
- `waitFor()`:快速路径(已 exited / needle 已在 transcript)不登记、不推 banner;
|
||||
进入轮询前登记记录并 `notify()`(banner 出现),`finally` 移除记录并 `notify()`(banner
|
||||
消失——found/timeout/exited/skipped/工具 abort 五条出路统一走 finally)。
|
||||
- 轮询每圈检查本记录的 `skipped` → 返回 `{ kind:'skipped', needle }`;50ms 轮询
|
||||
天然承载跳过延迟,无需事件唤醒。与终端退出在同一 tick 竞争时**退出优先**(见实施
|
||||
偏差记录)。
|
||||
- 新方法 `skipWait(uuid): number`:标记该 uuid 全部活动记录并返回条数;uuid 未知 →
|
||||
`not-found` 404(与 `expect` 一致)。
|
||||
- `AgentTerminalSnapshot` 增 `waiting?: { needle: string; since: number }`(取最新登记
|
||||
的一条;并发等待显示最新 needle,跳过跳过全部)。
|
||||
- `AgentTerminalWaitResult` 增 `skipped` 变体。
|
||||
|
||||
### 2. 工具层:`terminal_wait_for`(src/tools.ts)
|
||||
|
||||
- output schema `oneOf` 增 `{ kind: 'skipped', needle }` 分支;
|
||||
- render:`Skipped by user while waiting for "<needle>" — the wait ended early; call terminal_read to inspect the transcript if needed.`;
|
||||
- 工具描述补一句:用户可从侧栏 banner 跳过等待(返回 `skipped`)。
|
||||
|
||||
### 3. API:`buildApi`(src/index.ts)
|
||||
|
||||
- 新方法 `'agent-pty.skip-wait'`(命名对齐现有 `'agent-pty.close'`):
|
||||
`requireString uuid` → `registry.skipWait(uuid)`;degraded 模式(registry 为 null)
|
||||
幂等返回 `{ ok: true, skipped: 0 }`。
|
||||
|
||||
### 4. 推送零改动
|
||||
|
||||
`attachAgentList` 发的就是 `registry.list(sessionId)`,snapshot 新增的 `waiting` 字段
|
||||
自动随车;等待开始/结束由 `notify()` 触发推送。兼容性:
|
||||
|
||||
- 旧客户端 + 新 host:多余字段被忽略;
|
||||
- 新客户端 + 旧 host:永远收不到 `waiting` → 无 banner,安全降级。
|
||||
|
||||
### 5. 客户端状态(src/client/state.ts)
|
||||
|
||||
- `SidebarState` 增 `agentWaits: Record<uuid, { needle: string; since: number }>`(默认
|
||||
`{}`;等待结束即删除键);
|
||||
- `reconcileAgentTerminals` 从同一推送同步该映射——需解除"无 tab 增删即早退"的短路
|
||||
(仅 waiting 变化也要产出新状态);
|
||||
- **不持久化**:`sanitizeState` 逐字段重建、不恢复该字段 → 刷新后自然清空;WS attach
|
||||
即发首条推送,立刻纠正。
|
||||
|
||||
### 6. Banner(src/client/TerminalView.tsx)
|
||||
|
||||
- 仅 `agent:` tab 渲染;位置在终端区顶部(现有 fatal/deps banner 之上);
|
||||
- 内容:`t('terminalWaitBanner', { needle })`(zh「Agent 正在等待 {needle}」)+ 右侧
|
||||
`t('terminalSkipWait')`(zh「跳过等待」)按钮 → `api.agentSkipWait(uuid)`;
|
||||
- needle 超 ~80 字符截断显示,`title` 悬浮显全文;
|
||||
- banner 消失完全由推送驱动(skipped/exited/abort 均经 `notify`),点击不做乐观更新。
|
||||
|
||||
### 7. Tab ⏳ 徽章(src/client/builtins/tabs.tsx)
|
||||
|
||||
- terminal 描述符增 `badge: (_ctx, scope, state) => ...`:`agent:` tab 且
|
||||
`state.agentWaits[uuid]` 存在时返回 `'⏳'`,否则 null——复用 v0.12+ 徽章通道
|
||||
(git changes tab 先例),不碰 `TabBar`。
|
||||
|
||||
### 8. i18n / 样式
|
||||
|
||||
- 词典键:`terminalWaitBanner`、`terminalSkipWait`;zh/en 同步,**ja 翻译必须同步
|
||||
`src/client/locales-ja.ts`**(仓库规则);
|
||||
- `.terminalWaitBanner` 全部消费 `--dsw-alias-*` / `--ds-*` 令牌,无硬编码颜色
|
||||
(皮肤契约,`tests/theme.spec.ts` 守护)。
|
||||
|
||||
### 9. 测试
|
||||
|
||||
- `tests/agent-pty.spec.ts`:跳过返回 `skipped`;活动等待期间 snapshot 含 `waiting`、
|
||||
结束后消失;`skipWait` 未知 uuid → 404、无活动等待 → 0;等待开始/结束触发 `notify`;
|
||||
- `tests/tools.spec.ts`:FakeRegistry 增 skipped 形状;schema/render 断言;
|
||||
- `tests/agent-terminal-reconcile.spec.ts`:`agentWaits` 设置/清除、仅 waiting 变化也
|
||||
触发状态更新;
|
||||
- badge / banner 组件测试视现有 jsdom 基建可行性补齐。
|
||||
|
||||
## 边缘情况
|
||||
|
||||
- **并发多个 `wait_for` 同一 uuid**:banner/⏳ 显示最新登记的 needle;跳过一次跳过全部;
|
||||
- **工具调用取消(abort)**:`finally` 移除记录 → banner 消失并推送;
|
||||
- **终端退出 / 用户关 tab(close frame 杀 pty)**:轮询下一圈返回 `exited`,记录移除;
|
||||
- **UI-tab 终端**:无等待概念,`agentWaits` 永不含有其条目。
|
||||
|
||||
## 不做的事
|
||||
|
||||
- 不新增独立 waits WebSocket(方案 B 否决);
|
||||
- 不改 `ctx.betterSidebar` 服务面(`external-plugin-guide.md` 无需同步);
|
||||
- 不做乐观 UI(banner 消失以服务端推送为准)。
|
||||
|
||||
## 实施偏差记录
|
||||
|
||||
- **⏳ tab 徽章不走 `TabDescriptor.badge`**:实现时核实该 API 是 type-keyed
|
||||
(`badge(ctx, scope, state)`,无 tab 参数、external 插件共享),无法定位单个
|
||||
tab。改为 Sidebar 壳层 `tabBadgeOf` 的 sidebar-internal 特例(agent: 前缀判断
|
||||
+ `state.agentWaits` 查表),`service.ts` 与 external-plugin-guide.md 零改动。
|
||||
- **banner 抽为独立组件 `TerminalWaitBanner.tsx`**:设计原文写在 TerminalView
|
||||
内;抽出后无 xterm 依赖,jsdom 单测可直接渲染(TerminalView 仍负责订阅 store
|
||||
并把 onSkip 接到 `api.agentSkipWait`)。
|
||||
- **词典同步扩到全部 21 个语言文件**:设计只写了 zh/en/ja,但
|
||||
`tests/locales.spec.ts` 断言每个第三方词典与 zh 键集相等——只同步三语会让该
|
||||
守护测试挂掉。按仓库惯例(全语言同步,如 e8761ee)补齐其余 18 语言同两键。
|
||||
- **banner 配色**:仓库 CSS 无 state-info 令牌,沿用 `.terminalBanner` 的 warn
|
||||
对(`--dsw-alias-state-warn-label/-tertiary`),符合皮肤契约。
|
||||
- **设计文档与计划随 feat 分支进 PR**:main 受分支保护,纯文档直推被 hook 拒绝
|
||||
(`4a6a77a` / `2448068` 随本分支合并)。
|
||||
- **测试断言以文本计数取代 CSS 类名**:CSS module 类名在测试变换下不稳定,
|
||||
⏳ 数量断言改从 `textContent` 计数;长 needle 截断断言改为计量 needle 自身
|
||||
贡献字符数(本地化句子包裹 needle,整体长度断言与语言相关,原计划内部矛盾)。
|
||||
- **新测试的 localStorage 兜底**:Node 26 + jsdom 29 组合下测试环境可能无全局
|
||||
`localStorage`(上游 `bottom-auto-terminal.spec.tsx` 在同环境即失败,属已知
|
||||
win32/环境非回归类);新 spec 的 afterEach 加守卫 + try/catch(jsdom opaque
|
||||
origin 下 localStorage 可以是 throwing accessor,裸 typeof 也会炸),
|
||||
上游测试一律未动。
|
||||
- **已知限制:跨会话 pinned 虚拟 tab 收不到实时 banner/⏳**(Copilot review
|
||||
指出):等待状态 feed 按 viewer 会话订阅(`/sidebar/ws/agent-terminals` 按
|
||||
当前 sessionId 连接),钉到其他会话的 pinned 虚拟 agent tab
|
||||
(`pinned:<homeSessionId>:agent:<uuid>`)在其 home 会话非当前会话时收不到
|
||||
实时等待状态。修复需跨会话 wait feed 多路复用(连 home 会话推送或聚合
|
||||
推送),超出本特性范围,留待后续设计;跳过 API 按 uuid 全局寻址,跨会话
|
||||
跳过本身可达。
|
||||
- **Copilot review 第三轮修复(合并 rc.1 后扫描)**:
|
||||
1. `truncateNeedle` 改按 Unicode 码点截断(`Array.from`),原 UTF-16 码元
|
||||
截断会在截断点劈开代理对(emoji 渲染为替换字符);
|
||||
2. banner 消息加 `role="status"` + `aria-live="polite"`,完整未截断的
|
||||
本地化消息经 `aria-label` 提供给读屏器(不新增词典键);
|
||||
3. `terminal` tab 类型禁用时推送处理从整体跳过改为 `mirrorAgentWaits`
|
||||
——仍镜像权威 `waiting` map(等在禁用窗口内 resolve 也清 banner 状态),
|
||||
只跳过 tab 增删 reconcile,否则重启用后已开终端残留 stale banner/⏳
|
||||
直到无关推送到达;
|
||||
4. badge 测试跟踪挂载的 root 并在 `afterEach` 里先 unmount 再清 DOM
|
||||
(裸 `innerHTML = ''` 不触发 React effect 清理,store 订阅/host-feed
|
||||
effect 会泄漏进后续测试);
|
||||
5. 补并发双 `waitFor` 同 uuid 测试:快照显示最新 needle(`waits.at(-1)`)、
|
||||
`skipWait` 返回 2、两个 promise 都以各自 needle `skipped` 收敛、快照
|
||||
清空且 skipWait 幂等归零。
|
||||
- **轮询优先级:终端退出优先于用户跳过**(v0.19.0 发版前 review 发现):设计原文
|
||||
写「每圈优先检查 `skipped`」,实现是 `aborted → handle.exited → record.skipped`,
|
||||
即 pty 退出与用户点跳过落进同一个 50ms tick 时返回 `exited`。**维持实现现状**,
|
||||
设计原文已同步为「退出优先」:两种顺序下用户可见结果完全相同(banner 消失、
|
||||
`skipWait` 返回 0、等待结束),只有工具结果的 `kind` 文案不同;而进程已死是
|
||||
客观事实,报 `exited` 不撒谎,工具结果本身也引导 agent 用 `terminal_read` 复核。
|
||||
若日后要让「用户意图优先」,改动点是循环内两行先后 + 一条同 tick 竞态测试。
|
||||
- **两个已知限制的补充记录**(同一次 review):
|
||||
1. **pinned tab 的 banner 与 ⏳ 不对称**:pinned 虚拟 id 是
|
||||
`pinned:<homeSessionId>:agent:<uuid>`,`isAgentTabId` 只认 `agent:` 前缀,因此
|
||||
钉住的 agent tab **不会误挂 ⏳**(比"收不到"更强的保证);但 Workbench 渲染
|
||||
pinned tab 时给 TerminalView 传的是**原 tab id + home 作用域**,所以 home 会话
|
||||
正在等待时该视图**会**显示 banner,而它的 strip 徽章仍不出现。两个信号数据源
|
||||
不同(banner 读 home store,徽章读 viewer 的 `agentWaits`)。
|
||||
2. **⏳ 到不了 DSH 原生右侧栏**:徽章经 `getTabBadge` 渲染,唯一消费点是插件
|
||||
自绘的 `TabBar`;原生栏的 `NativeTabTitle` 契约只带 title,不带 badge。agent
|
||||
终端标签页走 `openTabInBottomPane`(底部工作台),故 agent 流程可见;这是原生
|
||||
承载面契约决定的上限,非本特性缺陷。
|
||||
- **注释随实现同步**(v0.19.0 发版前 review):`use-host-feeds.ts` effect 头部原写
|
||||
「terminal tab 类型禁用时 pushes are ignored」,与上面第 3 项的 `mirrorAgentWaits`
|
||||
改法矛盾,已改为「不增删 tab,但仍镜像权威 wait map」;`src/index.ts` 的
|
||||
`agent-pty.skip-wait` 注释原文把「无活动等待 → 0」泛化成了「幂等」,已收窄为
|
||||
「无活动等待返回 `{skipped:0}`;未知 uuid 走 `expect` → 404(已退出的终端即属此
|
||||
列),客户端 `.catch` 容忍并等下一次推送收敛」。
|
||||
@@ -0,0 +1,980 @@
|
||||
# 终端等待 banner + 跳过等待 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** agent 阻塞在 `terminal_wait_for` 时,对应终端卡片顶部显示「Agent 正在等待 {needle}」banner(右侧「跳过等待」按钮),tab 标题加 ⏳ 徽章;跳过后工具返回新增的 `{kind:'skipped'}`。
|
||||
|
||||
**Architecture:** 等待状态登记在 `AgentPtyRegistry` 的 handle 上,随现有 `/sidebar/ws/agent-terminals` 推送快照(`waiting` 字段)自然下发;跳过走新增 `/sidebar/api/agent-pty.skip-wait` 路由。客户端把推送镜像进 `state.agentWaits`,banner 组件与 tab 徽章从 store 读。
|
||||
|
||||
**Tech Stack:** TypeScript / React 18 / vitest(node 环境 pty 测试 + jsdom Sidebar 壳测试)/ ws 推送 / CSS Modules(皮肤令牌)。
|
||||
|
||||
**Spec:** `docs/plans/2026-09-09-terminal-wait-banner-design.md`(与本计划同分支,执行者两份都读)。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 分支:`feat/wait-banner`(已含设计文档提交 `4a6a77a`);所有代码改动走该分支 → PR,禁止直推 main(分支保护)。
|
||||
- 皮肤契约:新增 CSS 只允许 `--dsw-alias-*` / `--dsw-font-*` / `--ds-*` 令牌;本项目复用现成 warn 对 `--dsw-alias-state-warn-label` / `--dsw-alias-state-warn-tertiary`(仓库 CSS 无 state-info 令牌,已核实)。
|
||||
- i18n:新增 zh key 必须同步 en 词典与 `src/client/locales-ja.ts` 的 ja 翻译。
|
||||
- client bundle 纯度门:新文件只准 import 本插件模块(locales / css / api),禁止 value-import `@dsh-external/*` 或非白名单 `@deepseek-ai/*`。
|
||||
- 不改 `src/client/service.ts` / `docs/external-plugin-guide.md`(⏳ 徽章走 shell 层特例,不动 TabDescriptor API)。
|
||||
- 上游测试(含 win32 已知 27 个非回归失败)一律不改。
|
||||
- 每个任务用 `pnpm exec vitest run <file>` 验证;最终任务必须 `pnpm lint` + `pnpm typecheck` + `pnpm test` + `pnpm build` 全绿才 push(CI 曾因漏 lint 挂过)。
|
||||
- 提交信息风格:`feat(terminal): ...` / `test(...)`,仓库惯例小写 scope。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Registry 等待登记 + `skipped` 结果 + `waiting` 快照
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/agent-pty.ts`
|
||||
- Test: `tests/agent-pty.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 现有 `AgentPtyRegistry.waitFor` / `notify` / `subscribe` / `snapshotOf`。
|
||||
- Produces:
|
||||
- `type AgentTerminalActiveWait = { needle: string; since: number; skipped: boolean }`
|
||||
- `AgentTerminalHandle.waits: AgentTerminalActiveWait[]`(create 时初始化为 `[]`)
|
||||
- `AgentTerminalSnapshot.waiting?: { needle: string; since: number }`(最新一条活动等待)
|
||||
- `AgentTerminalWaitResult` 新增 `{ kind: 'skipped'; needle: string }` 变体
|
||||
- `registry.skipWait(uuid: string): number`(标记全部活动等待,返回条数;未知 uuid 抛 not-found)
|
||||
|
||||
- [ ] **Step 1: 写失败测试(4 个新用例,加在 `describe('AgentPtyRegistry')` 内、现有 `waitFor` 用例之后)**
|
||||
|
||||
在 `tests/agent-pty.spec.ts` 的 import 块不需要新增(`AgentPtyRegistry` / `snapshotOf` 已导入)。追加用例:
|
||||
|
||||
```ts
|
||||
it('waitFor returns skipped when the user skips from the sidebar', async () => {
|
||||
const registry = new AgentPtyRegistry(testShell())
|
||||
try {
|
||||
const uuid = registry.create('s1', 'skip-test', 'echo skip-ready', process.cwd(), 80, 24)
|
||||
await waitForTranscript(registry, uuid, 'skip-ready')
|
||||
// waitFor registers its record synchronously (before the first poll
|
||||
// await), so the skip can fire immediately after the call.
|
||||
const waitPromise = registry.waitFor(uuid, 'NEVER_APPEARS_XYZ', 30_000)
|
||||
expect(await registry.skipWait(uuid)).toBe(1)
|
||||
const result = await waitPromise
|
||||
expect(result.kind).toBe('skipped')
|
||||
if (result.kind === 'skipped') expect(result.needle).toBe('NEVER_APPEARS_XYZ')
|
||||
// Idempotent: nothing left to skip once the wait resolved.
|
||||
expect(registry.skipWait(uuid)).toBe(0)
|
||||
} finally {
|
||||
registry.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('snapshot exposes waiting while a wait is active and clears after it ends', async () => {
|
||||
const registry = new AgentPtyRegistry(testShell())
|
||||
try {
|
||||
const uuid = registry.create('s1', 'wait-snap', 'echo snap-ready', process.cwd(), 80, 24)
|
||||
await waitForTranscript(registry, uuid, 'snap-ready')
|
||||
const waitPromise = registry.waitFor(uuid, 'LATER_MARK_9', 30_000)
|
||||
// Registration happens synchronously before waitFor's first await.
|
||||
expect(registry.list('s1')[0]?.waiting?.needle).toBe('LATER_MARK_9')
|
||||
expect(typeof registry.list('s1')[0]?.waiting?.since).toBe('number')
|
||||
expect(registry.skipWait(uuid)).toBe(1)
|
||||
const result = await waitPromise
|
||||
expect(result.kind).toBe('skipped')
|
||||
expect(registry.list('s1')[0]?.waiting).toBeUndefined()
|
||||
} finally {
|
||||
registry.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('fires change listeners when a wait starts and ends', async () => {
|
||||
const registry = new AgentPtyRegistry(testShell())
|
||||
try {
|
||||
const uuid = registry.create('s1', 'watched-wait', 'echo notify-ready', process.cwd(), 80, 24)
|
||||
await waitForTranscript(registry, uuid, 'notify-ready')
|
||||
let changes = 0
|
||||
const unsubscribe = registry.subscribe(() => { changes += 1 })
|
||||
const waitPromise = registry.waitFor(uuid, 'NEVER_NOTIFY_1', 30_000)
|
||||
const afterStart = changes
|
||||
expect(afterStart).toBeGreaterThanOrEqual(1)
|
||||
expect(await registry.skipWait(uuid)).toBe(1)
|
||||
expect(await waitPromise).toEqual({ kind: 'skipped', needle: 'NEVER_NOTIFY_1' })
|
||||
expect(changes).toBeGreaterThan(afterStart)
|
||||
unsubscribe()
|
||||
} finally {
|
||||
registry.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('skipWait rejects an unknown uuid with not-found', () => {
|
||||
const registry = new AgentPtyRegistry(testShell())
|
||||
try {
|
||||
expect(() => registry.skipWait('missing-uuid')).toThrow(/not found/)
|
||||
} finally {
|
||||
registry.disposeAll()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `pnpm exec vitest run tests/agent-pty.spec.ts`
|
||||
Expected: FAIL — `waiting` 为 undefined、`skipWait` 不存在、skipped 分支不存在(TS 编译错误或断言失败)。
|
||||
|
||||
- [ ] **Step 3: 实现 `src/agent-pty.ts`**
|
||||
|
||||
(a) 在 `AgentTerminalReadResult` 之后、`AgentTerminalWaitResult` 之前新增:
|
||||
|
||||
```ts
|
||||
/** One active wait_for registration on a handle (banner + skip bookkeeping). */
|
||||
export interface AgentTerminalActiveWait {
|
||||
/** The needle being awaited (shown on the sidebar wait banner). */
|
||||
needle: string
|
||||
/** Epoch ms when the wait registered (age display / debugging). */
|
||||
since: number
|
||||
/** Flipped by `skipWait()`; the waiting poll returns `skipped` within one tick. */
|
||||
skipped: boolean
|
||||
}
|
||||
```
|
||||
|
||||
(b) `AgentTerminalWaitResult` union 末尾追加变体:
|
||||
|
||||
```ts
|
||||
| {
|
||||
/** The user skipped the wait from the sidebar banner. */
|
||||
kind: 'skipped'
|
||||
/** The needle that was awaited. */
|
||||
needle: string
|
||||
}
|
||||
```
|
||||
|
||||
(c) `AgentTerminalSnapshot` 追加字段(`exitSignal` 之后):
|
||||
|
||||
```ts
|
||||
/**
|
||||
* The model's active `terminal_wait_for` on this terminal (the sidebar
|
||||
* renders the wait banner from it). Present only while a wait is
|
||||
* registered; carries the LATEST wait when several overlap.
|
||||
*/
|
||||
waiting?: { needle: string; since: number }
|
||||
```
|
||||
|
||||
(d) `AgentTerminalHandle` 追加字段(`exitSignal` 之后):
|
||||
|
||||
```ts
|
||||
/** Active wait_for registrations (skip bookkeeping; empty while idle). */
|
||||
waits: AgentTerminalActiveWait[]
|
||||
```
|
||||
|
||||
(e) `create()` 的 handle 字面量中 `exited: false,` 之后加 `waits: [],`。
|
||||
|
||||
(f) `snapshotOf` 在 `return out` 前追加:
|
||||
|
||||
```ts
|
||||
const active = handle.waits.at(-1)
|
||||
if (active !== undefined) out.waiting = { needle: active.needle, since: active.since }
|
||||
```
|
||||
|
||||
(g) 新方法(放在 `waitFor` 之后):
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Mark every active wait on one terminal as skipped (the sidebar banner's
|
||||
* skip button). Each waiting poll loop observes its record's flag within
|
||||
* one 50ms tick and returns `{kind:'skipped'}`. Idempotent: 0 when nothing
|
||||
* is waiting (a stale banner racing a wait that already resolved).
|
||||
* @returns the number of waits that transitioned to skipped.
|
||||
*/
|
||||
skipWait(uuid: string): number {
|
||||
const handle = this.expect(uuid)
|
||||
let count = 0
|
||||
for (const record of handle.waits) {
|
||||
if (!record.skipped) {
|
||||
record.skipped = true
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
```
|
||||
|
||||
(h) 重写 `waitFor` 中段(`const firstHit = ...` 快速路径之后到方法结尾),登记记录 + 轮询检查 + `finally` 清理:
|
||||
|
||||
```ts
|
||||
const firstHit = locateNeedle(handle.transcript, needle, re)
|
||||
if (firstHit !== undefined) {
|
||||
return { kind: 'found', needle, line: firstHit.line, column: firstHit.column, match: firstHit.match, elapsedMs: Date.now() - start }
|
||||
}
|
||||
// Register the active wait so the sidebar can show the wait banner (the
|
||||
// snapshot's `waiting` field rides the agent-terminals push). Registered
|
||||
// only after the fast paths: a wait that resolves instantly never
|
||||
// flashes the banner. The notify() makes every push subscriber (the
|
||||
// sidebar view) converge on the new waiting state immediately.
|
||||
const record: AgentTerminalActiveWait = { needle, since: start, skipped: false }
|
||||
handle.waits.push(record)
|
||||
this.notify()
|
||||
try {
|
||||
while (true) {
|
||||
if (signal?.aborted) signal.throwIfAborted()
|
||||
if (handle.exited) {
|
||||
return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
|
||||
}
|
||||
if (record.skipped) {
|
||||
return { kind: 'skipped', needle }
|
||||
}
|
||||
const hit = locateNeedle(handle.transcript, needle, re)
|
||||
if (hit !== undefined) {
|
||||
return { kind: 'found', needle, line: hit.line, column: hit.column, match: hit.match, elapsedMs: Date.now() - start }
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
return { kind: 'timeout', needle, timeoutMs: timeout, totalLines: handle.transcript.split('\n').length }
|
||||
}
|
||||
await new Promise(resolve => {
|
||||
const t = setTimeout(resolve, 50)
|
||||
// Allow the Node process to exit even if the timer is pending.
|
||||
if (typeof t === 'object' && 'unref' in t) (t as { unref: () => void }).unref()
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
const index = handle.waits.indexOf(record)
|
||||
if (index !== -1) handle.waits.splice(index, 1)
|
||||
this.notify()
|
||||
}
|
||||
```
|
||||
|
||||
(i) `waitFor` 的 JSDoc:`@returns one of `found` / `timeout` / `exited`.` 改为 `@returns one of `found` / `timeout` / `exited` / `skipped`.`;并在 `@param signal` 行后补一行 `* A wait can also be skipped by the user from the sidebar banner (`skipWait`), which resolves it with `{kind:'skipped'}`.`
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过**
|
||||
|
||||
Run: `pnpm exec vitest run tests/agent-pty.spec.ts`
|
||||
Expected: PASS(全部用例,含既有 425 行文件内的用例)。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/agent-pty.ts tests/agent-pty.spec.ts
|
||||
git commit -m "feat(terminal): registry 支持等待登记、skipWait 与 skipped 结果"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 工具层 `skipped` 分支 + 宿主 `agent-pty.skip-wait` 路由
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/tools.ts`(terminal_wait_for 定义,约 287-377 行)
|
||||
- Modify: `src/index.ts`(buildApi 表,`'agent-pty.close'` 之后,约 545 行)
|
||||
- Test: `tests/tools.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 的 `AgentTerminalWaitResult` skipped 变体、`registry.skipWait(uuid): number`。
|
||||
- Produces: `/sidebar/api/agent-pty.skip-wait` 路由(payload `{uuid}` → `{ok:true, skipped:number}`);工具 output schema 第四分支;Task 4 的客户端 `api.agentSkipWait` 调它。
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
`tests/tools.spec.ts`:(a) `FakeRegistry` 增加注入口 —— 在 `readonly terminals = ...` 行后加属性,并把 `waitFor` 改为:
|
||||
|
||||
```ts
|
||||
/** When set, waitFor resolves with the skipped shape instead of the found default. */
|
||||
nextWaitResult: { kind: 'skipped' } | undefined
|
||||
|
||||
waitFor(_uuid: string, needle: string): Promise<
|
||||
{ kind: 'found'; needle: string; line: number; column: number; match: string; elapsedMs: number }
|
||||
| { kind: 'skipped'; needle: string }
|
||||
> {
|
||||
// Mirrors the registry's empty-needle rejection (the tool layer no
|
||||
// longer duplicates this check).
|
||||
if (needle === '') return Promise.reject(new Error('needle must be a non-empty string'))
|
||||
if (this.nextWaitResult !== undefined) return Promise.resolve({ kind: 'skipped', needle })
|
||||
return Promise.resolve({ kind: 'found', needle, line: 0, column: 0, match: needle, elapsedMs: 1 })
|
||||
}
|
||||
```
|
||||
|
||||
(b) 在 `describe('agent terminal tools')` 内、现有 `terminal_wait_for returns the registry result...` 用例后追加:
|
||||
|
||||
```ts
|
||||
it('terminal_wait_for returns skipped (schema-valid) when the registry reports a user skip', async () => {
|
||||
const { captured, registry } = mount()
|
||||
const tool = toolOf(captured, 'terminal_wait_for')
|
||||
registry.nextWaitResult = { kind: 'skipped' }
|
||||
const value = await tool.execute({ uuid: 'uuid-1', needle: 'BUILD_OK', timeout_ms: 1000 }, exec('s1'))
|
||||
expect(value).toEqual({ kind: 'skipped', needle: 'BUILD_OK' })
|
||||
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
|
||||
})
|
||||
|
||||
it('terminal_wait_for render describes the user skip and the needle', () => {
|
||||
const { captured } = mount()
|
||||
const tool = toolOf(captured, 'terminal_wait_for')
|
||||
const blocks = tool.output.render({}, { kind: 'skipped', needle: 'BUILD_OK' }) as Array<{ type: string; text: string }>
|
||||
expect(blocks[0]!.type).toBe('text')
|
||||
expect(blocks[0]!.text).toContain('Skipped by user')
|
||||
expect(blocks[0]!.text).toContain('BUILD_OK')
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `pnpm exec vitest run tests/tools.spec.ts`
|
||||
Expected: FAIL(schema 拒绝 skipped / render 走到 exited 分支输出不符)。
|
||||
|
||||
- [ ] **Step 3: 实现**
|
||||
|
||||
(a) `src/tools.ts` terminal_wait_for:
|
||||
|
||||
描述末尾(`'The wait is cooperative: ...'` 句之后)追加一句:
|
||||
|
||||
```ts
|
||||
+ 'The user can skip the wait from the sidebar ( a banner on the terminal\'s tab shows the needle and a skip button ) — the tool then returns `skipped`.',
|
||||
```
|
||||
|
||||
output.schema 的 `oneOf` 数组末尾(exited 分支对象之后)追加:
|
||||
|
||||
```ts
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
kind: { type: 'string', required: true, const: 'skipped' },
|
||||
needle: { type: 'string', required: true },
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
render 的 value 类型与分支 —— `const v = value as { kind: 'found' | 'timeout' | 'exited' | 'skipped'; ... }`(原行加 `'skipped'`),并在 timeout 分支后插入:
|
||||
|
||||
```ts
|
||||
if (v.kind === 'skipped') {
|
||||
return [{ type: 'text', text: `Skipped by user while waiting for "${v.needle}" — the wait ended early. Call terminal_read to inspect the transcript and decide how to proceed.` }]
|
||||
}
|
||||
```
|
||||
|
||||
(b) `src/index.ts` buildApi 表,`'agent-pty.close'` 条目(约 541-545 行)之后追加:
|
||||
|
||||
```ts
|
||||
// The sidebar wait banner's skip button: abort every active
|
||||
// terminal_wait_for on one agent terminal. Idempotent — 0 when nothing
|
||||
// is waiting (a stale banner racing a wait that already resolved).
|
||||
// Degraded mode (node-pty unavailable) has no registry and no waits: an
|
||||
// honest ok.
|
||||
'agent-pty.skip-wait': (payload) => {
|
||||
const uuid = requireString(payload, 'uuid')
|
||||
return { ok: true, skipped: agentPtyRegistry?.skipWait(uuid) ?? 0 }
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过**
|
||||
|
||||
Run: `pnpm exec vitest run tests/tools.spec.ts`
|
||||
Expected: PASS。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/tools.ts src/index.ts tests/tools.spec.ts
|
||||
git commit -m "feat(terminal): terminal_wait_for 增 skipped 结果与 agent-pty.skip-wait 路由"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 客户端状态 `agentWaits` 镜像 + 推送类型
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/client/state.ts`(SidebarState 接口 ~121 行、makeDefaultState ~221 行、sanitizeState ~1283 行、reconcileAgentTerminals ~1017 行)
|
||||
- Modify: `src/client/sidebar/use-host-feeds.ts`(~66 行解析类型)
|
||||
- Test: `tests/agent-terminal-reconcile.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 宿主推送数组(Task 1 的 snapshot 含 `waiting`)。
|
||||
- Produces:
|
||||
- `SidebarState.agentWaits: Record<string, { needle: string; since: number }>`(uuid → 活动等待;瞬态,不持久化)
|
||||
- `reconcileAgentTerminals(state, list)` 的 list 元素增 `waiting?: { needle: string; since: number } | null`,并把等待状态镜像进 `agentWaits`(仅等待变化也产出新状态)。
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
`tests/agent-terminal-reconcile.spec.ts` 追加两个用例(沿用文件现有 import,无需新增):
|
||||
|
||||
```ts
|
||||
it('mirrors the pushed waiting state into agentWaits and clears it when it ends', () => {
|
||||
let s = makeDefaultState()
|
||||
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'busy', waiting: { needle: 'READY_1', since: 42 } }])
|
||||
expect(s.agentWaits['aaa-111']).toEqual({ needle: 'READY_1', since: 42 })
|
||||
// The next push without waiting drops the entry (the push is authoritative).
|
||||
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable' }])
|
||||
expect(s.agentWaits['aaa-111']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('produces a new state when ONLY the wait state changes (no tab add/remove)', () => {
|
||||
let s = makeDefaultState()
|
||||
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable' }])
|
||||
const before = s
|
||||
const next = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable', waiting: { needle: 'N', since: 1 } }])
|
||||
expect(next).not.toBe(before)
|
||||
expect(next.agentWaits['aaa-111']).toBeDefined()
|
||||
// Idempotent: the same push again returns the same reference.
|
||||
expect(reconcileAgentTerminals(next, [{ uuid: 'aaa-111', title: 'stable', waiting: { needle: 'N', since: 1 } }])).toBe(next)
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `pnpm exec vitest run tests/agent-terminal-reconcile.spec.ts`
|
||||
Expected: FAIL(`agentWaits` 不存在 → TS 错误 / 断言失败)。
|
||||
|
||||
- [ ] **Step 3: 实现**
|
||||
|
||||
(a) `SidebarState` 接口 `floats: FloatWindow[]` 之后追加:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Live agent-terminal wait state (uuid → the wait the model currently
|
||||
* blocks on in `terminal_wait_for`), mirrored from the host's
|
||||
* agent-terminals push. Transient by design: sanitizeState never restores
|
||||
* it, so a reload starts clean and the next push (sent immediately on WS
|
||||
* attach) repopulates it.
|
||||
*/
|
||||
agentWaits: Record<string, { needle: string; since: number }>
|
||||
```
|
||||
|
||||
(b) `makeDefaultState` 的返回字面量 `floats: [],` 之后加 `agentWaits: {},`。
|
||||
|
||||
(c) `sanitizeState` 的返回字面量 `floats,` 之后加(带注释):
|
||||
|
||||
```ts
|
||||
// The agent wait state is TRANSIENT (like revealed): never restored from
|
||||
// storage — the host's first push after attach repopulates it.
|
||||
agentWaits: {},
|
||||
```
|
||||
|
||||
(d) `reconcileAgentTerminals`:参数类型改为
|
||||
|
||||
```ts
|
||||
agentTerminals: ReadonlyArray<{ uuid: string; title: string; waiting?: { needle: string; since: number } | null }>,
|
||||
```
|
||||
|
||||
在 `const toRemove = ...` 之后、`if (toAdd.length === 0 && toRemove.length === 0) return state` 处,替换为:
|
||||
|
||||
```ts
|
||||
// Mirror the live wait state from the push (authoritative: a vanished
|
||||
// waiting field simply drops the entry). A waits-only change must still
|
||||
// produce a new state — the tab add/remove no-change check alone would
|
||||
// swallow banner updates.
|
||||
const serverWaits: Record<string, { needle: string; since: number }> = {}
|
||||
for (const terminal of agentTerminals) {
|
||||
if (terminal.waiting !== undefined && terminal.waiting !== null) {
|
||||
serverWaits[terminal.uuid] = { needle: terminal.waiting.needle, since: terminal.waiting.since }
|
||||
}
|
||||
}
|
||||
if (toAdd.length === 0 && toRemove.length === 0 && sameAgentWaits(state.agentWaits, serverWaits)) return state
|
||||
```
|
||||
|
||||
文件末尾的 `return next` 改为 `return { ...next, agentWaits: serverWaits }`。
|
||||
|
||||
在 `reconcileAgentTerminals` 前方新增辅助函数:
|
||||
|
||||
```ts
|
||||
/** Shallow equality of two agent-wait maps (same keys, same needle+since). */
|
||||
function sameAgentWaits(
|
||||
a: SidebarState['agentWaits'] | undefined,
|
||||
b: Record<string, { needle: string; since: number }>,
|
||||
): boolean {
|
||||
if (a === undefined) return Object.keys(b).length === 0
|
||||
const aKeys = Object.keys(a)
|
||||
if (aKeys.length !== Object.keys(b).length) return false
|
||||
for (const key of aKeys) {
|
||||
const av = a[key]
|
||||
const bv = b[key]
|
||||
if (av === undefined || bv === undefined) return false
|
||||
if (av.needle !== bv.needle || av.since !== bv.since) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
(e) `src/client/sidebar/use-host-feeds.ts` 66 行的解析类型改为:
|
||||
|
||||
```ts
|
||||
const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean; waiting?: { needle: string; since: number } | null }>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过 + 全仓 typecheck(状态字段新增会暴露遗漏的构造点)**
|
||||
|
||||
Run: `pnpm exec vitest run tests/agent-terminal-reconcile.spec.ts && pnpm typecheck`
|
||||
Expected: PASS。若 typecheck 报其他 `SidebarState` 字面量缺 `agentWaits`(tests 里的手搓状态),补 `agentWaits: {}` —— **只许改测试里测试自己构造的字面量,不许改上游断言逻辑**。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/client/state.ts src/client/sidebar/use-host-feeds.ts tests/agent-terminal-reconcile.spec.ts
|
||||
git commit -m "feat(sidebar): 镜像 agent 终端等待状态到 agentWaits(瞬态)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 等待 banner 组件 + TerminalView 接线 + api 方法 + i18n + CSS
|
||||
|
||||
**Files:**
|
||||
- Create: `src/client/TerminalWaitBanner.tsx`
|
||||
- Modify: `src/client/TerminalView.tsx`(render 区 ~387-408 行 + 组件顶部订阅)
|
||||
- Modify: `src/client/api.ts`(`agentPtyClose` 之后 ~348 行)
|
||||
- Modify: `src/client/locales.ts`(zh ~117 / en ~567,紧随 `terminalRetry`)
|
||||
- Modify: `src/client/locales-ja.ts`(~105,紧随 `terminalRetry`)
|
||||
- Modify: `src/client/sidebar.module.css`(`.terminalBanner` 块之后 ~2202 行)
|
||||
- Test: `tests/terminal-wait-banner.spec.tsx`(新建)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 2 的 `agent-pty.skip-wait` 路由;Task 3 的 `state.agentWaits`;`api` 对象、`t()`、`css` module。
|
||||
- Produces: `TerminalWaitBanner({ needle: string; onSkip: () => void })` + `truncateNeedle(needle: string): string`(80 字符截断);词典键 `terminalWaitBanner` / `terminalSkipWait`。
|
||||
|
||||
- [ ] **Step 1: 写失败测试(新文件 `tests/terminal-wait-banner.spec.tsx`)**
|
||||
|
||||
```tsx
|
||||
/**
|
||||
* TerminalWaitBanner unit tests: localized text with the needle, the skip
|
||||
* button wiring, and the long-needle display cap (full text stays on the
|
||||
* title attribute). The component imports no xterm — renderable in plain
|
||||
* jsdom (the repo's jsdom pattern, see bottom-auto-terminal.spec.tsx head).
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { setupReactAct } from './test-utils.ts'
|
||||
setupReactAct()
|
||||
|
||||
import { TerminalWaitBanner } from '../src/client/TerminalWaitBanner.tsx'
|
||||
|
||||
describe('TerminalWaitBanner', () => {
|
||||
const mounted: Array<{ root: Root; container: HTMLDivElement }> = []
|
||||
function mount(needle: string, onSkip: () => void): HTMLDivElement {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root = createRoot(container)
|
||||
act(() => { root.render(createElement(TerminalWaitBanner, { needle, onSkip })) })
|
||||
mounted.push({ root, container })
|
||||
return container
|
||||
}
|
||||
afterEach(() => {
|
||||
for (const { root, container } of mounted.splice(0)) {
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the localized waiting text with the needle and a working skip button', () => {
|
||||
let skipped = 0
|
||||
const container = mount('BUILD_OK', () => { skipped += 1 })
|
||||
// jsdom navigator.language is en-US → the en dictionary applies.
|
||||
expect(container.textContent).toContain('Agent is waiting for')
|
||||
expect(container.textContent).toContain('BUILD_OK')
|
||||
expect(container.textContent).toContain('Skip wait')
|
||||
const button = container.querySelector('button')
|
||||
expect(button).not.toBeNull()
|
||||
act(() => { button!.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
||||
expect(skipped).toBe(1)
|
||||
})
|
||||
|
||||
it('truncates a long needle for display but keeps the full text on the title', () => {
|
||||
const long = 'X'.repeat(200)
|
||||
const container = mount(long, () => {})
|
||||
const span = container.querySelector('span[title]')
|
||||
expect(span).not.toBeNull()
|
||||
expect(span!.getAttribute('title')).toBe(long)
|
||||
expect((span!.textContent ?? '').length).toBeLessThanOrEqual(80)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `pnpm exec vitest run tests/terminal-wait-banner.spec.tsx`
|
||||
Expected: FAIL(模块不存在)。
|
||||
|
||||
- [ ] **Step 3: 实现**
|
||||
|
||||
(a) 新建 `src/client/TerminalWaitBanner.tsx`:
|
||||
|
||||
```tsx
|
||||
/**
|
||||
* The "Agent 正在等待 {needle}" wait banner: rendered at the top of an
|
||||
* agent-owned terminal's view while the model blocks in terminal_wait_for.
|
||||
* The skip button asks the host to abort every active wait on the terminal
|
||||
* (`agent-pty.skip-wait`); the banner disappears when the host's next
|
||||
* agent-terminals push drops the waiting field — no optimistic UI. Kept in
|
||||
* its own module (no xterm imports) so jsdom tests can render it directly.
|
||||
*/
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** Cap the needle shown inline; the full text rides the title tooltip. */
|
||||
const NEEDLE_DISPLAY_CAP = 80
|
||||
|
||||
/** Truncate one needle for inline display (title attr carries the full text). */
|
||||
export function truncateNeedle(needle: string): string {
|
||||
return needle.length > NEEDLE_DISPLAY_CAP ? `${needle.slice(0, NEEDLE_DISPLAY_CAP - 1)}…` : needle
|
||||
}
|
||||
|
||||
export function TerminalWaitBanner(props: { needle: string; onSkip: () => void }) {
|
||||
const { needle, onSkip } = props
|
||||
return (
|
||||
<div className={css.terminalWaitBanner}>
|
||||
<span className={css.terminalWaitNeedle} title={needle}>
|
||||
{t('terminalWaitBanner', { needle: truncateNeedle(needle) })}
|
||||
</span>
|
||||
<button type="button" className={css.terminalRetry} onClick={onSkip}>
|
||||
{t('terminalSkipWait')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
(b) `src/client/api.ts`:`agentPtyClose` 条目之后追加:
|
||||
|
||||
```ts
|
||||
/** Skip every active terminal_wait_for on one agent terminal (the wait
|
||||
* banner's skip button). Idempotent: {skipped:0} when none is active. */
|
||||
agentSkipWait: (uuid: string) =>
|
||||
call<{ ok: true; skipped: number }>('agent-pty.skip-wait', { uuid }),
|
||||
```
|
||||
|
||||
(c) `src/client/locales.ts`:zh 词典 `terminalRetry: '重试',` 行后加:
|
||||
|
||||
```ts
|
||||
terminalWaitBanner: 'Agent 正在等待 {needle}',
|
||||
terminalSkipWait: '跳过等待',
|
||||
```
|
||||
|
||||
en 词典 `terminalRetry: 'Retry',` 行后加:
|
||||
|
||||
```ts
|
||||
terminalWaitBanner: 'Agent is waiting for {needle}',
|
||||
terminalSkipWait: 'Skip wait',
|
||||
```
|
||||
|
||||
(d) `src/client/locales-ja.ts`:`terminalRetry: '再試行',` 行后加(仓库规则:新 zh 键必须同步 ja):
|
||||
|
||||
```ts
|
||||
terminalWaitBanner: 'エージェントが {needle} を待機中',
|
||||
terminalSkipWait: '待機をスキップ',
|
||||
```
|
||||
|
||||
(e) `src/client/sidebar.module.css`:`.terminalBannerUrl` 块之后加:
|
||||
|
||||
```css
|
||||
/* The "Agent 正在等待 {needle}" wait banner: a warn-toned strip above the
|
||||
terminal surface while the model blocks in terminal_wait_for; the needle
|
||||
ellipsizes inline and the full text rides the title tooltip. */
|
||||
.terminalWaitBanner {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 3px 10px;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
}
|
||||
|
||||
.terminalWaitNeedle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
```
|
||||
|
||||
(f) `src/client/TerminalView.tsx`:
|
||||
|
||||
imports 区追加:
|
||||
|
||||
```ts
|
||||
import { TerminalWaitBanner } from './TerminalWaitBanner.tsx'
|
||||
```
|
||||
|
||||
组件体内(`const [lastUrl, setLastUrl] = ...` 之后)加等待订阅:
|
||||
|
||||
```ts
|
||||
// Agent terminals only: the model's active terminal_wait_for (mirrored
|
||||
// from the host's agent-terminals push into the store) drives the wait
|
||||
// banner. Read + subscribe like the font prefs above; the banner vanishes
|
||||
// when the host's push drops the waiting field (skip / exit / abort all
|
||||
// converge through the same push). getSnapshot() is {sessionId, state?,
|
||||
// prefs} — the state may be briefly undefined around session switches.
|
||||
const agentUuid = isAgentTabId(tabId) ? agentUuidOf(tabId) : null
|
||||
const [waiting, setWaiting] = useState<{ needle: string; since: number } | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
if (agentUuid === null) return
|
||||
const read = (): void => {
|
||||
const next = store.getSnapshot().state?.agentWaits?.[agentUuid]
|
||||
setWaiting(prev => {
|
||||
const nextValue = next === undefined ? undefined : { needle: next.needle, since: next.since }
|
||||
if (prev?.needle === nextValue?.needle && prev?.since === nextValue?.since) return prev
|
||||
return nextValue
|
||||
})
|
||||
}
|
||||
read()
|
||||
return store.subscribe(read)
|
||||
}, [agentUuid, store])
|
||||
```
|
||||
|
||||
return JSX 中 `<div className={css.terminalWrap}>` 的第一个子元素位置(`depsFatal` 条目之前)加:
|
||||
|
||||
```tsx
|
||||
{agentUuid !== null && waiting !== undefined && (
|
||||
<TerminalWaitBanner
|
||||
needle={waiting.needle}
|
||||
onSkip={() => { void api.agentSkipWait(agentUuid).catch(() => { /* 跳过失败时 banner 留存,可重试 */ }) }}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过**
|
||||
|
||||
Run: `pnpm exec vitest run tests/terminal-wait-banner.spec.tsx`
|
||||
Expected: PASS。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/client/TerminalWaitBanner.tsx src/client/TerminalView.tsx src/client/api.ts src/client/locales.ts src/client/locales-ja.ts src/client/sidebar.module.css tests/terminal-wait-banner.spec.tsx
|
||||
git commit -m "feat(sidebar): agent 终端等待 banner(needle + 跳过等待按钮)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: tab 标题 ⏳ 徽章(shell 层特例)+ jsdom 链路测试
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/client/Sidebar.tsx`(`tabBadgeOf` ~828-841 行)
|
||||
- Test: `tests/agent-wait-badge.spec.tsx`(新文件,harness 照抄 `tests/bottom-auto-terminal.spec.tsx` 1-97 行的模式)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 3 的 `state.agentWaits`;`isAgentTabId` / `agentUuidOf`(Sidebar.tsx 已 import,第 38-39 行)。
|
||||
- Produces: agent tab 的 ⏳ 徽章 pill(复用 `css.tabBadge`);推送 → 状态 → 徽章全链路。
|
||||
|
||||
- [ ] **Step 1: 写失败测试(新文件 `tests/agent-wait-badge.spec.tsx`)**
|
||||
|
||||
完整文件(FakeWebSocket 记录 url 与实例,驱动 `/sidebar/ws/agent-terminals` 推送;`registerStubTerminal` 防止懒加载 chunk 在 jsdom 里拉真实 bundle):
|
||||
|
||||
```tsx
|
||||
/**
|
||||
* The agent-terminal wait badge chain, end to end in jsdom: the host's
|
||||
* agent-terminals push (mirroring the real /sidebar/ws/agent-terminals
|
||||
* frames) reconciles tabs AND the wait map; the shell's tabBadgeOf renders
|
||||
* the hourglass pill on the agent tab while a wait is live and drops it
|
||||
* when the push clears. Harness mirrors tests/bottom-auto-terminal.spec.tsx
|
||||
* (real Sidebar shell + fake context + stubbed WebSocket).
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { setupReactAct } from './test-utils.ts'
|
||||
setupReactAct()
|
||||
|
||||
import { Sidebar } from '../src/client/Sidebar.tsx'
|
||||
import { createSidebarStore, openTabInActivePane } from '../src/client/state.ts'
|
||||
import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts'
|
||||
|
||||
/** jsdom has no WebSocket; the agent-terminals push effect constructs one on mount. */
|
||||
class FakeWebSocket {
|
||||
static instances: FakeWebSocket[] = []
|
||||
onmessage: ((event: { data: unknown }) => void) | null = null
|
||||
onclose: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
close = (): void => {}
|
||||
constructor(readonly url: string) {
|
||||
FakeWebSocket.instances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
let sessionSeq = 0
|
||||
|
||||
function mountSidebar(): { container: HTMLDivElement; store: ReturnType<typeof createSidebarStore>; service: BetterSidebarService; unmount: () => void } {
|
||||
vi.stubGlobal('WebSocket', FakeWebSocket)
|
||||
FakeWebSocket.instances = []
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
// Replace the terminal descriptor with a stub: the real one lazy-loads the
|
||||
// xterm chunk, which jsdom cannot fetch — the badge under test renders in
|
||||
// the TAB STRIP, independent of the view component.
|
||||
service.registerTab({
|
||||
id: 'terminal',
|
||||
title: () => 'Terminal',
|
||||
component: () => null,
|
||||
})
|
||||
const sessionId = `s1-${++sessionSeq}`
|
||||
store.setSession(sessionId)
|
||||
// Stub the terminal descriptor: the real one lazy-loads the xterm chunk,
|
||||
// which jsdom cannot fetch (the bottom-auto-terminal harness does the same).
|
||||
service.registerTab({
|
||||
id: 'terminal',
|
||||
title: () => 'Terminal',
|
||||
component: () => null,
|
||||
})
|
||||
const localeSnapshot = { active: 'en' }
|
||||
const sessionsSnapshot = {
|
||||
current: sessionId,
|
||||
byId: { [sessionId]: { cwd: '/tmp' } },
|
||||
}
|
||||
const ctx = {
|
||||
locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot },
|
||||
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } },
|
||||
betterSidebar: service,
|
||||
get: (name: string) => name === 'betterSidebar' ? service : undefined,
|
||||
}
|
||||
const root: Root = createRoot(container)
|
||||
act(() => { root.render(createElement(Sidebar, { ctx: ctx as never, store })) })
|
||||
return {
|
||||
container,
|
||||
store,
|
||||
service,
|
||||
unmount: () => {
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let sessionSeq = 0
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
localStorage.clear()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** The agent-terminals push socket the shell opened for the current session. */
|
||||
function feedsSocket(): FakeWebSocket {
|
||||
const socket = FakeWebSocket.instances.find(candidate => candidate.url.includes('/sidebar/ws/agent-terminals'))
|
||||
if (socket === undefined) throw new Error('agent-terminals socket not connected')
|
||||
return socket
|
||||
}
|
||||
|
||||
describe('agent terminal wait badge (push → state → tab pill)', () => {
|
||||
it('shows ⏳ on the agent tab while a wait is active and drops it when it ends', () => {
|
||||
const { container } = mountSidebar()
|
||||
const terminal = { uuid: 'u1', title: 'dev server', command: '', exited: false }
|
||||
// First push: the agent tab lands in the active pane.
|
||||
act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([terminal]) }) })
|
||||
expect(container.textContent).toContain('dev server')
|
||||
expect(container.textContent).not.toContain('⏳')
|
||||
// Second push carries a live wait → the hourglass pill appears.
|
||||
act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([{ ...terminal, waiting: { needle: 'READY_1', since: 1 } }]) }) })
|
||||
expect(container.textContent).toContain('⏳')
|
||||
// The wait resolved (skip/exit/abort all converge to a waiting-less push).
|
||||
act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([terminal]) }) })
|
||||
expect(container.textContent).not.toContain('⏳')
|
||||
})
|
||||
|
||||
it('shows ⏳ only on agent tabs, never on UI-owned terminal tabs', () => {
|
||||
const { container, store } = mountSidebar()
|
||||
// A UI-owned terminal tab (id NOT agent:) lives in the strip too; the
|
||||
// badge lookup keys on the agent uuid, so it never badges.
|
||||
act(() => { store.reduce(s => openTabInActivePane(s, { id: 'terminal:manual-1', type: 'terminal', title: 'UI terminal' })) })
|
||||
const terminal = { uuid: 'u1', title: 'dev server', command: '', exited: false }
|
||||
act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([{ ...terminal, waiting: { needle: 'READY_1', since: 1 } }]) }) })
|
||||
// Exactly ONE hourglass: the agent tab. (Counted from textContent — CSS
|
||||
// module class names are not stable under the test transform.)
|
||||
expect((container.textContent.match(/⏳/g) ?? []).length).toBe(1)
|
||||
// The wait resolved → the pill disappears entirely.
|
||||
act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([terminal]) }) })
|
||||
expect(container.textContent).not.toContain('⏳')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
> 实现注意:`sessionSeq` 声明放 `mountSidebar` 之前(与 bottom-auto-terminal.spec.tsx 相同的模块级写法);`createSidebarStore` 返回类型直接用 `ReturnType<typeof createSidebarStore>`,勿手写接口。
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `pnpm exec vitest run tests/agent-wait-badge.spec.tsx`
|
||||
Expected: FAIL(⏳ 从不出现)。
|
||||
|
||||
- [ ] **Step 3: 实现 Sidebar.tsx 的 `tabBadgeOf` 特例**
|
||||
|
||||
`src/client/Sidebar.tsx` 828 行 `const tabBadgeOf = (tab: SidebarTab): ReactNode => {` 函数体开头(descriptor 查找之前)插入:
|
||||
|
||||
```ts
|
||||
// Agent-terminal wait indicator (sidebar-internal, deliberately NOT a
|
||||
// TabDescriptor.badge — that API is type-keyed and shared with external
|
||||
// plugins, and cannot address one tab): the agent-terminals push mirrors
|
||||
// the model's live terminal_wait_for into state.agentWaits; an agent tab
|
||||
// whose uuid is waiting shows the hourglass pill.
|
||||
if (isAgentTabId(tab.id)) {
|
||||
const wait = state.agentWaits?.[agentUuidOf(tab.id)]
|
||||
if (wait !== undefined) return <span className={css.tabBadge}>{'⏳'}</span>
|
||||
}
|
||||
```
|
||||
|
||||
(`isAgentTabId` / `agentUuidOf` 已在 38-39 行 import;`css` / `state` 均在作用域内。)
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过**
|
||||
|
||||
Run: `pnpm exec vitest run tests/agent-wait-badge.spec.tsx`
|
||||
Expected: PASS(2 个用例)。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/client/Sidebar.tsx tests/agent-wait-badge.spec.tsx
|
||||
git commit -m "feat(sidebar): agent 终端 tab 标题在等待时显示 ⏳ 徽章"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 设计文档偏差记录 + 全量验证 + 推送 + PR
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/plans/2026-09-09-terminal-wait-banner-design.md`(实施偏差记录节)
|
||||
- 无代码改动。
|
||||
|
||||
- [ ] **Step 1: 更新设计文档**
|
||||
|
||||
在 `docs/plans/2026-09-09-terminal-wait-banner-design.md` 末尾追加:
|
||||
|
||||
```markdown
|
||||
|
||||
## 实施偏差记录
|
||||
|
||||
- **⏳ tab 徽章不走 `TabDescriptor.badge`**:实现时核实该 API 是 type-keyed
|
||||
(`badge(ctx, scope, state)`,无 tab 参数,external 插件共享),无法定位单个
|
||||
tab。改为 Sidebar 壳层 `tabBadgeOf` 的 sidebar-internal 特例(agent: tab 前缀
|
||||
判断 + `state.agentWaits` 查表),`service.ts` 与 external-plugin-guide.md
|
||||
零改动。
|
||||
- **banner 抽为独立组件 `TerminalWaitBanner.tsx`**:设计原文写在
|
||||
TerminalView.tsx 内;抽出后无 xterm 依赖,可直接被 jsdom 单测渲染
|
||||
(TerminalView 仍负责订阅 store 并接线 onSkip → api.agentSkipWait)。
|
||||
- **设计文档随 feat 分支进 PR**:main 受分支保护,纯文档直推被 hook 拒绝
|
||||
(`4a6a77a` 随本分支合并)。
|
||||
- **banner 配色**:仓库 CSS 无 state-info 令牌,沿用 `.terminalBanner` 的
|
||||
warn 对(`--dsw-alias-state-warn-label/-tertiary`),符合皮肤契约。
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 全量验证**
|
||||
|
||||
Run(依次,全绿才继续):
|
||||
`pnpm lint` → `pnpm typecheck` → `pnpm test` → `pnpm build`
|
||||
|
||||
Expected:
|
||||
- lint: 0 error(历史上 CI 因 no-useless-assignment 挂过——新代码避免「先声明后必然覆盖」的写法);
|
||||
- test: 除已知的 win32 非回归失败集(bottom-auto-terminal / free-window / sidebar-auto-activation / sidebar-crash 等既有失败,对照 main 基线)外全绿,新增 spec 全部 PASS;
|
||||
- build: lib/ 产物含新 chunk `client.js` 变化(TerminalWaitBanner 打进 client bundle)。
|
||||
|
||||
- [ ] **Step 3: 提交并推送**
|
||||
|
||||
```bash
|
||||
git add docs/plans/2026-09-09-terminal-wait-banner-design.md
|
||||
git commit -m "docs(plans): 终端等待 banner 实施偏差记录"
|
||||
git push -u origin feat/wait-banner
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 建 PR**
|
||||
|
||||
```bash
|
||||
gh pr create --repo omdsh-dev/DSH-better-sidebar --base main --head feat/wait-banner \
|
||||
--title "feat(terminal): 等待 banner + 跳过等待(terminal_wait_for skipped)" \
|
||||
--body "设计:docs/plans/2026-09-09-terminal-wait-banner-design.md(随本 PR)。agent 阻塞在 terminal_wait_for 时:对应终端卡片顶部显示「Agent 正在等待 {needle}」banner,右侧跳过按钮走 agent-pty.skip-wait;tab 标题 ⏳ 徽章;工具新增 {kind:'skipped'} 返回。"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 等待 CI(用 terminal_wait_for 盯 `(ci|ci-windows|plugin-mount).*\((pass|fail)\)` 之类的多结局 needle,勿 sleep)**
|
||||
|
||||
Expected: 三个 job 全 pass。
|
||||
@@ -0,0 +1,60 @@
|
||||
# 自动激活落点修复:任务页回到 DSH 原生右侧栏
|
||||
|
||||
日期:2026-09-10 分支:`fix/auto-activation-native-landing`
|
||||
|
||||
## 背景
|
||||
|
||||
用户报告:**自动打开的侧边栏是底栏,而不是右侧边栏**。两个自动激活开关(`autoOpenSubagent` / `autoOpenJobs`)与子代理拓扑跳回在 v0.19.0 上把任务页(`subagent` tab)开进了插件自绘的**底部工作台**,底栏随之展开;而产品文案描述的是侧边栏落点——README 特性表「新子代理 / 新任务可自动激活任务页,**宽屏同时展开侧边栏**,窄屏不强制展开全屏抽屉」,`settingsSubagentDesc` / `settingsJobsDesc` 及全部 20 份词典同样措辞,README 的 #314 条目也写着「宽屏展开侧边栏,窄屏只准备 Tab」。
|
||||
|
||||
## 根因
|
||||
|
||||
`223b254`(v0.19.0-alpha.0「退役插件自绘右侧面板与自由窗口」)改写了 `src/client/sidebar/use-host-feeds.ts` 的三处触发:
|
||||
|
||||
| | 改动前(v0.18.x) | 改动后(v0.19.0) |
|
||||
|---|---|---|
|
||||
| 展开 | `store.reduce(s => s.panelOpen ? s : togglePanel(s))`(宽屏才展开) | 无(由落点决定) |
|
||||
| 落点 | `activePane = firstLeaf(s.splits).id` + `openTab({ type:'subagent', title })` —— **无 `target`** | `openTab({ type:'subagent', title, target:'bottom' })` |
|
||||
|
||||
`service.ts:683` 的判据是「装了原生面 + 非 `bottom` → 落 DSH 原生右侧栏」,`target: 'bottom'` 直接绕过它走 `land = openTabInBottomPane`(`service.ts:744` → `state.ts:453`,置 `bottomOpen: true`)。也就是说:退役右侧面板时,`splits`(插件自己的右面板)消失,落点被改成了插件自己的**底**面板,而正解是当时同文件其它打开早已默认落上的新宿主——DSH 原生右侧栏。
|
||||
|
||||
## 决策
|
||||
|
||||
| # | 决策 | 理由 |
|
||||
|---|---|---|
|
||||
| 1 | 三处触发改回默认落点(`'right'`) | 宿主 `openContent` 内部 `planSetExpanded(state, true)`(`dsh-client-ui-sidebar-right`),打开即在同一步展开原生栏;这才是文案里的「侧边栏」 |
|
||||
| 2 | 后台活动(子代理 / 新任务)在窄屏**停放**,不展开 | 宿主 `RightbarSeat` 有 `autoFullscreen = viewportWidth < 768`,该宽度下原生栏以 `position: fixed; inset: 0` 全屏绘制——展开即整屏抢占,正是 #373 修掉的行为。宿主没有「只放 tab、不展开」的公开选项,故开前读 `ctx.sidebarRight.isExpanded()`、开后 `toggleExpanded()` 收回;两次提交落在同一 React 批内,中间态不会渲染 |
|
||||
| 3 | 停放仅在目标会话在屏时执行 | `isExpanded` / `toggleExpanded` 作用于**已挂载会话**(`mounted()` = `binding.surfaces[binding.sessionId]`);目标不在屏时执行会误动用户当前那一栏 |
|
||||
| 4 | 拓扑跳回(用户点击)一律展开 | 它是显式手势,不是后台活动;对齐 v0.18.x 跳回无窄屏门禁的语义 |
|
||||
| 5 | 底部工作台自有流程保持 `target: 'bottom'` | 底栏 `+` 菜单(`Sidebar.tsx:628`)与首次展开自动终端(`:336`)本就该落底栏;`OpenTabSeed.target` API 不变 |
|
||||
|
||||
阈值一致性:插件既有 `NARROW_MAX_WIDTH = 768`(`width < 768`,`src/client/breakpoints.ts`)与宿主 `viewportWidth < 768` 的全屏规则逐字对齐,不引入第二个断点。
|
||||
|
||||
窄屏语义与 v0.18.x 等价:**tab 已就位、抽屉不展开**(用户点开会话头右侧的展开控件即可看到任务页)。
|
||||
|
||||
## 改动清单(子系统级)
|
||||
|
||||
| 子系统 | 文件 | 变更 |
|
||||
|---|---|---|
|
||||
| 触发 | `src/client/sidebar/use-host-feeds.ts` | 新增模块内 `activateTasksPage(ctx, sessionId, { background })`(落点 + 停放判定集中在此);三处调用改为它(子代理 / 后台任务 `background: true`,跳回 `false`);重新引入 `isNarrowWidth`;三段 docblock 与文件头同步 |
|
||||
| 测试 | `tests/sidebar-auto-activation.spec.tsx` | 断言原生面收到 `openTab { kind:'subagent', revealIfOpened:true }`、底部工作台保持原状(未展开、未新增 tab)、窄屏停放次数(1 / 0)、宽屏与已展开栏不停放、去抖触发时读视口、跳回不停放 |
|
||||
|
||||
无 API / 持久化 / i18n / README 行为文案改动:文案本就在描述修复后的行为。
|
||||
|
||||
## 验证
|
||||
|
||||
- **回归守护(确定性)**:新 spec 10 passed;把 `src/client/sidebar/use-host-feeds.ts` 回退到 `HEAD` 后同 10 条**全红**(10 failed),证明它测的正是这次修复。
|
||||
- **门槛**:`pnpm typecheck` / `pnpm lint` / `pnpm check:consumer-types` 干净;`pnpm test` = **124 files / 1299 passed / 9 skipped**(较基线 +3 条,即本 spec 的净增)。
|
||||
- **打包挂载**:`pnpm build && pnpm pack && pnpm test:mount` = **7 passed**(真实 DSH 0.1.5-rc.1,scratch profile,不触碰用户 `~/.dsh`)。
|
||||
- **环境事实(踩过一次)**:本机 PATH 上的 `dsh` 是 **0.1.2-rc.1**,用它跑挂载车道会在 `dsh plugin add` 后报 `dsh-better-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册`(该版本不会把 `file:` tarball 注册进 profile bundles,且插件 peer 要求 `^0.1.5-rc.1`)。CI 装的是钉版 0.1.5-rc.1;本地需用同版本的 shim / `DSH_CMD` 才能复现车道结果。
|
||||
- **真机**:见 PR 描述(3080 实例,web profile 换装新 tarball 后人工触发子代理 / 后台任务确认)。
|
||||
|
||||
## 未覆盖(诚实记录)
|
||||
|
||||
- 挂载车道是 keyless 的(无模型),无法真派生子代理或后台任务,因此**该行为只有单测是确定性证据**;宿主 `session/create` 的请求体不接受 `origin` / `parentId`,也无法用 RPC 造出 `origin: 'subagent'` 的子会话,所以真机断言只能依赖人工触发或非确定性的 prompt。
|
||||
- 宿主若在窄屏自行折叠(`shown && !fullscreen && !canShow` 的 room rule),停放不会与之冲突(两者都指向「不展开」)。
|
||||
|
||||
## 不做
|
||||
|
||||
- 不新增设置项、不改变 `OpenTabSeed.target` 的语义、不把任务页改到别处承载。
|
||||
- 不动底栏 `+` 菜单 / 首次展开自动终端(它们是底部工作台自己的流程)。
|
||||
- 不改 DSH 源码(仓库硬约束 §1)。
|
||||
@@ -0,0 +1,86 @@
|
||||
# DSH 0.1.5-rc.1 适配(v0.19.0 正式版基线)
|
||||
|
||||
日期:2026-09-10 分支:`feat/dsh-0.1.5-rc.1`
|
||||
|
||||
## 背景
|
||||
|
||||
DSH `dsh-v0.1.5-alpha.2..dsh-v0.1.5-rc.1`(上游 commit `183f08e9c`,npm 发布 2026-09-10T03:12Z)的 delta 很小:373 个变更文件,绝大多数是各包 `package.json` 版本号、原生右侧栏预览 UI 打磨与测试快照。触及本插件的只有一处契约回归——`SidebarRightGuideEntry.description` 回来了。本计划记录该 delta、插件对它的取舍、以及 v0.19.0 正式版的基线决策。上一版(alpha.2)的适配记录见 [2026-09-09-dsh-0.1.5-alpha.2-adaptation.md](./2026-09-09-dsh-0.1.5-alpha.2-adaptation.md);插件对外的全部行为差异以 [docs/external-plugin-guide.md](../external-plugin-guide.md) 为准;仓库级适配要点(基线、CI 钉版、宿主契约)同步到 [AGENTS.md](../AGENTS.md) §2/§3。
|
||||
|
||||
## 上游 delta(alpha.2 → rc.1,影响本插件的三项)
|
||||
|
||||
| # | 上游变更 | 对插件的影响 |
|
||||
|---|---|---|
|
||||
| 1 | `SidebarRightGuideEntry.description?: () => string` 回归(可选):原生指南**只在列出条目 ≤ 4 条时**渲染它(上游 `MAX_DESCRIBED_ENTRIES = 4`),更长的列表整列丢弃说明;没有 `icon` 的条目改由宿主补方块占位 | 恢复 `TabDescriptor.description`(六个内置类型声明它,`guideDesc*` 词条一并回来);**不恢复**旧的通用兜底句 `nativeGuideDesc`——宿主自己没有兜底,通用句在所有页面上长得一样、纯噪音;未声明该字段的 descriptor 就不发 `description` 字段 |
|
||||
| 2 | 内置 `files` 类型重新声明指南说明,且改用彩色的 `FileTypeIcon kind="folder"` 作指南字形 | 仅背景信息:`files` 页面 kind 由插件接管,该行现在随插件渲染(`editor` descriptor 的图标与说明) |
|
||||
| 3 | `@deepseek-ai/dsh-client-ui-primitives@0.1.5-rc.1` **仍不声明 `dependencies`**,而其 bundle 继续裸 import `anser` / `shiki` / `@shikijs/langs/*` / `mdast-util-*` / `micromark-*` / `katex` | 上一版提升进 devDependencies 的那组第三方包**不得回退**(回退即 vitest 一加载 primitives 就 `Cannot find package 'anser'`);`ui-primitives` 的导出面未变,只有 CodeBlock 渲染变化 |
|
||||
|
||||
**明确未变**(不必再核):
|
||||
|
||||
- 全局主面板模型——根级 keyed `main` 槽、`sidebar.panellist`、`ctx.layout.selectPanel` / `beginNavigation`、根级 `rightbar` + `rightbar.session`;插件仍不接入。
|
||||
- 文件地址语法——`packages/util/workspace-path` 只动了 `package.json` 的版本号。
|
||||
- 原生 tab 体宿主契约——`dsh-client-ui-dockkit` 的 `.paneBody` 仍是「有确定高度的块级滚动容器」,只加了两行 z-index。
|
||||
- core / agent / session / subagent 宿主 API——仅版本号变化。
|
||||
|
||||
## 决策
|
||||
|
||||
- **插件版本 0.19.0(无 prerelease 后缀)**:`release.yml` 按「版本号是否含 `-`」自动选 dist-tag,因此本版发 npm **`latest`**;npm 的 `alpha` 标签保持指向 `0.19.0-alpha.1`(上游 rc.1 自己的 GitHub Release 仍标 pre-release,与本插件的通道无关)。
|
||||
- **基线 = DSH 0.1.5-rc.1+ only**:peer 下限 `^0.1.5-rc.1`、manifest `engines.dsh >=0.1.5-rc.1`、CI 钉 `@deepseek-ai/dsh@0.1.5-rc.1`。DSH **0.1.5-alpha.2 用户留在 v0.19.0-alpha.1**,DSH **0.1.2-rc.1 用户留在 v0.18.1**。
|
||||
- **恢复 `description`,不恢复通用兜底句**:宿主渲染第二行时读 descriptor 自己的说明;`nativeGuideDesc` 兜底句不再存在——没写说明的条目就是单行「图标 + 标题」。
|
||||
- **接受 ≤4 渲染规则,不做额外工程**:插件默认贡献 6 个 guide 条目 > 4,说明在默认组合下不渲染(见下节)。不为此裁剪内置类型、不改条目数、不把关键信息只放进说明里。
|
||||
- **升级本地 3080 实例**到 DSH 0.1.5-rc.1 + 插件 0.19.0,作为真机观察点。
|
||||
|
||||
## ≤4 渲染规则的后果(重要)
|
||||
|
||||
上游只在 guide 列出的条目 **≤ 4 条**时渲染 `description`(`MAX_DESCRIBED_ENTRIES = 4`),更长的列表整列丢掉所有说明——注意是「整列丢弃」,不是截断到前四条。插件默认贡献 6 条 guide 条目(文件 / 文件变动 / 任务管理 / 侧边对话 / 终端 / 浏览器),**因此在默认组合下,插件与内置类型的说明都不会出现**,六条仍是「图标 + 标题」。
|
||||
|
||||
说明只在读者于插件设置页关掉足够多的 tab 类型、把 guide 压到 ≤ 4 条时才出现(逐类型开关;关掉 `editor` 会同时移除接管出去的「文件」行)。这是上游行为而非插件缺陷:插件恢复字段是为了让「关到 ≤ 4 条」的读者拿到真实说明,而不是为了在默认组合里显示什么。指南 §0 与 §4.1 都写明了这条规则,消费插件不应把关键信息只放在 `description` 里。
|
||||
|
||||
## 改动清单(子系统级)
|
||||
|
||||
| 子系统 | 文件 | 变更 |
|
||||
|---|---|---|
|
||||
| 基线 | `package.json` | 版本 → `0.19.0`(无后缀);dsh-* peer 全部 `^0.1.5-rc.1`;devDeps 同步钉 `0.1.5-rc.1`;`dsh-client-locale` 跟随基线到 `0.1.5-rc.1`(该包已回到与 DSH 同 tuple 发版,§3-9 的落后例外保持消除) |
|
||||
| 基线 | `pnpm-lock.yaml` | 锁文件对齐 rc.1 |
|
||||
| 基线 | `dsh.plugin.json` | `version` → `0.19.0`;`engines.dsh` → `>=0.1.5-rc.1` |
|
||||
| 基线 | `.github/workflows/ci.yml` | mount lane 钉 `@deepseek-ai/dsh@0.1.5-rc.1`(rc.1 在 npm 上同时是 `latest` 与 `next`) |
|
||||
| 基线 | `package.json` | 上一版提升的第三方 devDependencies(anser / shiki / `@shikijs/langs` / `mdast-util-*` / `micromark-*` / katex)保留不回退——`ui-primitives@0.1.5-rc.1` 仍无 `dependencies` |
|
||||
| Tab 契约 | `src/client/service.ts` | `TabDescriptor` 恢复可选 `description`;`SIDEBAR_SERVICE_VERSION` 跟随插件版本(`0.19.0`) |
|
||||
| Tab 契约 | `src/client/builtins/tabs.tsx` | 六个内置类型(editor / git / subagent / sidechat / terminal / browser)重新声明 `description` |
|
||||
| Tab 契约 | `src/client/native/index.ts` | guide 条目在 descriptor 声明了说明时带上 `description`;未声明就不发该字段(不补通用句) |
|
||||
| 词典 | `src/client/locales.ts` + 19 份 `locales-*.ts` | 恢复 6 个 `guideDesc*` 词条(zh / en / ja 与其余第三方语言) |
|
||||
| 测试 | `tests/builtins.spec.ts` | 指南断言从「仅 `title`」改回「`title` + `description`」 |
|
||||
| 测试 | `tests/native-surface.spec.ts` | 断言 guide 条目携带 `description`;`editor` 类型仍不单独占行、接管行承载其图标与说明 |
|
||||
| 文档 | `docs/external-plugin-guide.md` | 顶部基线行(v0.19.0 / DSH 0.1.5-rc.1+ / 两个钉版旧版本)、§0 「新建标签页列表」行、§4.1 恢复 `description` 字段与 ≤4 规则 |
|
||||
| 文档 | `docs/plans/2026-09-09-dsh-0.1.5-alpha.2-adaptation.md` | 加一行前向链接(该版删掉的 `description` 在 rc.1 回归) |
|
||||
| 文档 | `docs/plans/2026-09-10-dsh-0.1.5-rc.1-adaptation.md` | 本计划 |
|
||||
| 文档 | `AGENTS.md` | §2 CI 基线戳、§3 标题与基线 prose、§3 第 9/10 条(devDeps 不回退 / `description` 回归) |
|
||||
| 文档 | `README.md` / `README_EN.md` | 顶部与安装节的「支持的 DSH 版本」徽章、安装节正式版说明、新增 `### v0.19.0` 更新段落 |
|
||||
|
||||
## 验证
|
||||
|
||||
- **本工作流(文档)未执行任何门禁或真机验证**,只做了与工作树现状的交叉核对:`package.json` 为 `0.19.0`(dsh-* peer 与 devDep 均 `0.1.5-rc.1`)、`dsh.plugin.json` 的 `engines.dsh` 为 `>=0.1.5-rc.1`、`.github/workflows/ci.yml` 钉 `@deepseek-ai/dsh@0.1.5-rc.1`、`SIDEBAR_SERVICE_VERSION` 为 `0.19.0`、`src/client/builtins/tabs.tsx` 六个类型与 `src/client/native/index.ts` 的 guide 条目(含 `files` 接管行)重新带上 `description`、`src/client/locales*.ts` 恢复 6 个 `guideDesc*` 键。
|
||||
- 仓库**没有守护指南文档章节的测试**——`tests/builtins.spec.ts` / `tests/native-surface.spec.ts` 守护的是 guide 行的形态,不是文档文本,因此文档改动没有对应的测试命令可跑。
|
||||
- **待补(由主代理在实施完成后回填,本文件不预填未经执行的结论)**:门禁数字(`pnpm typecheck` / `pnpm lint` / `pnpm vitest run --exclude '**/*.e2e.ts'` / `pnpm peers check`)、`scripts/e2e-mount.sh` 真机挂载车道的通过数,以及本地 3080 实例(DSH 0.1.5-rc.1 + 插件 0.19.0)上的实测结果——默认组合下 6 条 guide 行仍是「图标 + 标题」、关掉 tab 类型至 ≤ 4 条后说明出现、以及 `pageerror` / 控制台状况。
|
||||
|
||||
### 回填(主代理实测)
|
||||
|
||||
- **门禁**:`pnpm typecheck` / `pnpm lint` / `pnpm check:consumer-types` 全绿;`pnpm vitest run --exclude '**/*.e2e.ts'` = **124 files / 1296 passed / 9 skipped**;`pnpm peers check` = No peer dependency issues found(rc.1 未新增失配的传递 peer,因此没有再提升任何 devDep)。
|
||||
- **挂载冒烟**(`DSH_CMD` 指向 npm 安装的 0.1.5-rc.1,`scripts/e2e-mount.sh`):**7 passed**,含 tab 体填充断言与新增的 ≤4 条描述断言。
|
||||
- **≤4 断言的真实形态**:默认 6 条下永远拿不到说明文本,所以该用例先用插件自己的 `settings.get` / `settings.update` 把启用集合压到 3 个类型(files / sidechat / browser),断言 `files` 胶囊文本含英文说明片段 `workspace tree`,并断言被禁用的三个条目离开 guide。
|
||||
- **踩坑(已修)**:恢复设置时最初只把原始 `tabsEnabled`(scratch profile 里是 `{}`)原样发回——而该路由的 patch 是**逐键合并**,空对象清不掉刚写下的三个 `false`,于是同车道的 `perf.e2e.ts` 扫 guide 时找不到 `git` 条目而失败。改为对本次动过的每个键显式恢复(原值缺省即 `true`)后全绿;这条经验对任何"临时改设置再恢复"的 e2e 都适用。
|
||||
- **真机 3080**(DSH 0.1.5-rc.1 + 插件 0.19.0):guide 六行胶囊均为「图标 + 标题」、**无说明**(6 > 4,符合上游规则);胶囊高度 56px(上游把 `min-height` 从 alpha.2 的 48px 调到 56px);文件树点击 `AGENTS.md` → tab 标题 `AGENTS.md` + CodeMirror 挂载;sidechat 宿主盒 962px == `.paneBody` 962px(fillGap 0),composer 底边距 8px;底部工作台与 `[data-dsh-center-col]` 左右边完全重合(gap 0);`pageerror` 0;控制台仅第三方 `dsh-tauri-worktree` 的 `/api/dsh-worktree/attach` 500(alpha.2 期同样存在,与本次无关)。rc.1 真机的 `[data-slot]` 清单与 alpha.2 完全一致(`main` / `main.conversation` / `rightbar` / `rightbar.session`),印证"全局面板模型未变"。
|
||||
|
||||
## 不做
|
||||
|
||||
- 不为 ≤4 渲染规则裁剪内置 tab 类型、调整条目数或改写图标。
|
||||
- 不恢复 `nativeGuideDesc` 通用兜底句。
|
||||
- 不接入 alpha.2 的全局面板(`main` 槽 / `sidebar.panellist` / `ctx.layout` / `usePanelInfo` / 根级 `rightbar`)——沿用上一版结论,rc.1 上这些面同样只是兼容保留。
|
||||
- 不回退提升进 devDependencies 的第三方包。
|
||||
- 不改 DSH 源码(仓库硬约束 §1)。
|
||||
|
||||
## 落地
|
||||
|
||||
- [#613](https://github.com/omdsh-dev/DSH-better-sidebar/pull/613) 合入 `main`(merge commit `754974a`);main 的 `package.json` 为 `0.19.0`、`engines.dsh` 为 `>=0.1.5-rc.1`。PR 的 `ci` / `ci-windows` / `plugin-mount` 三个 check 全绿。
|
||||
- 发布 [v0.19.0](https://github.com/omdsh-dev/DSH-better-sidebar/releases/tag/v0.19.0):`release.yml` 通过全门禁后经 npm Trusted Publishing 发布到 **`latest`**(带 provenance);实测 dist-tags:`latest = 0.19.0`、`alpha = 0.19.0-alpha.1`(不变)、`beta` 不变。
|
||||
- 产物核对:`lib/index.js` 与本地已验证构建**逐字节一致**,且与 `v0.19.0-alpha.1` 的同名文件同哈希(宿主侧代码在这两版之间确实没动);`lib/client.js` 仅差已知的打包非确定性;发布包的 client bundle 内含恢复后的说明文案。
|
||||
- **npm 传播延迟仍是 ~4–5 分钟**:06:49:10 工作流报 `Published package dsh-better-sidebar@0.19.0`,06:53:53 dist-tags 才可见 `latest = 0.19.0`(版本元数据与 tarball 依次到达,期间 `npm view` / tarball 会 404)。这不是发布失败,别重发。
|
||||
@@ -0,0 +1,104 @@
|
||||
# DSH 0.1.5-rc.2 适配(v0.19.1 基线)+ CI 三类失败修复
|
||||
|
||||
日期:2026-09-10(复核 2026-09-11) 分支:`feat/dsh-0.1.5-rc.2`
|
||||
|
||||
## 背景
|
||||
|
||||
DSH `dsh-v0.1.5-rc.1..dsh-v0.1.5-rc.2`(上游 4 个 commit / 300 个变更文件,npm 发布 2026-09-10T14:57Z,dist-tag **`next`**;`latest` 仍是 rc.1)是 0.1.5 线的第二个候选版。本计划记录该 delta、插件对它的结论(**无需任何运行时/类型层适配**,只推进钉版),以及同一时间窗内 `ci-windows` / `plugin-mount` 两个车道真实存在的三类 CI 失败与修法。上一版(rc.1)记录见 [2026-09-10-dsh-0.1.5-rc.1-adaptation.md](./2026-09-10-dsh-0.1.5-rc.1-adaptation.md);仓库级适配要点同步到 [AGENTS.md](../../AGENTS.md) §2/§3。
|
||||
|
||||
## 上游 delta(rc.1 → rc.2,影响本插件的部分:无)
|
||||
|
||||
300 个文件里绝大多数是各包 `package.json` 的 `version` 单行改动(`1+1-`)。逐文件过完后的真实代码改动只有四处:
|
||||
|
||||
| # | 上游变更 | 文件 | 对插件的影响 |
|
||||
|---|---|---|---|
|
||||
| 1 | 消息反馈改为对称提交(弹窗、动作按钮、控制器、槽位、i18n) | `packages/client/ui-message-feedback/src/client/*` | 无——插件不注册反馈槽、不读该控制器 |
|
||||
| 2 | 产物卡片内边距与呈现微调 | `packages/client/ui-deliverables/src/client/*`、`ui-chat/src/client/chat/TurnTailNodeView.module.css` | 无——插件不消费这两个包的组件,也不做会话尾迹布局 |
|
||||
| 3 | `CodeFileIcon` 的 477 行内联 SVG 拆进新增的 `code-file-icon-artwork.ts`(另有 `.manifest.json` 记录设计导出摘要) | `packages/client/ui-primitives/src/CodeFileIcon.tsx` | 无——`CodeFileIcon` 的导出面与 props 未变,且插件**本来就不用它**(插件文件树用 `IconCodeOutline16`;`#611` 的文件图标主题还在 PR #594,未进 main) |
|
||||
| 4 | 其余全部为版本号 | 各 `package.json` | 无 |
|
||||
|
||||
**明确未变(不必再核)**:
|
||||
|
||||
- `packages/api/*`(session-controller / workspace-controller / gateway / remotes)、`packages/host/*`、`packages/session/*`、`packages/agent/*`——**零**变更;除 `packages/feedback/message-feedback/src/types.ts`(1 行)外**零**类型文件变更。
|
||||
- 斜杠 RPC 方言、一次性 token 鉴权、会话事件模型、`sidebarRight` / `sidebarRightTabs` 承载面、`SidebarRightGuideEntry.description` 的 ≤4 渲染规则:全部沿用 rc.1 结论。
|
||||
- `@deepseek-ai/dsh-client-ui-primitives` 仍不声明 `dependencies`(bundle 仍裸 import `anser` / `shiki` / `@shikijs/langs/*` / `mdast-util-*` / `micromark-*` / `katex`)——提升进 devDependencies 的那组包继续不得回退。
|
||||
|
||||
## 决策
|
||||
|
||||
- **peer 下限保持 `^0.1.5-rc.1` 不动**:rc.2 无契约变化,`^0.1.5-rc.1` 天然容纳它,rc.1 用户无需升级即可用新版插件。因此本版是**纯 patch 的 0.19.1**,只推进 devDependency 钉版、CI 挂载钉版与 `SIDEBAR_SERVICE_VERSION`。
|
||||
- **`engines.dsh` 保持 `>=0.1.5-rc.1`**:与 peer 下限同源(同一个支持范围,两处不能分叉)。
|
||||
- **CI 挂载车道钉 rc.2**:真机证据要落在最新版上;`latest` 仍是 rc.1 只是上游的发布节奏,与本插件支持范围无关。
|
||||
- **不为此版写运行时兼容层**:上游 delta 里没有本插件触达的面,写兼容层就是无据的复杂度。
|
||||
|
||||
## CI 三类失败(2026-09-09/10 窗口,60 次已完成 run 的取证)
|
||||
|
||||
窗口内 `ci-windows|Test` 失败 8 次、`plugin-mount|Install dsh CLI (pinned)` 失败 4 次。逐条读日志后归为三类,其中两类是**真实缺陷**、一类是**运维噪音**:
|
||||
|
||||
### A. Windows 真实超时(6/8 次 `ci-windows` 失败,主因)
|
||||
|
||||
- 证据:run `34494370187`、`34490215638`、`34459499945`(**3 次 attempt 全红**)、`34372999975`、`34322271074`、`34319666611` 均为 `Error: Test timed out in 5000ms`,位置 `tests/agent-pty.spec.ts:71:3` / `:106:3`;`tests/install-powershell.spec.ts:105:41` 的 `powershell.exe` 单测自身耗时 **12151ms** 后才超时。
|
||||
- 根因:`vitest.config.ts` 没有任何 `testTimeout` 配置(默认 5000ms),而这些 spec 每个用例都要真起一个 `PowerShell + ConPTY` 对;`tests/agent-pty.spec.ts` 的 `waitForTranscript` 默认预算**也**是 5000ms——内层轮询吃掉整个对外预算,结构上必然超时。
|
||||
- 修法:**全局** `testTimeout: 15_000`(`vitest.config.ts`)+ 两个最重的 describe 显式 `{ timeout: 30_000 }`(vitest 4 的 describe 级选项,已实测生效)+ `waitForTranscript` 默认预算降到 15s。**为什么是全局而不是逐文件**:本 PR 第一次跑真实 Windows 时,同一个 5000ms 默认又在**第三个文件**上翻车——`tests/git.spec.ts:85`「caps status entries at the truncation limit」耗时 **9607ms**(构造病态 untracked 集合 + 真实 git 调用)。三个不同文件、同一个根因,逐文件加超时是打地鼠;15s 是默认值的 3 倍、也是 Windows 上实测最大单测耗时的 ~1.5 倍,而 pty / powershell 两类最重的用例各有自己的 30s 预算。
|
||||
|
||||
### B. Windows worker 静默死亡(2 次,非确定性)
|
||||
|
||||
- 证据:run `34500290681`(295615abb)、`34448911025`(bbf953b8a) 报 `[vitest-pool]: Worker forks emitted error / Caused by: Error: Worker exited unexpectedly`(`Errors 1 error`),同一份日志里 `0 FAIL`、125 个文件全过;后者静默吞掉一个文件的 10 条结果。同一 commit `8ae527170` 的另一次 run 里 `ci-windows` 是**绿的** → 该步骤可证伪地非确定。
|
||||
- 归因(**推断,非确证**):真起进程的 spec 有 5 个(`agent-pty` / `pty-deps` / `pty-helpers` / `install-powershell` / `smoke`),node-pty 的 ConPTY 辅助进程与冷启 `powershell.exe` 都绑定控制台,在默认**无界 fork 池**下并行抢占;本地 macOS 无法复现该模式,因此不做无证据的猜测性改动(不加 `retry`,不改默认 pool)。
|
||||
- 修法(两次迭代,第一次被真实结果证否):先上 `test:windows`(`vitest run --maxWorkers=2`)——**真实 Windows 上仍复现**(run `34574847774`:126/127 文件全过、`0 FAIL`、`Unhandled Error: Worker exited unexpectedly`,丢结果的那个 worker 正是跑 `tests/native-surface.spec.ts` 的)。该次日志里 `native-surface.spec.ts` **零结果**,与历史两次崩溃同形(`34500290681` 125/126、`34448911025` 123/124——都是「N-1 个文件有结果 + teardown 期 worker 静默退出」)。
|
||||
- 迭代后的修法:`--maxWorkers=1`(整个套件跑在一个 fork 里,**零 worker 回收**)。理由:跨 4 次崩溃的一致形态是「worker 回收/退出竞态」,而不是某个测试失败;单 worker 从根上取消这个竞态,代价只是墙钟时间(本地 14s → 41s,Windows 步骤仍在 30 分钟 job 预算内)。**仍不加 retry**:retry 会把真实回归一起吞掉。
|
||||
|
||||
### C. `plugin-mount` 的 npm 安装失败(4 次:2× ETARGET + 2× OOM)
|
||||
|
||||
- 证据:run `34492160123` / `34491256626` 报 `npm error notarget No matching version found for @deepseek-ai/dsh-tool-bash-persistent@^0.1.5-rc.2` / `@deepseek-ai/dsh-sandbox-windows-acl@^0.1.5-rc.2`;run `34490912649` 先刷 **3062 条 ERESOLVE**、9 分钟后 `FATAL ERROR: Ineffective mark-compacts near heap limit` exit 134。同一机制在 2026-09-09 的 alpha 线也复现过一次(run `34363627184`,1808 条 ERESOLVE → OOM);同期成功的安装是 `added 521 packages in 19s`、**0 条 ERESOLVE**。
|
||||
- 根因:**钉版并没有真的钉住**。`@deepseek-ai/dsh@0.1.5-rc.1` 的传递依赖声明为浮动 `^0.1.5-rc.1`,会被上游**分阶段发布**中的 rc.2 满足(rc.2 的 14:43–14:57 逐个包上线),于是 npm 组出 rc.1/rc.2 混合 peer 图 → 解析器爆炸(OOM),或对尚未发布的兄弟包硬报 ETARGET(两个 ETARGET 分别在该包上线前 5.5 分钟与 25 秒)。
|
||||
- 修法(`.github/workflows/ci.yml`):钉一个**已完整发布**的 rc.2(修 ETARGET),并加 `NODE_OPTIONS=--max-old-space-size=4096`(修 OOM)。
|
||||
- **一次被真实结果否掉的中间方案(值得记)**:第一版修复还加了 `--legacy-peer-deps`(想法是「跳过 peer 协商即与发布窗口解耦」)。真实 Windows 结果表明它只是把安装**弄坏**而不是弄稳:它跳过的正是全局安装必须提供的 peer,而 `@deepseek-ai/dsh-app-boot` 在 boot 时 require 的 `@deepseek-ai/cordis-plugin-group` **是 peer 而不是 dependency**,于是 CLI 在冒烟开始前就以 `ERR_MODULE_NOT_FOUND` 死掉(run 34560785101 的 `plugin-mount`)。本地复现并验证:同一命令去掉该 flag 后安装树完整(`dsh/node_modules/@deepseek-ai/cordis-plugin-group` 存在),且 `DSH_CMD` 指向这个 classic 安装的 `test:mount` **7 passed**——宿主自带依赖,插件侧看不到差别。
|
||||
- 顺带修掉一处过时注释(原文断言「rc.1 同时是 `latest` 与 `next`」——现在 `latest=rc.1`、`next=rc.2`);这条注释正是浮动范围隐患长期不可见的来源。
|
||||
|
||||
## 改动清单(子系统级)
|
||||
|
||||
| 子系统 | 文件 | 变更 |
|
||||
|---|---|---|
|
||||
| 基线 | `package.json` | `version` → `0.19.1`;dsh-* **devDependencies** 全部钉 `0.1.5-rc.2`(含 `dsh-client-locale`,上游仍与 DSH 同 tuple 发版);**peerDependencies 一律保持 `^0.1.5-rc.1`** |
|
||||
| 基线 | `pnpm-lock.yaml` | 锁文件对齐 rc.2 |
|
||||
| 基线 | `dsh.plugin.json` | `version` → `0.19.1`;`engines.dsh` 保持 `>=0.1.5-rc.1`(与 peer 下限同源) |
|
||||
| 基线 | `src/client/service.ts` | `SIDEBAR_SERVICE_VERSION` → `0.19.1`(`tests/service.spec.ts` 断言它 === `package.json` 的 version) |
|
||||
| CI | `.github/workflows/ci.yml` | mount 车道钉 `@deepseek-ai/dsh@0.1.5-rc.2` + `NODE_OPTIONS=--max-old-space-size=4096`(C 类;**不用** `--legacy-peer-deps`,理由见 C 节);`ci-windows` 的 `Test` 改跑 `pnpm test:windows`(B 类);注释同步事实 |
|
||||
| CI | `package.json` | 新增 `scripts.test:windows`(`vitest run --maxWorkers=2`) |
|
||||
| CI | `vitest.config.ts` | 全局 `testTimeout: 15_000`(A 类;覆盖 git / powershell / pty 三类真起进程的 spec,逐文件打地鼠的做法在真实 Windows 上被证否) |
|
||||
| CI | `tests/agent-pty.spec.ts` | `AgentPtyRegistry` describe 显式 `{ timeout: 30_000 }`;`waitForTranscript` 默认预算 5000 → 15_000(A 类) |
|
||||
| CI | `tests/install-powershell.spec.ts` | `PowerShell installer entry points` describe 显式 `{ timeout: 30_000 }`(A 类) |
|
||||
| 文档 | `docs/plans/2026-09-10-dsh-0.1.5-rc.2-adaptation.md` | 本计划 |
|
||||
| 图标 | `src/client/file-icons.tsx` / `src/client/builtins/tab-icons.*` / `src/client/native/tab-adapter.tsx` | 文件图标改用宿主官方 `FileTypeIcon`;内置 tab 与原生芯片图标彩色——独立记录见 [2026-09-11-file-icons-and-tab-glyphs.md](./2026-09-11-file-icons-and-tab-glyphs.md) |
|
||||
| 文档 | `AGENTS.md` | §2 CI 基线戳与 mount 车道说明、§3 标题/基线段(rc.2 仍无契约变化,只推进钉版) |
|
||||
| 文档 | `README.md` / `README_EN.md` | 支持版本徽章与说明、新增 `### v0.19.1` 段 |
|
||||
| 文档 | `docs/external-plugin-guide.md` | 顶部基线行 → v0.19.1 / DSH 0.1.5-rc.1+(已在 rc.2 上验证) |
|
||||
| 文档 | `tests/native-surface.spec.ts`、`tests/builtins.spec.ts`、`tests/e2e/mount.e2e.ts`、`src/client/service.ts`、`src/client/native/index.ts` | 注释里的 `DSH 0.1.5-rc.1` → `0.1.5-rc.1+`(同一行为在 rc.2 未变) |
|
||||
|
||||
## 验证
|
||||
|
||||
- **未执行**的门禁与真机步骤都留空,由主代理实施后回填(本文件不预填未经执行的结论)。
|
||||
|
||||
### 回填(主代理实测)
|
||||
|
||||
- **门禁(rc.2 钉版,macOS arm64)**:`pnpm install` 成功且锁文件对齐 rc.2;`pnpm peers check` = `No peer dependency issues found`;`pnpm typecheck` / `pnpm lint` / `pnpm check:consumer-types` 全绿;`pnpm test` = **126 files / 1312 passed / 9 skipped**(与 rc.1 基线同数,无新增 skip)。
|
||||
- **真机挂载冒烟(rc.2)**:`DSH_CMD` 指向 `npx -y --package @deepseek-ai/dsh@0.1.5-rc.2 dsh`;见"落地"节的实测计数。
|
||||
- **真实 Windows 收敛**:`ci-windows` **连续两次全绿**(同一 commit `5451f5c`)——`pull_request` 自动触发的 run [34561189320](https://github.com/omdsh-dev/DSH-better-sidebar/actions/runs/34561189320) 与随后 `workflow_dispatch` 的 run [34561457067](https://github.com/omdsh-dev/DSH-better-sidebar/actions/runs/34561457067),两次都是 **126 files / 1318 passed / 3 skipped / 0 errors**(`Unhandled Errors` 与 `Worker exited unexpectedly` 出现次数均为 **0**;同环境的 2026-09-09/10 窗口里这两行出现过 2 次)。同一分支的 `plugin-mount` 两次都绿:`added 521 packages in 24s`(**0 条 ERESOLVE**,与历史 OOM run 的 3062 条对比)、`test:mount` **7 passed**、聚合双挂载通过。
|
||||
- **首次真实结果的证伪(已修正)**:本 PR 的第一版修复在同一 commit 上仍红两处,`.github/workflows/ci.yml` 的改动因此被真实证据改过一轮——见 C 节的「一次被真实结果否掉的中间方案」与 A 节第三个文件(`tests/git.spec.ts:85`,9607ms)。
|
||||
- **B 类的实测结论(已按"升级而非掩盖"走完两轮)**:`--maxWorkers=2` 下仍崩溃(run `34574847774`),故升级为 `--maxWorkers=1`(单 fork、零回收)。这**不等于证明了根因**(4 次崩溃的一致形态是「N-1 文件有结果 + teardown 期静默退出」,只能说明是 worker 回收竞态而非测试失败);收敛判据是单 worker 下连续两次 `ci-windows` 全绿——**已达成**:run [34575721734](https://github.com/omdsh-dev/DSH-better-sidebar/actions/runs/34575721734)(PR 触发)与 run [34576146970](https://github.com/omdsh-dev/DSH-better-sidebar/actions/runs/34576146970)(同 commit `4fe67e3` 的 `workflow_dispatch`)都是 **127 files / 0 errors**,`Unhandled Errors` 与 `Worker exited unexpectedly` 出现 0 次。
|
||||
- **本地 3080 真机**(DSH 0.1.5-rc.2 + 插件 0.19.1):部署与路由探针已完成(见"落地"节),GUI 人工验证由用户执行。
|
||||
|
||||
## 不做
|
||||
|
||||
- 不为 rc.2 写任何运行时/类型层兼容层(上游 delta 未触及本插件的面)。
|
||||
- 不把 peer 下限抬到 `^0.1.5-rc.2`(会无谓地把 rc.1 用户挡在上一版)。
|
||||
- 不为 B 类做无证据的猜测性改动:不加 vitest `retry`、不改默认 pool、不关掉真实 pty 测试。
|
||||
- 不改 DSH 源码(仓库硬约束 §1);不改 `release.yml`(0.19.1 无 prerelease 后缀 → 自动发 `latest`,符合 patch 语义)。
|
||||
|
||||
## 落地
|
||||
|
||||
- PR:[#626](https://github.com/omdsh-dev/DSH-better-sidebar/pull/626)(`feat/dsh-0.1.5-rc.2`)。
|
||||
- 本地真机挂载冒烟(rc.2,macOS arm64):`pnpm test:mount` **7 passed**(含 tab 体填充断言、指南 ≤4 条说明渲染断言、mermaid / README 预览与 sidechat 路由烟测);`pnpm test:mount:aggregate` 通过(聚合双挂载下插件自动退让,`/sidebar/api` 未知方法 404 与 `terminal.deps` 契约均符合)。
|
||||
- 本地 3080 实例部署(`~/.dsh/profiles/web` + `~/.dsh/dsh-web.sh`):profile 依赖从 `dsh-better-sidebar-0.19.0.tgz` 改指 0.19.1 tarball 并 `pnpm install`(`node_modules/dsh-better-sidebar/package.json` = **0.19.1**);`dsh-web.sh` 的 npx 钉版改 `@deepseek-ai/dsh@0.1.5-rc.2`,旧进程(PID 99521/99509/99507)定向 kill 后由 LaunchAgent `com.pm2.dshweb.resurrect` 自动拉起(新 PID 36025,npx 缓存 hash 因版本变更从 `1da1392061ab1944` 变为 `c40503fdf38a82ea`)。**已核**:新缓存里的 `@deepseek-ai/dsh` = **0.1.5-rc.2**,`/sidebar/api/shell.get` → `{"ok":true,"value":{"shell":"zsh","name":"zsh"}}`、`settings.get` 回默认设置、`terminal.deps` → `{"ok":true}`(三条插件路由在生产实例上工作);GUI 人工验证由用户执行,结论回填到本节的下一步。
|
||||
- PR:[#626](https://github.com/omdsh-dev/DSH-better-sidebar/pull/626)(`feat/dsh-0.1.5-rc.2`)。PR 三个 check(`ci` / `ci-windows` / `plugin-mount`)在 run 34561189320 与 run 34561457067 上均绿。
|
||||
- 待回填:3080 人工验证结果(用户执行)、发版情况(v0.19.1 的 tag / npm 发布由用户决定)。
|
||||
@@ -0,0 +1,92 @@
|
||||
# 文件图标与彩色 Tab 图标(v0.19.1,#531 + #594 合并后的重做)
|
||||
|
||||
日期:2026-09-11 分支:`feat/dsh-0.1.5-rc.2`(并入 PR [#626](https://github.com/omdsh-dev/DSH-better-sidebar/pull/626))
|
||||
|
||||
## 背景与决策
|
||||
|
||||
[#611](https://github.com/omdsh-dev/DSH-better-sidebar/issues/611) 希望文件树按类型显示图标(「仿照官方做法」),[#429](https://github.com/omdsh-dev/DSH-better-sidebar/pull/429)(@fenter)为此做了 563 条彩色图标数据,落成两个叠着的 PR:
|
||||
|
||||
- [#531](https://github.com/omdsh-dev/DSH-better-sidebar/pull/531)(`feat/file-icons-api`,base=main):文件树/文件 tab 按扩展名分图标 + 对外 `registerFileIcon` API;
|
||||
- [#594](https://github.com/omdsh-dev/DSH-better-sidebar/pull/594)(`feat/file-icon-theme`,base=#531 分支):在其上叠「内置单色 / 彩色品牌图标」可选主题,彩色数据做成懒加载 chunk(255 kB)+ 设置页开关。
|
||||
|
||||
两者在 2026-09-08 都绿,但都基于 v0.18.1、落后 main 55 个 commit,且与 DSH 0.1.5-rc.2 的一个新事实冲突:**rc.2 的 `ui-primitives` 已经导出官方文件类型图形** `FileTypeIcon` / `CodeFileIcon`(48 类全彩代码/配置图形 + 12 类类目色板图形 + `classifyFileType` 分类器)。也就是 #611 想要的东西上游自己做了。
|
||||
|
||||
用户决策(2026-09-11):**两个 PR 都合、并进 v0.19.1 一个 PR;文件图标改用宿主官方 `FileTypeIcon`;内置 tab 图标彩色 + 原生右侧栏芯片也加图标;删掉主题开关,彩色即唯一行为。**
|
||||
|
||||
## 上游事实(实测,非推断)
|
||||
|
||||
| 事实 | 证据 |
|
||||
|---|---|
|
||||
| 宿主导出官方图形 | `@deepseek-ai/dsh-client-ui-primitives@0.1.5-rc.2` 的 `lib/types/index.d.ts` 导出 `FileTypeIcon`、`classifyFileType`、`fileExtension`、`CodeFileIcon`、`isCodeFileType` 与 `CodeFileType` 类型;`FileTypeIcon` 声明 `size` 默认 28 并透传给内部 `FileGlyph`/`CodeFileIcon`(14px 树行可用) |
|
||||
| 全彩图形保留自己的调色板 | `CodeFileIcon.d.ts` 的文档原话「its identifying palette intact」;`kind:'folder'` 落宿主的 `IconFolderClose16`(单色,随 `currentColor`) |
|
||||
| 分类器不返回文件夹 | `classifyFileType` 只走 `classifyCodeFileType ?? NAME_TYPES ?? EXTENSION_TYPES ?? 'other'`,`'folder'` 只能由调用方以 `kind` 显式指定 |
|
||||
| 官方图形在 rc.1 就有 | `npm pack @deepseek-ai/dsh-client-ui-primitives@0.1.5-rc.1` 的 tarball 内含 `lib/FileTypeIcon.module.css` 与 `lib/types/{FileTypeIcon,CodeFileIcon,code-file-types}.d.ts`(rc.2 只是把 SVG 数据拆进 `code-file-icon-artwork.ts`) |
|
||||
| 原生 tab 定义没有 icon 字段 | `dsh-client-ui-sidebar-right` 的 `SidebarRightTabDefinition` 只有 `id/kind/patterns/priority/canOpen/title/guide`;`icon` 只在 `SidebarRightGuideEntry` 上 |
|
||||
| 但 title 槽就是芯片内容 | 宿主 `contract/slots.d.ts`:`'sidebar.right.pane.tab.title'` —— 「A tab's title as its chip (and a floating panel's header) shows it」 |
|
||||
|
||||
## 合并(3 处冲突,均为注释/列表并集)
|
||||
|
||||
| 文件 | 冲突 | 解法 |
|
||||
|---|---|---|
|
||||
| `src/client/service.ts` | ① import 区(main 保留 `extOf`,PR 侧已改成 `baseName, extOf` + 自己的 `isNarrowWidth`)② `SIDEBAR_FEATURES` 注释与列表(main 写「v0.19.0 删除了 floatWindows」,PR 侧在旧清单上加 `'fileIcons'`) | ① 取 `baseName, extOf`:`isNarrowWidth` 属于 PR 自己的「新建会话按视口折叠」逻辑,main 已把那套 seeding 重写掉,合并后该 import 是死代码(实测 grep 确认后删除)。② 并集:保留「已移除 floatWindows」措辞 + 追加 `'fileIcons'` |
|
||||
| `src/client/Sidebar.tsx` | `tabIconOf` 上方注释(两侧各改一句) | 并集:保留 main 的「The tab icon from the tab-type registry.」并补 PR 侧「带 path 的编辑器 tab 显示文件图标」的说明 |
|
||||
| `docs/external-plugin-guide.md` | `features` 清单注释 | 并集:main 的新措辞(只增不删,唯一例外 v0.19.0 删除 floatWindows)+ `'fileIcons'` |
|
||||
|
||||
#594 的其余冲突(`tests/plugin-shape.spec.ts`、`tests/prefs.spec.ts`)**取本线一侧**:main 已删除这两个文件里的 store-seeding 用例并重写了 schema 断言(`SIDEBAR_PREFS_DEFAULTS` 解构),PR 侧重新加回的块描述的是 main 不再存在的行为——把旧块带回来只会测到死代码。
|
||||
|
||||
## 重做(把 #594 的彩色实现换成宿主官方图形)
|
||||
|
||||
### 删除
|
||||
|
||||
- `src/client/chunks/file-icons.tsx`(563 条数据)、`src/client/file-icon-theme.ts`(chunk loader)、`tests/file-icon-theme.spec.tsx`;
|
||||
- `fileIconTheme` pref 三处(`prefs-shared.ts` / `src/config.ts` / `src/client/prefs.ts`)、设置页「文件图标」select 行、20 份词典里的 6 个 `settingsFileIconTheme*` key(共 126 行);
|
||||
- chunk 注册三处(`src/bundle-route.ts` 的 `CHUNK_NAMES`、`src/client/chunk-loader.ts` 的 `ChunkName`、`tsdown.config.ts` 的 `CHUNKS` 与 react-icons alias)与对应测试期望(`bundle-route.spec.ts` / `manifest-consistency.spec.ts`);
|
||||
- `src/client/index.tsx` 里驱动主题 loader 的 effect。
|
||||
|
||||
### 新增/改写
|
||||
|
||||
| 位置 | 内容 |
|
||||
|---|---|
|
||||
| `src/client/file-icons.tsx` | `builtinFileIcon(path, size)` → `<FileTypeIcon path size />`;`builtinFolderIcon` → `<FileTypeIcon kind="folder" size />`(宿主只有一个文件夹图形,开/合两态不另画);`fallbackFileIcon(size)` → `<FileTypeIcon kind="other" size />` |
|
||||
| `src/client/service.ts` 的 `fileIcon` 链 | 具体注册 → **catch-all 全局默认 → 宿主图形**(原先 built-in map 排在 catch-all 之前) |
|
||||
| `src/client/builtins/tab-icons.tsx` + `.module.css` | 六个内置类型 + diff 视图的彩色 glyph;颜色全部 `var(--dsw-alias-*)`(files=brand、changes=success、tasks=warn、sidechat/browser=business、terminal=label-primary,各带 fallback 链) |
|
||||
| `src/client/native/tab-adapter.tsx` 的 `NativeTabTitle` | 渲染 `[glyph][title]`:编辑器 tab 带 path 时用 `service.fileIcon(path, 14)`,其余用 `descriptor.icon(14)`;glyph `aria-hidden`;`NativeTitleInjected` 增加 `service` 与 `descriptorId`,`registerSlots` 的 title 注入同步 |
|
||||
| `src/client/sidebar.module.css` | 新增 `.chipIcon`(芯片 glyph 的排版,颜色继承) |
|
||||
| `tests/theme.spec.ts` | 豁免段整体删除,改为三条守护:图标模块零颜色字面量、`tab-icons.module.css` 里每条 `color` 都解析到 `var(--dsw-`、`src/client/chunks/*` 不含 6 位颜色字面量(图标数据不得再做回 chunk) |
|
||||
| `tests/file-icons.spec.tsx` | 内置断言从 codicon 身份改为宿主 `FileTypeIcon`;新增「path/size 透传」「文件夹用 `kind:'folder'`」;catch-all 用例改写为「注册了 catch-all 就接管全部未具体命中的行」 |
|
||||
| `tests/native-surface.spec.ts` | 新增 3 例芯片 glyph:descriptor glyph 在标题前、编辑器 tab 用文件图标而非类型图标、类型注销后只剩标题 |
|
||||
|
||||
## 语义后果(必须写进文档)
|
||||
|
||||
**宿主分类器覆盖任意路径**,因此「插件自己已经能画这个扩展名」不再是拦住 catch-all 的理由:注册 `exts: []` 的插件现在会接管所有未具体命中的文件行(原先会被内置 map 拦下)。旧版彩色主题那种「整表注册、靠内置 map 兜底」的形态不再成立——只想补几个类型的插件用 `exts`/`names`。已写进 [指南 §7](../external-plugin-guide.md) 与 [AGENTS.md](../../AGENTS.md) §5。
|
||||
|
||||
## 验证
|
||||
|
||||
**已执行(macOS arm64,DSH 0.1.5-rc.2 钉版)**
|
||||
|
||||
- `pnpm typecheck` / `pnpm lint` / `pnpm check:consumer-types` 全绿;
|
||||
- `pnpm test` = **127 files / 1343 passed / 9 skipped**(v0.19.1 基线 126/1312 + 合并与新增的 31 例,失败数 0;返工后 +1 例 glyph 身份断言);
|
||||
- `pnpm build`:`lib/client.js` **0.85 → 0.86 MB**(+10 kB),**没有** `lib/client-file-icons.js`;核心 bundle 内不含图标数据(只有 6 个被引用的 glyph 组件);
|
||||
- 真机挂载冒烟(`DSH_CMD` 指向 `@deepseek-ai/dsh@0.1.5-rc.2`):`pnpm test:mount` **7 passed**(含 guide 深扫、tab 体填充断言、mermaid/README 预览、sidechat 路由),`pnpm test:mount:aggregate` 通过。
|
||||
|
||||
### 真机反馈的两处返工(用户人工验证后)
|
||||
|
||||
| 反馈 | 结论与改动 |
|
||||
|---|---|
|
||||
| 「任务管理」图标不对——该页是 subagent + 后台 jobs,不是 todo | `VscTasklist`(清单)→ **`VscLayers`**(多层堆叠 = 后台在跑的工作)。`tests/builtins.spec.ts` 用 glyph 身份把这条读法钉住 |
|
||||
| 终端图标「非常怪」 | 根因是**观感权重**:`VscTerminal` 是整套里最宽的实心矩形,在 14px 下比邻座重。**只缩小一档**(`0.85 × size`,下限 10px)——形状与绘制方式保持图标集原样 |
|
||||
| 追加反馈:上一版给全部 tab glyph 加的 `currentColor` 发丝描边(`strokeWidth: 1` + `non-scaling-stroke`)「太粗」 | **整体回退**:不描边,glyph 只用包裹类的令牌着色。“描边”是我对「怪」的过度解读,反馈的本意只是终端尺寸。`tests/builtins.spec.ts` 现在同时钉住「终端尺寸更小」与「glyph 不带 style 覆盖」,防止描边再溜回来 |
|
||||
|
||||
### 真实 CI(已收敛)
|
||||
|
||||
`ci` / `plugin-mount` 每轮都绿;`ci-windows` 的 worker 回收竞态在 `--maxWorkers=1`(单 fork、零回收)下**连续三次全绿**:run [34575721734](https://github.com/omdsh-dev/DSH-better-sidebar/actions/runs/34575721734)、[34576146970](https://github.com/omdsh-dev/DSH-better-sidebar/actions/runs/34576146970)、[34576493202](https://github.com/omdsh-dev/DSH-better-sidebar/actions/runs/34576493202),均 **127 files / 0 errors**。两轮迭代(`--maxWorkers=2` 被真实结果证否)的取证见 [rc.2 计划 §B](./2026-09-10-dsh-0.1.5-rc.2-adaptation.md)。
|
||||
|
||||
**待验证**
|
||||
|
||||
- 3080 人工验证(返工后第二轮):树行与编辑器 tab 的彩色图标、指南六行彩色、原生右侧栏芯片图标、14px 下官方 48 类图形的可读性、以及这次返工后的任务/终端两个图标(用户此前明确选择用官方图形;若细节折损不可接受,回退方案是「树行用官方图形、编辑器 tab 保留插件 glyph 着色」,改动只在 `file-icons.tsx` 一处)。
|
||||
|
||||
## 不做
|
||||
|
||||
- 不改 DSH 源码;不把 #429 的 563 条数据搬回仓库(要走按需加载也应按懒加载 chunk 走,而不是常驻数据);
|
||||
- 不为彩色图标新增皮肤契约豁免(插件侧零硬编码颜色);
|
||||
- 不恢复 `floatWindows`;不动已合入的 rc.2 基线结论与 CI 修复。
|
||||
@@ -0,0 +1,185 @@
|
||||
# 01 · 需求分析:官方原生右列 vs 装了 apes-sidebar
|
||||
|
||||
> 本篇只回答一个问题:**一个在 DSH 里干活的人,从"官方原生"换到"装了这个包",他的需求和处境发生了什么变化?**
|
||||
> 对比的两端只有两个:**官方原生的右侧栏**,和**装了 apes-sidebar 之后的右侧栏**(那个包是一个整体,九个 tab 一起算)。
|
||||
> 机制与根因只在[附录 A](#附录-a--证据与实现索引)留索引,细节见 [02-系统架构](02-系统架构)、[06-故障排查](06-故障排查)。
|
||||
|
||||
## 一、先钉住"谁在用、他在哪儿干活"
|
||||
|
||||
在 DSH 里,**右侧栏是使用者一天里盯得最久的一块地方**。它不是"偶尔打开的设置页",而是"工作台 + 仪表盘"。
|
||||
|
||||
一个典型使用者(在 DSH 里做项目的人):
|
||||
|
||||
- 中间是对话主线——他和模型一起干活;
|
||||
- 右边是这块地方——看文件、看改动、看子代理、顺手追问、跑命令、看预览;
|
||||
- 他往往同时开着好几个会话、好几个项目。
|
||||
|
||||
这决定了本页所有需求的共同特征:**这些信息"看一眼就够,但要频繁看"。**
|
||||
频繁看的东西必须常驻在视野里——不能要求他"跳出去看一眼,再跳回来"。
|
||||
|
||||
## 二、官方原生的右列:一个「只读的文件浏览器 + 预览器」
|
||||
|
||||
本机宿主(`dsh@0.1.5-rc.2`)里,官方真正放进右列的东西只有三件:
|
||||
|
||||
| 官方包 | 它给用户什么 | 官方原话 |
|
||||
| --- | --- | --- |
|
||||
| `dsh-client-ui-sidebar-right` | **容器**:每会话一块可停靠面板、两种形态(贴靠 / 全屏)、展开按钮在会话 header 角落、最多左右两格、拖拽与浮窗、tab 菜单、内置撤销历史 | "为每个会话持有一个停靠面……拥有导航控制器(`ctx.sidebarRight`)、tab 类型注册表" |
|
||||
| `dsh-client-ui-sidebar-files` | **唯一的页**:「文件」——把会话工作区根目录画成一棵**只读**的树 | "把会话的工作区根目录画成一棵树,逐层经线上列出,并把文件打开到 Sidebar 里" |
|
||||
| `dsh-client-ui-sidebar-documentpreview` | **唯一的查看器**(kind = `text`):预览 Markdown / 代码 / 图片 / PDF / HTML,认不出的扩展名走**纯文本兜底** | "在右侧 Sidebar 预览**可读文件**,无需另开 tab 即可切换已注册的渲染器" |
|
||||
|
||||
一个容易被忽略、但对"用户体验"影响很大的细节:
|
||||
|
||||
> 引导页(罗盘)**只在注册了两个以上入口时才出现**——官方随包只有一个入口(Files),
|
||||
> 所以官方用户的右列**默认直接就是那棵文件树,罗盘从不露面**。
|
||||
> (原文:"恰好一个入口时直接打开对应页面(随包组合中为 Files)"。)
|
||||
|
||||
**一句话概括官方原生的取向:它假设你只是想「看」。**
|
||||
它给你树和预览,都是只读的;**它不假设你要在这块地方「干活」**。
|
||||
|
||||
于是,官方原生用户在右列里**做不到**这些事(每一条都是他能直接感知的缺失):
|
||||
|
||||
| 他想做的事 | 官方原生右列 | 他只能怎么办 |
|
||||
| --- | --- | --- |
|
||||
| 改一个文件 | ❌ 只有预览,没有可写的编辑器 | 跳出去,用外部编辑器打开 |
|
||||
| 知道刚才改了哪些东西 | ❌ 没有变更清单、没有 diff | 切终端敲 `git status` / `git diff`,或另开 diff 工具 |
|
||||
| 看某个子代理干了什么 | ❌ 右列里没有这个入口 | 翻会话树、翻聊天记录去认人 |
|
||||
| 追问一句又不想弄脏主线 | ❌ 没有旁路对话 | 硬着头皮在主对话里问,或另开一个窗口 |
|
||||
| 跑一条命令 | ❌ 没有终端 | 切到系统终端,还得先 `cd` 到对的目录 |
|
||||
| 看页面 / HTML 出来什么样 | ❌ 没有内嵌浏览器 | 切浏览器标签,或手动打开产物文件 |
|
||||
| 知道谁在替我干活、跑到哪了 | ❌ 完全没有 | 只能往上翻记录,翻到一半又忘了上下文 |
|
||||
| 知道这摊活是在哪儿干的 | ❌ 界面不写 | 靠脑子记,或敲 `pwd` 确认 |
|
||||
| 知道这个会话现在什么状态 | ❌ 界面不写 | 靠界面上的小细节猜 |
|
||||
|
||||
## 三、装了 apes-sidebar 之后:同一块地方,从「看」变成「干 + 心里有数」
|
||||
|
||||
apes-sidebar 把这九件事一次性放进右列。它们**是一个整体需求**:把右列从"文件浏览器"变成"工作台 + 仪表盘"。
|
||||
|
||||
| tab | 他真正想要的是什么 | 官方原生时他被迫怎么做 | 装了之后 | 需求被改变成 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 文件(编辑器) | 看**和改**代码,而不丢掉当前上下文 | ⌘Tab 跳到外部编辑器;回来要重新找位置 | 右列里直接看、直接改 | 「看文件不该让我离开现场」 |
|
||||
| 文件变动(git) | 刚才到底改了哪些东西 | 切终端敲命令,或另开 diff 工具 | 变更清单 + 单文件 diff,就在旁边 | 「改了什么必须当场看得见」——**这是信任的前提** |
|
||||
| 子代理 | 进某个子代理,看它到底干了什么 | 翻会话树、翻记录去认人 | 右列里直接进入该子代理 | 「子代理是可进入的,不是只能干等的」 |
|
||||
| 侧边对话 | 追问一句,但不想污染主线 | 硬着头皮在主对话里问,或另开窗口 | 旁路开一个小对话,两边互不干扰 | 「提问不该有副作用」——**性质变了** |
|
||||
| 终端 | 跑一条命令 | 切到系统终端,常常还得先 `cd` | 右列里跑,工作目录天然就是对的 | 「命令的上下文应该跟着会话走」 |
|
||||
| 浏览器 / 预览 | 看这个页面出来是什么样 | 切浏览器标签,或手动打开产物 | 右列内嵌,当场看 | 「产出物要能当场验收」 |
|
||||
| **智能体** | 谁在替我干活、跑到哪了、哪个停了 | **基本没办法**——只能翻记录猜 | 主代理在最上,直系子代理一人一行,带运行/结束状态 | 「派了活之后,我要的是**扫一眼**,不是盯着守」(见 §四·洞察 4) |
|
||||
| **应用** | 这摊活到底是在哪儿干的 | **基本没办法**——靠脑子记或敲 `pwd` | 打开就看到当前会话锚定的工作区 | 「界面应该告诉我我在哪,而不是要我自己记住」 |
|
||||
| **画像** | 这个会话现在是什么状态 | 靠界面小细节猜(在跑?停了?哪一次?) | 标题 / 运行或空闲 / 工作区,写清楚 | 「离席回来,不用重建心智模型」 |
|
||||
|
||||
连**顺序**都在讲同一件事:`10…50` 是操作面(看/改/查/问/跑/验),`60…70` 是态势面(谁在干、在哪儿干、什么状态)。
|
||||
先让"活能在这儿干完",再让"我心里有数"——这是这个包对用户的两级承诺。
|
||||
|
||||
## 四、深度分析:需求到底发生了什么变化
|
||||
|
||||
### 洞察 0 · 官方原生假设"你只是看",而真实使用是"在这儿干活"
|
||||
|
||||
这是整个需求空间的起点。官方原生不是做得不好,而是**取向不同**:只读预览是"查看器"的思路,
|
||||
而使用者把它当"工位"用——他会在这里改一个文件、跑一条命令、瞄一眼改动。
|
||||
**当一块地方被当成工位,只读就会立刻变成绕路。** 这就是这个包存在的全部理由。
|
||||
|
||||
### 洞察 1 · 从「只读」到「可写」:补的不是功能,是主权的归还
|
||||
|
||||
官方只给预览,意味着**每一个"改一下"都要离开这块地方**。装上之后,改、查改动、跑、验四件事都在原地完成。
|
||||
用户省下的不是几次点击,而是**每次离开都要付出的"上下文重建"成本**——回来要重新找到刚才的位置、重新想起来自己在干嘛。
|
||||
需求因此从「我要能看」变成「我要在这儿把活干完」。
|
||||
|
||||
### 洞察 2 · 「留在原地」会变成默认预期(棘轮效应)
|
||||
|
||||
一旦右列里能改文件、能跑命令、能看预览,他对"跳出去一下"的容忍度会**永久下降**。
|
||||
他会开始说「为什么这个还要我切窗口?」——而这句话在他用官方原生时是说不出口的(因为原生本来就没有可切的东西)。
|
||||
**这类需求只前进、不后退**,所以值得一次做对:它一旦被填好,就变成基础设施。
|
||||
|
||||
### 洞察 3 · 消除副作用之后,他会开始"问"(侧边对话)
|
||||
|
||||
侧边对话的价值不在"多一个对话框",而在**把提问的代价从"可能污染主线"降到"零"**。
|
||||
需求的性质变了:以前他的问题是「要不要问?」(一个决策),现在变成「顺手问一下」(一个动作)。
|
||||
**当一件事的代价趋近于零,用户的行为频率会跳一个数量级**——这是需求分析里最值得警惕、也最值得利用的一条规律。
|
||||
|
||||
### 洞察 4 · 看得见 → 敢放手(智能体 tab 的核心价值)
|
||||
|
||||
这是整个包里最值钱的一条。官方原生在这一点上是**彻底空白**:
|
||||
|
||||
- 官方没有子代理视图,也没有任何"队伍"概念 → 他派了活之后**只能等**,或者不停往上翻记录确认;
|
||||
- 这个包的「子代理」让他能**进入**某一个;「智能体」让他**不必进去就看得到全局**——主代理 + 直系子代理,谁在跑、谁停了。
|
||||
|
||||
用户真正的痛点不是"我进不去子代理",而是**"我不敢派太多活"**:
|
||||
因为看不见,他只能用"盯着"来对冲风险,而盯着就失去了并行的意义。
|
||||
|
||||
> **看得见 → 敢放手 → 真的并行 → 才配得上"多智能体"。**
|
||||
> 需求从「我要能看见」升级为「我要能放心」。
|
||||
|
||||
### 洞察 5 · 心智模型从脑子里搬到界面上(应用 / 画像)
|
||||
|
||||
多项目、多会话的人切换时要重建一次"我这是在哪儿"——标题差不多的会话最容易看错,
|
||||
而看错的代价是**写错文件**。官方原生在右列里一个字都不写这件事。
|
||||
「应用」「画像」做的就是**把这份"我记得"外化成"界面写着"**。
|
||||
需求的性质:从「我要记性好」变成「界面不该让我记」。
|
||||
|
||||
### 洞察 6 · "引导页"第一次有了意义:从单一工具到工作台
|
||||
|
||||
官方原生只有一个入口(Files),所以它**默认直接开文件树,罗盘界面根本不会出现**。
|
||||
入口一多,罗盘才登场——用户第一次能"一屏看见这块地方能干什么"。
|
||||
这是**用户层面的分水岭**:官方是"一个工具",装了之后是"一个工作台的目录页"。
|
||||
(对照 §二那个细节:入口数决定了这块地方的形态。)
|
||||
|
||||
### 洞察 7 · 价值随"并行度"放大;而成本几乎为零
|
||||
|
||||
- 单会话、单项目的用户:「应用」「画像」几乎用不上,「智能体」也只有一个主代理;
|
||||
- 一旦同时开几个会话、派几个子代理、换几个项目:这三个 tab 的边际价值**陡增**。
|
||||
|
||||
所以这套东西**不是人人需要,但接到的人离不开**。
|
||||
同时它**不新增数据源、不新增进程、不引入新的失败面**——只是把宿主已经投影出来的信息换个视角呈现
|
||||
(逐条列在 [03-功能说明](03-功能说明))。对使用者意味着:**低风险地换到确定性**,这是它最该守住的边界。
|
||||
|
||||
## 五、由以上分析推出的需求(U1–U6)
|
||||
|
||||
| # | 需求(用户口吻) | 怎么算他满意了 | 现状 |
|
||||
| --- | --- | --- | --- |
|
||||
| U1 | 「我要在这儿把活干完,别老让我跳出去」 | 改文件、查改动、跑命令、看预览、顺手追问,都在右列完成 | ✅ 操作面六个 tab |
|
||||
| U2 | 「我要看得见谁在替我干活」 | 右列点一下就有队伍表;人数/状态一眼可辨;没有子代理时明确说"没有",不是空白 | ✅ 智能体 tab |
|
||||
| U3 | 「我要知道我是在哪儿干的活、这会话什么状态」 | 打开就看到工作区与运行/空闲,不用敲 `pwd`;拿不到应用信息时如实说明 | ◑ 工作区/状态 ✅ · 应用层等宿主 |
|
||||
| U4 | 「我点开 HTML 就该看得见」 | 自包含 HTML 完整显示,而不是一行 `forbidden`;**但不许为此拆掉沙箱** | ✅(多文件需我本人点「临时解锁」) |
|
||||
| U5 | 「我的界面不许自己乱套」 | 右列只有一套侧边栏、一个开合入口;弹窗完整盖在面板之上、按钮点得到 | ✅ |
|
||||
| U6 | 「我敢升级,也退得回去」 | 有"宿主版本 ↔ 插件版本"对照表;每步升级都有分支/tag 可回滚 | ✅ 两条版本线;新宿主线待实机验证 |
|
||||
|
||||
## 六、我们不做的事(省得期待错位)
|
||||
|
||||
- **不重造官方已有的东西。** 官方给了容器与文件树,我们在它里面长东西,不另起一套侧边栏。
|
||||
- **不为"看得见"放宽安全边界。** 需要放宽必须由用户**本人显式点**「临时解锁」。
|
||||
- **不做团队/房间/多智能体协作编排。** 那是宿主的另一类职责。
|
||||
- **不承诺"装上立刻见效"。** 单会话用户可能几乎感觉不到差异(见洞察 7)。
|
||||
|
||||
## 七、已知的「暂时满足不了」(诚实交代)
|
||||
|
||||
| 他想要的 | 现状 | 为什么(一句话) | 何时会有 |
|
||||
| --- | --- | --- | --- |
|
||||
| 知道这摊活属于哪个**应用**、有哪些入口 | 「应用」只显示工作区 | 宿主目前没有向插件提供应用清单的通道 | 宿主提供 feed 后即可接上 |
|
||||
| 一次摊开**完整**子代理树(子代理的子代理) | 只显示**直系** | 宿主投影只给一层 | 同上 |
|
||||
| 沙箱状态下多文件 HTML 的相对资源 | 需点「临时解锁」 | 不透明源沙箱下子请求拿不到宿主信任头(安全边界的必然结果) | 不打算改——这是刻意保留的边界 |
|
||||
|
||||
> 这三条说明同一件事:**需求跑在宿主能力前面**。写出来不是免责,是让读的人知道
|
||||
> "不能用"和"还没做"是两回事。
|
||||
|
||||
## 附录 A · 证据与实现索引
|
||||
|
||||
开发与排障看这里;其余读者可跳过。
|
||||
|
||||
**"官方原生到底有什么"的证据**(本机宿主 `dsh@0.1.5-rc.2`,官方包自带文档):
|
||||
|
||||
- 容器:`@deepseek-ai/dsh-client-ui-sidebar-right/README.zh.md` —— 停靠面、两种形态、扩展席位、引导页。
|
||||
- 唯一的页:`@deepseek-ai/dsh-client-ui-sidebar-files/README.zh.md` —— "文件树 tab 类型……把文件打开到 Sidebar 里"。
|
||||
- 唯一的查看器:`@deepseek-ai/dsh-client-ui-sidebar-documentpreview/README.zh.md` —— "Sidebar tab 的 kind 为 `text`",未知扩展名纯文本兜底。
|
||||
- "官方只有一个入口 ⇒ 罗盘不出现":同上 sidebar-right 文档「引导页」一节。
|
||||
|
||||
**本包一侧的对应实现**(`editor`/`git`/`subagent`/`sidechat`/`terminal`/`browser` 为随包操作面;
|
||||
`agents`/`apps`/`profile` 是本 fork 自增的态势面——对内维护用,对外它是一个包):
|
||||
|
||||
| 需求 | 机制(去哪看) | 关键证据 |
|
||||
| --- | --- | --- |
|
||||
| U5-a 右列只有一套侧边栏 | 每个 tab 注册成原生 tab 类型(`kind = descriptor.id`)渲染进官方右列;自绘面板已移除 | [02-系统架构](02-系统架构)、[05-运维手册](05-运维手册) |
|
||||
| U5-b 弹窗不被面板切 | 宿主容器上的层叠上下文(`isolation`)导致;挂载点改挂 `#root` 而不再挂 `document.body` | [06-故障排查](06-故障排查) |
|
||||
| U4 HTML 预览 | 主框架 `fetch` → `srcDoc`,**`sandbox` 属性未动**;失败回退路由 URL | [06-故障排查](06-故障排查) |
|
||||
| 与官方 `text` 预览的关系 | 本包的编辑器类型以更高优先级接管 `dsh-resource://file/**`,卸载即恢复官方预览 | `docs/external-plugin-guide.md` §「内置类型接管」 |
|
||||
| U6 版本线 | 宿主版本不匹配时宿主**静默禁用**整行(不报错、无日志);`^` 跨 minor 判 false | [04-部署指南](04-部署指南)、[06-故障排查](06-故障排查) |
|
||||
|
||||
<!-- 本页只讲"为什么需要"。实现细节、提交号、测试名一律留在 02–07 与附录索引里。 -->
|
||||
@@ -0,0 +1,154 @@
|
||||
# 02 · 系统架构
|
||||
|
||||
> 本篇说明插件的**分层、接口面、层叠契约与关键不变量**。
|
||||
> 与源码一一对应,可直接当作改动前的「雷区地图」使用。
|
||||
|
||||
## 一、总体分层
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ DSH 宿主(Desktop Electron 壳 / Web profile) │
|
||||
│ ├─ 原生右侧栏 ctx.sidebarRight / sidebarRightTabs(tab 类型注册表) │
|
||||
│ ├─ 会话与任务 ctx.sessions.list(会话摘要、子代理、投影)、ctx.tools │
|
||||
│ ├─ webServer 路由挂载点(插件用 /sidebar/* 前缀注册) │
|
||||
│ └─ 桌面壳层 webserver 外层的请求围栏 + #root 的 content-viewport 标记 │
|
||||
└───────────────┬──────────────────────────────────────────────────────────┘
|
||||
│ ① 宿主 → 插件:cordis 服务注入(inject)
|
||||
│ ② 插件 → 宿主:原生 tab / 资源打开 / 路由
|
||||
┌───────────────┴──────────────────────────────────────────────────────────┐
|
||||
│ 插件 HOST 半(Node,lib/index.js) │
|
||||
│ · src/index.ts 路由注册(/sidebar/*)、信任围栏、会话/文件数据、HTML 交付 │
|
||||
│ · src/trust-fence.ts 请求信任围栏(Host 头 / 令牌) │
|
||||
│ · src/invariant.ts 依赖零的断言与 semver 工具(host/client 共用) │
|
||||
└───────────────┬──────────────────────────────────────────────────────────┘
|
||||
│ 浏览器侧模块(__ModuleLoader__ bundle,非 CJS)
|
||||
┌───────────────┴──────────────────────────────────────────────────────────┐
|
||||
│ 插件 CLIENT 半(浏览器,lib/client.js + 懒加载 chunk) │
|
||||
│ · src/client/index.tsx 应用入口:装配 store/service/原生适配/信息层 │
|
||||
│ · src/client/native/ 原生右侧栏适配(tab 记录、tab 体、tab 芯片标题) │
|
||||
│ · src/client/builtins/ 内置面板:文件 / 文件变动 / 子代理 / 侧边对话 / │
|
||||
│ 终端 / 浏览器 + 文件预览器 │
|
||||
│ · src/client/sidebar/ 自绘底部工作台与开合控件(原生侧边栏之外的部分) │
|
||||
│ · src/client/apes/ 信息层三 tab(本 fork 的增量) │
|
||||
│ · src/client/service.ts ctx.betterSidebar 服务(供其他插件注册) │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 二、构建产物
|
||||
|
||||
| 产物 | 角色 | 备注 |
|
||||
| --- | --- | --- |
|
||||
| `lib/index.js` | host 半(ESM,Node) | 路由、围栏、数据面 |
|
||||
| `lib/invariant.js` | 不变量面(零依赖) | host/client 都要 import,**不得**引入 Node 内置或宿主包 |
|
||||
| `lib/client.js` | client 主 bundle | 浏览器 `window.__ModuleLoader__.load({...})` 形态,**不是** CJS/ESM,Node 里 `require` 会因 `window is not defined` 抛错 |
|
||||
| `lib/client-registry.js` | 懒加载 chunk | 高开销能力(图标/预览库)按需加载 |
|
||||
| `lib/client-editor.js` | 懒加载 chunk | 编辑器(CodeMirror 系) |
|
||||
| `lib/client-locale.js` | 懒加载 chunk | 语言包 |
|
||||
| `lib/client-mermaid.js` | 懒加载 chunk | Mermaid 渲染 |
|
||||
| `lib/client-terminal.js` | 懒加载 chunk | 终端 |
|
||||
| `lib/types/**/*.d.ts` | 类型声明 | 供消费方类型检查 |
|
||||
|
||||
> 懒加载动机:主 bundle 不应静态 import 重型预览库;chunk 通过宿主 `ctx.modules` 解析外部依赖
|
||||
> (入口在 `src/client/index.tsx` 里先 `setChunkModuleSystem(ctx.modules)`)。
|
||||
|
||||
## 三、三层接口面
|
||||
|
||||
### 3.1 宿主 → 插件(cordis 注入)
|
||||
|
||||
```ts
|
||||
// src/index.ts
|
||||
export const name = 'dsh-apes-sidebar'
|
||||
export const inject = ['webServer', 'sessions', 'webRuntime', 'tools']
|
||||
export function apply(ctx: Context, config?: SidebarConfig): void
|
||||
```
|
||||
|
||||
客户端侧读取的宿主面(结构化镜像,运行时由宿主提供):
|
||||
`ctx.sessions.list`(`getSnapshot/subscribe`)、`ctx.sidebarRight`(原生右列与 `openResource`)、
|
||||
`ctx.slots`、`ctx.settings`(挂载行 `config`)、`ctx.modules`(懒加载 chunk 的模块系统)。
|
||||
|
||||
### 3.2 插件 → 宿主
|
||||
|
||||
| 方向 | 手段 |
|
||||
| --- | --- |
|
||||
| 注册 tab 类型与 tab 体 | `src/client/native/registerNativeSurface()` → 原生右侧栏注册表 |
|
||||
| 打开会话内文件 | `ctx.sidebarRight.openResource('dsh-resource://file/…')` |
|
||||
| 暴露 HTTP 能力 | `/sidebar/` 前缀路由:`api` · `file` · `html` · `upload` · `ws/terminal` · `ws/agent-opens` · `ws/agent-terminals` |
|
||||
|
||||
### 3.3 插件 → 其他插件(对外服务)
|
||||
|
||||
`ctx.betterSidebar`(`src/client/service.ts`)对外开放 `registerTab` / `registerFileViewer` 等,
|
||||
并按「能力清单」做前向兼容门控(能力只增不减)。对外契约见仓库内
|
||||
[docs/external-plugin-guide.md](../blob/main/docs/external-plugin-guide.md)。
|
||||
服务版本常量 `SIDEBAR_SERVICE_VERSION` 必须与 `package.json` / `dsh.plugin.json` 的版本三方一致
|
||||
(有单元测试断言这一对)。
|
||||
|
||||
## 四、面板宿主与层叠契约(重点雷区)
|
||||
|
||||
插件的所有面板渲染在一个 **body 级(或壳层 viewport 级)的宿主层**里:
|
||||
|
||||
```css
|
||||
[data-dsh-panel-host] { position: fixed; inset: 0; z-index: 25; pointer-events: none }
|
||||
```
|
||||
|
||||
`z-index: 25` 不是随手取的,它要同时满足:**在 app 内容与壳层 overlay 之上、在宿主交给插件用的
|
||||
overlay 槽之下**。完整取义:
|
||||
|
||||
| 层级 | 对象 |
|
||||
| --- | --- |
|
||||
| `z-index: auto` | app 的流内内容、`.dshDesktopFrame` |
|
||||
| **25** | **本插件的面板宿主** |
|
||||
| 30 | 宿主的动态插件面板(ui-cordis) |
|
||||
| 100+ | app 的 overlay 栈(Toast / onBoarding 等) |
|
||||
| 1000 | `.dshDesktopOverlay[data-shell-overlay]`(宿主给 `shell.overlay` 槽的容器,第三方弹窗在此) |
|
||||
|
||||
**陷阱(已修,`4422001`)**:DSH 桌面壳的有框模式会给上游 `#root` 打标
|
||||
`data-dsh-desktop-content-viewport` 并施加 `isolation: isolate`,使 `#root` 成为一个**层叠上下文**。
|
||||
宿主自己的 overlay 层就在这个上下文内;若插件宿主挂在 `document.body`(上下文之外),
|
||||
它是「带正 z-index 的定位元素」,会画在整个上下文**之上**——把宿主 overlay 里的弹窗(如社区插件市场)盖住。
|
||||
|
||||
```ts
|
||||
// src/client/desktop-env.ts —— 修法:挂进壳层的 content viewport
|
||||
export function panelHostParent(doc: Document = document): HTMLElement {
|
||||
return doc.querySelector<HTMLElement>('#root[data-dsh-desktop-content-viewport]') ?? doc.body
|
||||
}
|
||||
```
|
||||
|
||||
进入该上下文后,25 恢复原意(在 app frame 之上、在 overlay 层 1000 之下);无该标记的部署
|
||||
(纯网页 / 未打标的壳)仍挂 `document.body`,行为不变。重新锚定守卫同时监听
|
||||
`document.body` 的 `data-dsh-desktop-mode`(壳层打标与它是同一个 effect),因此壳层在插件挂载**之后**
|
||||
才框住 `#root` 的情况也能被搬到正确位置。
|
||||
|
||||
## 五、数据流示例
|
||||
|
||||
**会话 → 信息层**
|
||||
|
||||
```
|
||||
ctx.sessions.list.getSnapshot() → { byId, projectionsBySession? }
|
||||
useSyncExternalStore(subscribe) (uSES 安全订阅)
|
||||
├─ apes:agents → rootAncestor(byId, scope.sessionId) 找主代理
|
||||
│ → projectionsBySession[rootId].values.subagentCatalog 取直系子代理
|
||||
│ (无投影时回退 byId 里的 durable 子会话)
|
||||
└─ apes:profile / apes:apps → byId[scope.sessionId](标题 / cwd / running)
|
||||
```
|
||||
|
||||
**文件 → 预览**(HTML 为例)
|
||||
|
||||
```
|
||||
编辑器/diff 面板 → /sidebar/html/<session>/<path> (路由负责路径围栏与体积上限)
|
||||
主框架 fetch 取回文档文本 → 注入 <base href="<该文件的路由目录>">
|
||||
→ iframe srcDoc + sandbox(不透明源)
|
||||
取失败时回退为 iframe src=路由 URL,把路由的答复原样透出
|
||||
```
|
||||
|
||||
## 六、关键不变量(改动前必读)
|
||||
|
||||
1. **host / client 拆分是结构性的**:client bundle 在浏览器运行,不得 import Node 内置;
|
||||
`src/invariant.ts` 两侧共用,必须保持零依赖。
|
||||
2. **`dsh-better-sidebar` 是 peer,永不打包**:一旦被内联,client 会带上第二份侧边栏注册表,
|
||||
tab 注册进死实例,界面上永不出现。
|
||||
3. **tab id 前缀归本 fork**:信息层用 `apes:*`;不得占用上游/宿主内部 id。
|
||||
4. **HTML 预览的沙箱令牌不得放宽**(不加 `allow-same-origin` 等);交付方式只能是「主框架取文档 + `srcDoc`」。
|
||||
5. **颜色只用宿主设计令牌**(`--dsw-alias-*`),不写硬编码色值;几何量(字号/间距/圆角)无公开令牌,内联即可。
|
||||
6. **所有面向用户的字符串走 i18n**,`zh` / `en` 必须同时存在。
|
||||
7. **变更后必须重新同步到 profile**:构建产物非字节确定性,且 profile 的 `file:` 依赖是复制语义,
|
||||
不能假定旧副本可用(见 [04-部署指南](04-部署指南))。
|
||||
@@ -0,0 +1,109 @@
|
||||
# 03 · 功能说明
|
||||
|
||||
> 本篇是**功能清单与行为契约**:有哪些面板、数据从哪来、文案与配色怎么走、安全边界在哪、能配什么。
|
||||
|
||||
## 一、面板与标签页总表
|
||||
|
||||
「来源」列:**上游** = 来自 `dsh-better-sidebar`;**宿主** = DSH 自身提供并占用该槽位;**本 fork** = 本项目的增量。
|
||||
|
||||
| id | 名称 | order | 来源 | 数据 / 行为 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `editor` | 文件 | 10 | 上游 | 文件树 + 编辑器(CodeMirror 系,懒加载 `client-editor`) |
|
||||
| `git` | 文件变动 | 20 | 上游 | 工作区变更、单个 diff 视图 |
|
||||
| `subagent` | 子代理 | 30 | 上游 | 会话的子代理视图 |
|
||||
| `sidechat` | 侧边对话 | 35 | 上游 | 会话内旁路对话 |
|
||||
| `terminal` | 终端 | 40 | 上游 | PTY 终端(懒加载 `client-terminal`;v0.24.1 线起由宿主内置终端承担) |
|
||||
| `browser` | 浏览器 | 50 | 上游 | 内嵌浏览器/HTML 预览宿主 |
|
||||
| `diff` | (内部) | -1 | 上游 | 非用户可见,供内部打开 diff |
|
||||
| **`apes:agents`** | **智能体** | **60** | **本 fork** | 主代理 + 直系子代理拓扑 |
|
||||
| **`apes:apps`** | **应用** | **65** | **本 fork** | 当前会话所属工作区(宿主暂无「应用」feed) |
|
||||
| **`apes:profile`** | **画像** | **70** | **本 fork** | 当前会话摘要(标题 / cwd / 运行态) |
|
||||
|
||||
> order 10–50 是上游/宿主既定带宽,apos 信息层取 60/65/70 留出余量,避免与原生面板抢占位置。
|
||||
|
||||
## 二、信息层三 tab(本 fork 的增量)
|
||||
|
||||
三个 tab 全部是**单实例**(`single: true`),且都按 DSH 标准 `TabDescriptor` 注册,
|
||||
因此自动获得原生侧边栏的全部待遇:进入原生 guide、tab 芯片带图标、可开合与分栏。
|
||||
|
||||
| tab | 展示内容 | 数据来源 | 空态 |
|
||||
| --- | --- | --- | --- |
|
||||
| `apes:agents` | 根(主代理)卡片 + 其直系子代理卡片;每行显示标签、`one-shot`/`continuable`、运行态圆点 | ① `ctx.sessions.list` 的 `byId`(durable 会话摘要);② 宿主的会话投影 `projectionsBySession[sessionId].values.subagentCatalog`(直系子代理清单) | 「当前会话没有子代理」提示 |
|
||||
| `apes:apps` | 当前会话的工作区(cwd) | `byId[scope.sessionId].cwd`,回退 `scope.cwd` | 明确写出「宿主今日无应用 feed」 |
|
||||
| `apes:profile` | 会话标题、运行/空闲、cwd | `byId[scope.sessionId]` | 「无 cwd」提示 |
|
||||
|
||||
**实现要点**:两个来源被归一化成同一种行形状 `{ id, label, mode }`,**活动态统一从 `byId` 读**,
|
||||
于是「投影已就绪」与「仅有 durable 摘要」两种情况下渲染一致;这也是上游 0.1.6→0.1.7 会话 feed
|
||||
迁移(`subagentsByParent` → `projectionsBySession`、`list.current` 删除)后的落地方式。
|
||||
|
||||
## 三、文件预览器
|
||||
|
||||
| id | 适用 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `image` | 图片 | 走媒体路由,受 `mediaLimit` 限制 |
|
||||
| `pdf` | PDF | 内嵌查看 |
|
||||
| `markdown` | Markdown | 渲染(Mermaid 走懒加载 chunk,可手动刷新) |
|
||||
| `html` | HTML | **不透明源沙箱 iframe**,见下节 |
|
||||
| `code` | 代码 | 高亮查看(编辑器懒加载) |
|
||||
| `binary-download` | 二进制 | 只提供下载 |
|
||||
|
||||
### 3.1 HTML 预览的交付方式(重要)
|
||||
|
||||
```
|
||||
路由 /sidebar/html/<session>/<path> ← 路径围栏 + 体积上限(readLimit)
|
||||
│
|
||||
├─ 主框架 fetch(唯一同时被宿主围栏信任、又能带上 Electron 头的角色)
|
||||
│
|
||||
├─ 注入 <base href="<该文件的路由目录>"> ← 让 ./style.css 等相对引用解析回本文件所在目录
|
||||
│
|
||||
└─ iframe srcDoc=<文档> + sandbox=HTML_IFRAME_SANDBOX
|
||||
```
|
||||
|
||||
| 状态 | 自包含 HTML | 多文件 HTML(外链 CSS/JS/图片) |
|
||||
| --- | --- | --- |
|
||||
| 沙箱态(默认) | ✅ 完整可用 | 正文可用,**相对资源不可用**(iframe 源为 `null`,子请求同样被宿主围栏拒绝) |
|
||||
| 临时解锁(无 sandbox 属性) | ✅ | ✅ **相对资源可用**(iframe 继承 GUI 源,成为普通同源请求) |
|
||||
|
||||
设计取舍:**沙箱令牌一个字符都不放宽**。多文件 HTML 想要完整效果需用户显式点「临时解锁」——
|
||||
这是把「放宽边界」的决定权留给用户的显式动作,而不是插件默认降级。
|
||||
|
||||
## 四、安全边界(三层)
|
||||
|
||||
| 层 | 机制 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 预览内容 | `sandbox="allow-scripts allow-popups allow-downloads allow-modals"` | **不含 `allow-same-origin`**:预览文档运行在不透明源里。宿主路由另有 CSP `sandbox` 兜底 |
|
||||
| 插件路由 | 请求信任围栏(`src/trust-fence.ts`) | 校验 Host 头/令牌,拒绝跨站或非授权来源对 `/sidebar/*` 的访问 |
|
||||
| 桌面壳 | Electron 注入的 `x-dsh-desktop-renderer` 头 | 扩展模式下所有路由的前置围栏;只发给主框架与同源子框架。这正是「沙箱 iframe 发不出请求」的根源 |
|
||||
|
||||
## 五、i18n 与主题
|
||||
|
||||
- **语言包**:`src/client/locales.ts` + 19 个 `src/client/locales-*.ts` 覆盖文件;默认 `zh`,缺省回退 `en`。
|
||||
- **本 fork 文案**:集中在 `src/client/apes/locales.ts`,每个 key 同时提供 `zh` / `en`。
|
||||
- **硬性约定**:界面里**不得出现裸字符串**,一律 `t('key')`。
|
||||
- **配色**:只用宿主设计令牌 `--dsw-alias-*`(bg / border / label / state / interactive 系列),
|
||||
写法为 `var(--dsw-alias-xxx, <字面回退>)`,保证在非 DSH 环境(单元测试)也能渲染。
|
||||
**无公开的 `--ds-*` 几何令牌**,字号/间距/圆角内联即可。
|
||||
|
||||
## 六、配置面(挂载行 `config`)
|
||||
|
||||
配置写在插件挂载行上(profile 的 `cordis.patch.yml` 或 bundle patch 的 insert 行),设置页写的也是它:
|
||||
|
||||
| 字段 | 默认 | 含义 |
|
||||
| --- | --- | --- |
|
||||
| `readLimit` | `512 KiB` | 单文件读取上限(超出截断并标记 `truncated`) |
|
||||
| `mediaLimit` | `20 MiB` | 媒体(图片等)读取上限 |
|
||||
| `uploadLimit` | `128 MiB` | 上传上限 |
|
||||
| `listLimit` | `1000` | 目录列举条目上限 |
|
||||
| `terminalsPerSession` | `3` | 每会话终端数上限 |
|
||||
| `shell` / `shellArgs` | 自动解析 | 终端 shell 与其启动参数(`shellArgs` 非空时替换默认 `-l`) |
|
||||
|
||||
例:在挂载行里指定 shell
|
||||
|
||||
```yaml
|
||||
- insert:
|
||||
- id: better-sidebar
|
||||
name: 'dsh-apes-sidebar'
|
||||
config:
|
||||
shell: /bin/zsh
|
||||
shellArgs: ['--noprofile', '--no-rc']
|
||||
```
|
||||
@@ -0,0 +1,163 @@
|
||||
# 04 · 部署指南
|
||||
|
||||
> 目标:在一台机器上把本插件挂进 DSH,并确认它**真的在跑**。
|
||||
> 关键前提:**先确认宿主版本,再选版本线**——选错线的唯一症状是「静默失效」。
|
||||
|
||||
## 一、前置条件
|
||||
|
||||
| 项 | 要求 |
|
||||
| --- | --- |
|
||||
| Node | ≥ 20(`engines.node`);本机验证用 22.x |
|
||||
| pnpm | 11.x(`pnpm install` 会自动触发 `prepare: tsdown` 构建) |
|
||||
| DSH | 见下面的版本矩阵 |
|
||||
| 终端可用性 | 仅 v0.19.1 线需要额外放行 `node-pty` 原生构建(见 §五) |
|
||||
|
||||
### 1.1 宿主版本矩阵(先读这张表)
|
||||
|
||||
| 版本线 | 分支 | 插件版本 | peer 声明 | 适用宿主 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 旧宿主线 | `main` | 1.2.0 | `^0.1.5-rc.1` | DSH `0.1.5-rc.1+`(含 `0.1.5-rc.2`,即 **DSH Desktop 2.0.10** 内置版本) |
|
||||
| 新宿主线 | `sync/v0.24.1` | 1.3.0 | `^0.2.0-rc.1` | DSH `0.2.0-rc.1+` |
|
||||
|
||||
**怎么查宿主版本**(扩展模式桌面版):
|
||||
|
||||
```bash
|
||||
# 内置的 dsh 版本
|
||||
grep -m1 '"version"' "/Applications/DSH Desktop.app/Contents/Resources/app/node_modules/@deepseek-ai/dsh/package.json"
|
||||
# 或看 profile 里装的宿主包
|
||||
grep -m1 -A2 '"@deepseek-ai/dsh"' ~/.dsh/profiles/desktop/package.json
|
||||
```
|
||||
|
||||
**选线决策**
|
||||
|
||||
```
|
||||
宿主 ≥ 0.2.0-rc.1 ? ── 是 ──▶ sync/v0.24.1(1.3.0)
|
||||
└─ 否 ──▶ main(1.2.0)
|
||||
```
|
||||
|
||||
> ⚠️ **peer 不匹配不会报错。** 宿主以 `semver.satisfies(host, peerRange, { includePrerelease: true })` 预检,
|
||||
> caret 跨 minor 判 false 后**整行静默禁用**:不加载、无日志、界面上「像没装」。
|
||||
> 因此「装完没反应」的第一件事永远是核对宿主版本 ↔ peer 范围。
|
||||
|
||||
## 二、安装方式(本 fork 未发布 npm 包)
|
||||
|
||||
`npm view dsh-apes-sidebar` 返回 404 —— **本 fork 不在 npm 上**(README 里的 npm 徽章/命令是沿用上游文档)。
|
||||
实际部署使用「本地源码 + `file:` 链接依赖」:
|
||||
|
||||
### 步骤 1:构建
|
||||
|
||||
```bash
|
||||
cd <插件仓库绝对路径>
|
||||
git checkout main # 或 sync/v0.24.1(按宿主版本选)
|
||||
pnpm install # 会顺带跑 prepare: tsdown
|
||||
pnpm typecheck && pnpm build && pnpm test # 门禁三连
|
||||
```
|
||||
|
||||
产物(`lib/`):`index.js` / `invariant.js`(host)+ `client.js` + 懒加载 chunk
|
||||
(`client-registry` / `client-editor` / `client-locale` / `client-mermaid` / `client-terminal`)+ `types/`。
|
||||
|
||||
### 步骤 2:接进 profile
|
||||
|
||||
编辑 `~/.dsh/profiles/<profile>/package.json`(桌面版为 `desktop`,网页版通常为 `web`):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"dependencies": {
|
||||
"dsh-apes-sidebar": "file:/绝对路径/dsh-apes-sidebar" // ← 必须是绝对路径
|
||||
},
|
||||
"dsh": {
|
||||
"profile": {
|
||||
"bundles": [
|
||||
"…",
|
||||
"dsh-apes-sidebar" // ← 加入 bundle 栈
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 也可以在 profile 自己的 `cordis.patch.yml` 里手写挂载行(老方式)。**两种方式不要同时用**:
|
||||
> 会双挂载成两个 Node 半、两个侧边栏。包内自带的 `cordis.patch.yml` 是 `dsh.bundle.patch` 层,
|
||||
> 由官方 CLI 安装时自动追加 bundle,并带「聚合包双挂载退让」守卫。
|
||||
|
||||
### 步骤 3:让 profile 重新安装依赖
|
||||
|
||||
```bash
|
||||
cd ~/.dsh/profiles/<profile> && pnpm install
|
||||
```
|
||||
|
||||
**为什么必须重装**:pnpm 对 `file:` 依赖是**复制**语义(不是软链)。源码 `pnpm build` 之后,
|
||||
profile 的 `node_modules/dsh-apes-sidebar/lib` **仍是旧副本**——这就是「改了代码但界面没变」的头号原因。
|
||||
DSH 启动时也会 reconcile profile 的 pnpm workspace,但不要假定它一定帮你刷新。
|
||||
|
||||
### 步骤 4:完整重启
|
||||
|
||||
**⌘Q 完全退出 DSH,再重新启动**(不是关窗口)。client 半有热加载,但 host 半与依赖变更必须走完整重启。
|
||||
|
||||
## 三、验收清单
|
||||
|
||||
重启后逐条核对(每条都对应一个可观察事实):
|
||||
|
||||
- [ ] **右列只有一套侧边栏**、一个开合控件(不再出现自绘的第二套开关)
|
||||
- [ ] 原生 guide 里能看到本插件的 tab 芯片,且**带图标**
|
||||
- [ ] 三个信息层 tab 存在:**智能体 / 应用 / 画像**(order 在原生面板之后)
|
||||
- [ ] `apes:agents` 打开后:有主代理卡片;有子代理时列出直系子代理(标签 + 模式 + 运行态)
|
||||
- [ ] 打开一个 HTML 文件:**沙箱态下自包含 HTML 能正常渲染**(不再是 `forbidden`)
|
||||
- [ ] 打开「社区插件市场」:弹窗**完整盖在**本插件面板之上(面板不切弹窗角)
|
||||
- [ ] 页面左下角**没有**红色诊断条(有的话说明 client 生命周期某处抛错)
|
||||
- [ ] 开发者控制台**没有** `[dsh-apes-sidebar] … error` 前缀的错误
|
||||
|
||||
## 四、网页 profile 与桌面 profile 的差异
|
||||
|
||||
| 项 | 桌面版(DSH Desktop) | 网页版 |
|
||||
| --- | --- | --- |
|
||||
| profile 目录 | `~/.dsh/profiles/desktop` | 通常 `~/.dsh/profiles/web` |
|
||||
| 外壳 | Electron 壳,有「扩展模式/兼容模式」与请求围栏 | 浏览器直连宿主端口 |
|
||||
| 预览 iframe | 受渲染器围栏约束(因此必须走 `srcDoc` 交付) | 无该围栏,但仍受路由 CSP/沙箱约束 |
|
||||
| 面板宿主层级 | 壳层会给 `#root` 打 content-viewport 标记 → 宿主挂进 `#root` | 无标记 → 宿主挂 `document.body` |
|
||||
| 刷新方式 | ⌘Q 完整退出再启动 | 硬刷新(Cmd/Ctrl+Shift+R) |
|
||||
|
||||
## 五、v0.19.1 线特有:放行 node-pty 原生构建
|
||||
|
||||
`main`(上游 v0.19.1 基线)在 `package.json` 中仍有 `node-pty` 依赖;pnpm 11 默认**拦截依赖的构建脚本**,
|
||||
于是首次安装会失败或终端不可用:
|
||||
|
||||
```bash
|
||||
cd ~/.dsh/profiles/<profile>
|
||||
pnpm approve-builds # 选中 node-pty,回车确认
|
||||
pnpm rebuild node-pty # 必要时
|
||||
```
|
||||
|
||||
`sync/v0.24.1`(1.3.0)**不含** `node-pty`(终端交由宿主内置承担),无需此步。
|
||||
(`main` 与 `sync/v0.24.1` 的 `node-pty` 引用数:1 / 0。)
|
||||
|
||||
## 六、切换与卸载
|
||||
|
||||
同一条线只能装一个(两条线都注册 `ctx.betterSidebar`,entry id 冲突,第二个被丢弃)。
|
||||
仓库自带切换脚本:
|
||||
|
||||
```bash
|
||||
bash scripts/switch.sh --status # 只看当前是哪条线
|
||||
bash scripts/switch.sh aps # 切到本 fork 线(本地 file: 安装)
|
||||
bash scripts/switch.sh upstream # 切回上游 dsh-better-sidebar
|
||||
bash scripts/switch.sh upstream 0.18.0 # 指定上游版本
|
||||
bash scripts/switch.sh aps --profile web # 指定 profile
|
||||
```
|
||||
|
||||
**完全卸载**:从 profile 的 `dependencies` 与 `dsh.profile.bundles` 中移除 `dsh-apes-sidebar`,
|
||||
`pnpm install`,⌘Q 完整重启。若曾手写过 `cordis.patch.yml` 挂载行,一并删除(否则会留下悬空 entry)。
|
||||
|
||||
> 若同时装了聚合包(如 `@linxin666/dsh-web-ui-all`)且它内部也挂载了本包,
|
||||
> 包内 bundle patch 的守卫会让**本行退让**;此时请确认聚合包在 bundle 栈中位于本行之前。
|
||||
|
||||
## 七、变更后的重新部署
|
||||
|
||||
```bash
|
||||
# 插件仓库
|
||||
pnpm typecheck && pnpm build && pnpm test
|
||||
# profile
|
||||
cd ~/.dsh/profiles/<profile> && pnpm install
|
||||
# ⌘Q 退出 DSH 再启动
|
||||
```
|
||||
|
||||
改完代码**必须**走完这三步:产物构建非字节确定性,profile 的 `file:` 副本也不会自动跟随。
|
||||
@@ -0,0 +1,133 @@
|
||||
# 05 · 运维手册
|
||||
|
||||
> 本篇是**日常运维、升级与回滚**的操作手册。所有命令都以「先留锚点、再动手、改完回读验证」为原则。
|
||||
|
||||
## 一、版本线与分支策略(当前远端实况)
|
||||
|
||||
| ref | 指向 | 含义 / 用途 |
|
||||
| --- | --- | --- |
|
||||
| `main` | `4422001` | **旧宿主线**:插件 1.2.0,上游基线 v0.19.1,peer `^0.1.5-rc.1`。本机 DSH Desktop 2.0.10 正在跑这条 |
|
||||
| `sync/v0.24.1` | `e5bbef1` | **新宿主线**:插件 1.3.0,上游基线 v0.24.1,peer `^0.2.0-rc.1`。待 ≥0.2.0 宿主实机验证 |
|
||||
| `backup/pre-sync-v0.19.1` | `96cc334` | v0.19.1 re-base **之前**的状态(旧 fork 三角色 tab 版) |
|
||||
| `backup/pre-sync-v0.24.1` | `4422001` | v0.24.1 re-base 之前的 `main`(等价于当前 main,作为下次升级的锚点) |
|
||||
| `tags/apes-v1.1.0-pre-sync` | `96cc334` | 与上面第一支 backup 同一提交,tag 形式的锚点 |
|
||||
| `tags/apes-v1.2.0` | `4422001` | 当前已验收的 1.2.0 线 |
|
||||
|
||||
**纪律**:任何 re-base / 大改之前,先建 `backup/pre-sync-<新基线>` 分支 + `apes-v<版本>` tag,
|
||||
并**推送**到远端(本地锚点在多人/多机场景等于不存在)。
|
||||
|
||||
## 二、回滚剧本
|
||||
|
||||
### 场景 A:某次提交引入回归(单点回退)
|
||||
|
||||
```bash
|
||||
cd <插件仓库>
|
||||
git log --oneline -5 # 定位可疑提交
|
||||
git revert <sha> # 保留历史的回退(推荐)
|
||||
# 或整体丢弃:git reset --hard <上一提交>
|
||||
pnpm typecheck && pnpm build && pnpm test
|
||||
cd ~/.dsh/profiles/<profile> && pnpm install # file: 是复制语义,必须重装
|
||||
# ⌘Q 完全退出 DSH → 重启
|
||||
```
|
||||
|
||||
### 场景 B:整条线回退(例如新线在真机上不可用)
|
||||
|
||||
```bash
|
||||
git checkout main # 或 backup/pre-sync-v0.24.1
|
||||
pnpm install && pnpm build
|
||||
cd ~/.dsh/profiles/<profile> && pnpm install
|
||||
# ⌘Q 重启
|
||||
```
|
||||
|
||||
### 场景 C:profile 依赖/config 被改坏
|
||||
|
||||
```bash
|
||||
cd ~/.dsh/profiles/<profile>
|
||||
cp package.json package.json.bak.$(date +%s) # 先备份
|
||||
bash <插件仓库>/scripts/switch.sh aps # 或 upstream,脚本会重写依赖与 bundle 行
|
||||
pnpm install
|
||||
# ⌘Q 重启
|
||||
```
|
||||
|
||||
**回滚验收**:重启后按 [04-部署指南](04-部署指南) §三 的验收清单逐条核对(至少「右列一套侧边栏」
|
||||
与「三个信息层 tab 存在」两条)。
|
||||
|
||||
## 三、发布 / 变更纪律
|
||||
|
||||
1. **版本三元组必须一致**:
|
||||
- `package.json` 的 `version`
|
||||
- `dsh.plugin.json` 的 `version`
|
||||
- `src/client/service.ts` 的 `SIDEBAR_SERVICE_VERSION`
|
||||
三者不一致会被单元测试(`tests/service.spec.ts`)直接拦下——**不要改其中一个而忘了另一个**。
|
||||
2. **门禁三连必过**:`pnpm typecheck && pnpm build && pnpm test`。当前基线:`main` 131 个测试文件 /
|
||||
1367 通过;`sync/v0.24.1` 141 个文件 / 1515 通过(另有 9 个 skip)。
|
||||
3. **产物非字节确定性**:两次构建的字节数可能相同但 **sha 不同**。因此「改完代码 → 让 profile 用旧副本」
|
||||
永远不成立;必须重新 `pnpm build` + 让 profile 重新安装。
|
||||
4. **发布方式**:本 fork 未发布 npm 包,发布 = 推分支/tag + 目标机器 `git fetch && checkout && pnpm build`
|
||||
+ profile 重装。发布前把 `README.md` 与 `docs/wiki/` 一并更新(wiki 见 §六)。
|
||||
5. **不要顺手改无关文件**:re-base 时保持「只带自己的增量」,避免下次升级冲突面扩大。
|
||||
|
||||
## 四、日常巡检清单
|
||||
|
||||
| 频率 | 检查项 | 命令 / 判据 |
|
||||
| --- | --- | --- |
|
||||
| 每次部署后 | 版本三元组一致 | 见 §三.1 |
|
||||
| 每次部署后 | 产物标记齐全 | `grep -c 'apes:agents' lib/client.js`(>0)、`grep -c 'dsh-desktop-content-viewport' lib/client.js`(>0) |
|
||||
| 每次部署后 | profile 副本已刷新 | 比对 `lib/client.js` 与 `~/.dsh/profiles/<p>/node_modules/dsh-apes-sidebar/lib/client.js` 的 sha256 |
|
||||
| 每次升级宿主/插件 | 宿主版本 ↔ peer 范围 | 见 [04-部署指南](04-部署指南) §1.1(不匹配 = 静默禁用) |
|
||||
| 每月/大改前 | 锚点是否已推远端 | `git ls-remote <remote> \| grep -E 'heads/(main\|sync)\|tags/apes'` |
|
||||
| 异常时 | 诊断条与控制台 | 页面左下角红条;控制台 `[dsh-apes-sidebar]` 前缀 |
|
||||
|
||||
## 五、备份与配置铁律
|
||||
|
||||
- **改任何 profile 配置前先备份**,改完**回读验证**(不要假定写入即生效):
|
||||
|
||||
```bash
|
||||
cp ~/.dsh/profiles/<profile>/package.json{,.bak.$(date +%s)}
|
||||
# 改完:
|
||||
python3 -c "import json;json.load(open('$HOME/.dsh/profiles/<profile>/package.json'));print('JSON OK')"
|
||||
```
|
||||
|
||||
- **DSH 应用配置的类型陷阱**:部分字段(如材质/透明度类的 `macosMaterial`)必须是**字符串**
|
||||
(`"off"` / `"transparent"`)。若被写成 YAML 布尔(`false`),DSH 会启动进**恢复模式**。
|
||||
改完务必回读校验类型:
|
||||
|
||||
```bash
|
||||
python3 -c "import yaml;d=yaml.safe_load(open('$HOME/.dsh/settings.yaml'));v=d.get('macosMaterial');print(v, type(v).__name__)"
|
||||
```
|
||||
|
||||
- **不要回滚/手工编辑** profile 的 `pnpm-lock.yaml`:由 pnpm 重建;手工改会导致解析错乱。
|
||||
- **凭据不进仓库**:远端推送用系统凭据助手(`git credential fill`),仓库内不写 token。
|
||||
|
||||
## 六、百科(wiki)本身的维护
|
||||
|
||||
百科源文件版本化在仓库 `docs/wiki/`,通过脚本推送到 Gitea wiki:
|
||||
|
||||
```bash
|
||||
bash scripts/push-wiki.sh # 推送/更新全部页面(幂等:有则 PATCH,无则 POST)
|
||||
bash scripts/push-wiki.sh 05-运维手册 # 只推一页
|
||||
```
|
||||
|
||||
凭据来自 `git credential fill`(与推送代码同一套,不落盘)。修改文档的推荐流程:
|
||||
**改 `docs/wiki/*.md` → 提交到仓库 → 跑脚本推送**,这样 wiki 与代码同源可回溯。
|
||||
|
||||
## 七、诊断与日志
|
||||
|
||||
| 信号 | 含义 | 位置 |
|
||||
| --- | --- | --- |
|
||||
| 左下角红色诊断条 `[dsh-apes-sidebar] <phase> error: …` | client 生命周期某阶段抛错(mount / native / 懒加载等) | 页面左下角(`z-index: 2147483000`),同时 `console.error` 打前缀 |
|
||||
| 控制台 `[dsh-apes-sidebar] …` | 同上,且带上具体阶段名 | 开发者工具 Console |
|
||||
| 「插件像没装」且**无任何日志** | 宿主兼容性预检失败(peer 不匹配)→ **静默禁用** | 只能靠版本矩阵排查,见 [06-故障排查](06-故障排查) §1 |
|
||||
| 界面正常但面板层级错乱 | 面板宿主挂载点/层叠上下文问题 | 见 [02-系统架构](02-系统架构) §四 |
|
||||
|
||||
## 八、性能与容量
|
||||
|
||||
| 项 | 观察值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `lib/client.js` | ≈ 0.9 MB | 主 bundle,始终加载 |
|
||||
| `lib/client-mermaid.js` | ≈ 7 MB(+12 MB map) | 懒加载,仅在需要渲染 Mermaid 时拉取 |
|
||||
| `lib/client-editor.js` | ≈ 2 MB | 懒加载,仅打开编辑器时 |
|
||||
| `lib/client-terminal.js` | ≈ 0.5 MB | 懒加载,仅打开终端时 |
|
||||
| 全量测试 | 约 3–4 分钟 | 变更后按需跑全量,日常可只跑相关 spec |
|
||||
|
||||
主 bundle 不应静态引入上述重型库;新增重型依赖一律走 `src/client/chunks/`(懒加载 chunk)。
|
||||
@@ -0,0 +1,208 @@
|
||||
# 06 · 故障排查
|
||||
|
||||
> 每条都给出**症状 → 根因(机制)→ 处置 → 验证**。标注 ✅ 的条目是在真机上定位并验证过的案例。
|
||||
|
||||
## 速查表
|
||||
|
||||
| 症状 | 最可能根因 | 跳到 |
|
||||
| --- | --- | --- |
|
||||
| 装完「像没装」,界面无变化且**无任何日志** | peer 范围与宿主版本不匹配 → 静默禁用 | §1 |
|
||||
| 页面出现**两个侧边栏** / 两套开合按钮 | 双挂载(两条线同装,或残留挂载行) | §2 |
|
||||
| HTML 预览只显示 `forbidden` | 桌面壳渲染器围栏 + 不透明源 iframe | §3 |
|
||||
| 插件面板盖住宿主/第三方弹窗(如插件市场) | 面板宿主挂在层叠上下文之外 | §4 |
|
||||
| 终端打不开 / `node-pty` 加载失败 | pnpm 拦截原生构建(仅 v0.19.1 线) | §5 |
|
||||
| 改了代码,界面没变 | 产物未重建 / profile 副本未刷新 / 浏览器缓存 | §6 |
|
||||
| DSH 拒绝加载插件、entry pending | 插件声明(`dsh.plugin.json` / bundle patch)不合法 | §7 |
|
||||
| DSH 启动进**恢复模式** | profile 配置被写成错误类型(如 YAML 布尔) | §8 |
|
||||
|
||||
---
|
||||
|
||||
## §1 装完「像没装」:静默禁用 ✅
|
||||
|
||||
**症状**:profile 里依赖装了、文件也在,但界面没有任何变化;日志里也没有插件相关报错。
|
||||
|
||||
**根因**:宿主启动时做兼容性预检,判定 `peerDependencies` 与自身版本是否满足:
|
||||
|
||||
```
|
||||
semver.satisfies(hostVersion, peerRange, { includePrerelease: true })
|
||||
```
|
||||
|
||||
caret 范围**跨 minor 必然为 false**(例如 `^0.1.5-rc.1` 对 `0.2.0`)。判定失败后宿主的处理是
|
||||
**跳过这一行**——不抛错、不打日志、不加载。
|
||||
|
||||
**处置**:核对宿主版本,改用匹配的版本线(见 [04-部署指南](04-部署指南) §1.1)。
|
||||
|
||||
```bash
|
||||
grep -m1 '"version"' "/Applications/DSH Desktop.app/Contents/Resources/app/node_modules/@deepseek-ai/dsh/package.json"
|
||||
grep -n 'dsh-client-ui-sidebar-right' <插件仓库>/package.json # 看 peer 范围
|
||||
```
|
||||
|
||||
**验证**:重启后右列出现插件 tab;`lib/client.js` 里能 grep 到 `apes:agents`。
|
||||
|
||||
**教训**:这一条没有日志可查,因此**部署前先看版本矩阵**是唯一可靠的预防手段。
|
||||
|
||||
## §2 两个侧边栏 / 两套开合按钮 ✅
|
||||
|
||||
**症状**:窗口里出现两套右侧面板与两组开合控件。
|
||||
|
||||
**根因**:两个方向都可能:
|
||||
1. **旧版遗留**:v0.18.0 基线时插件自绘右侧面板与开关(`toggleCluster`)。升级后自绘部分应已移除。
|
||||
2. **双挂载**:同一条线被挂两次(profile 自己的 `cordis.patch.yml` 里还留着旧挂载行,
|
||||
与 bundle patch 同时生效),或者本 fork 线与上游线同时安装(两者都注册 `ctx.betterSidebar`,
|
||||
entry id 冲突,第二个被丢弃)。
|
||||
|
||||
**处置**:
|
||||
|
||||
```bash
|
||||
# 确认产物里已经没有自绘开关
|
||||
grep -c toggleCluster <插件仓库>/lib/client.js # 期望 0
|
||||
|
||||
# 检查是否双挂载:profile 里是否同时存在 bundle 行 与 手写挂载行
|
||||
grep -n "dsh-apes-sidebar\|dsh-better-sidebar" ~/.dsh/profiles/<profile>/cordis.patch.yml ~/.dsh/profiles/<profile>/package.json
|
||||
```
|
||||
|
||||
- 有残留手写挂载行 → 删除它,只保留一条通道。
|
||||
- 两条线同装 → `bash scripts/switch.sh aps`(或 `upstream`)二选一。
|
||||
- 装了聚合包(如 `@linxin666/dsh-web-ui-all`)→ 确认聚合包在本包之前,让包内守卫生效。
|
||||
|
||||
**验证**:右列只剩一套面板与一个开合控件。
|
||||
|
||||
## §3 HTML 预览显示 `forbidden` ✅
|
||||
|
||||
**症状**:预览 HTML 文件时 iframe 里只有一行裸文本 `forbidden`;而「临时解锁」后又能看。
|
||||
|
||||
**根因(三层)**:
|
||||
|
||||
1. DSH Desktop 在**扩展模式**下给所有路由套了一层请求围栏:`ordinaryBrowserEnabled` 恒为 false,
|
||||
不带 Electron 注入的 `x-dsh-desktop-renderer` 头的请求一律 `403 + "forbidden"`;
|
||||
2. 该头只注入给 **mainFrame** 与**同源**子框架;
|
||||
3. 预览 iframe 的 sandbox 令牌不含 `allow-same-origin`,请求时其源为 `null` → 拿不到头 → 403。
|
||||
|
||||
与「是否升级了插件基线」无关(v0.18.0 起行为一致)。
|
||||
|
||||
**处置**:插件已在 1.2.0+ 用 **`srcDoc` 交付**修复(主框架取文档 → 注入 `<base>` → 注入 iframe),
|
||||
`main` 上对应提交 `4c242ac`。**不要**通过给 iframe 加 `allow-same-origin` 来"修"——那是安全降级。
|
||||
|
||||
**验证**:
|
||||
- 沙箱态下自包含 HTML(含内联脚本)能正常渲染;
|
||||
- 有外链资源的 HTML 在沙箱态下缺资源属**已知限制**,点「临时解锁」后应完整显示。
|
||||
|
||||
**排查命令**(确认宿主围栏在起作用):
|
||||
|
||||
```bash
|
||||
lsof -nP -iTCP -sTCP:LISTEN | grep -i dsh # 找宿主端口(每次启动可能不同)
|
||||
curl -s -o /dev/null -w '%{http_code}\n' "http://127.0.0.1:<端口>/" # 无渲染器头 → 403
|
||||
```
|
||||
|
||||
## §4 插件面板盖住宿主/第三方弹窗 ✅
|
||||
|
||||
**症状**:打开「社区插件市场」(宿主自带 `dsh-community-market`)时,弹窗左下角被本插件的
|
||||
文件面板整齐切掉一块。
|
||||
|
||||
**根因**:桌面壳的有框模式会给上游 `#root` 打标 `data-dsh-desktop-content-viewport` 并施加
|
||||
`isolation: isolate`,使 `#root` 成为**层叠上下文**;宿主的 overlay 层(`.dshDesktopOverlay`,
|
||||
`z-index: 1000`)位于其中。插件面板宿主若挂在 `document.body`(该上下文之外)且是定位元素,
|
||||
就会画在整个上下文之上,把 overlay 里的弹窗盖住。
|
||||
|
||||
**处置**:已修(`4422001`)——面板宿主改为挂进 `#root[data-dsh-desktop-content-viewport]`,
|
||||
无标记时回退 `document.body`。**不要**通过调大 `z-index` 解决(25 的取义见
|
||||
[02-系统架构](02-系统架构) §四;乱调会破坏与宿主其他层级的关系)。
|
||||
|
||||
**验证**:插件市场弹窗完整盖住本插件面板,同时本插件面板仍在对话内容之上。
|
||||
|
||||
## §5 终端不可用 / `node-pty` 加载失败(仅 v0.19.1 线)✅
|
||||
|
||||
**症状**:终端面板报 shell 启动失败或提示 `node-pty` 加载失败;`pnpm install` 阶段报
|
||||
"ignored build scripts"。
|
||||
|
||||
**根因**:pnpm 11 默认**拦截依赖的构建脚本**,`node-pty` 的原生二进制没被编译。
|
||||
(`main` 依赖 `node-pty`;`sync/v0.24.1` 已移除该依赖,终端由宿主内置承担。)
|
||||
|
||||
**处置**:
|
||||
|
||||
```bash
|
||||
cd ~/.dsh/profiles/<profile>
|
||||
pnpm approve-builds # 选中 node-pty
|
||||
pnpm rebuild node-pty # 必要时
|
||||
# ⌘Q 完整退出 DSH 再启动
|
||||
```
|
||||
|
||||
**验证**:终端能开出 shell;`node_modules/node-pty/build/Release/*.node` 存在。
|
||||
|
||||
## §6 改了代码,界面没变 ✅
|
||||
|
||||
**症状**:源码已改、`pnpm build` 也跑了,但界面行为仍是旧的。
|
||||
|
||||
**根因(三个叠加因素)**:
|
||||
|
||||
1. 产物**非字节确定性**,且 profile 的 `file:` 依赖是**复制**语义 → profile 里仍是旧副本;
|
||||
2. `lib/client.js` 是浏览器的 `window.__ModuleLoader__.load({...})` bundle,**不是 CJS/ESM**,
|
||||
在 Node 里 `require` 会因 `window is not defined` 报错——因此不能用「在 Node 里试跑」来验证前端改动;
|
||||
3. host 半的改动必须重启进程(client 半有热加载,host 半没有)。
|
||||
|
||||
**处置**:
|
||||
|
||||
```bash
|
||||
cd <插件仓库> && pnpm build
|
||||
cd ~/.dsh/profiles/<profile> && pnpm install # 刷新 file: 副本
|
||||
# ⌘Q 完整退出 DSH → 重启
|
||||
```
|
||||
|
||||
**验证**:比对两边产物 sha:
|
||||
|
||||
```bash
|
||||
shasum -a 256 <插件仓库>/lib/client.js \
|
||||
~/.dsh/profiles/<profile>/node_modules/dsh-apes-sidebar/lib/client.js
|
||||
```
|
||||
|
||||
## §7 DSH 拒绝加载插件 / entry pending
|
||||
|
||||
**症状**:插件目录存在,但宿主不加载,或 loader 报 entry pending / 恢复模式。
|
||||
|
||||
**根因**:插件的声明面不合法或不被接受,常见于:
|
||||
- `dsh.plugin.json` 的 `engines.dsh` 与宿主不符;
|
||||
- bundle patch / 挂载行的 `insert` 结构不合法(例如把声明写成宿主无法解析的形态);
|
||||
- 同 id 重复挂载(`duplicate loader entry id` / `duplicate prefix route`)。
|
||||
|
||||
**处置**:
|
||||
|
||||
```bash
|
||||
cat <插件仓库>/dsh.plugin.json # 检查 id / engines / main
|
||||
cat <插件仓库>/cordis.patch.yml # 检查 insert 结构与守卫表达式
|
||||
grep -n "cordis.patch\|dsh-apes-sidebar" ~/.dsh/profiles/<profile>/package.json
|
||||
```
|
||||
|
||||
确保只有一条挂载通道、`name` 与实际包名一致(本 fork 为 `dsh-apes-sidebar`),
|
||||
`engines.dsh` 与宿主版本相容。改完 `pnpm install` + **完整重启**。
|
||||
|
||||
**验证**:启动日志无 loader 报错;右列出现插件 tab。
|
||||
|
||||
## §8 DSH 启动进「恢复模式」
|
||||
|
||||
**症状**:DSH 启动后进入恢复/安全模式,插件与侧边栏都不正常。
|
||||
|
||||
**根因**:profile 或应用配置被写成**错误类型**。典型是材质/透明度字段要求**字符串**
|
||||
(`"off"` / `"transparent"`),但被 YAML 序列化成了布尔 `false` —— 宿主按类型校验失败即进恢复模式。
|
||||
|
||||
**处置与验证**:
|
||||
|
||||
```bash
|
||||
python3 -c "import yaml;d=yaml.safe_load(open('$HOME/.dsh/settings.yaml'));v=d.get('macosMaterial');print(repr(v), type(v).__name__)"
|
||||
# 期望:'off' str(或 'transparent' str);若为 False bool → 改回字符串
|
||||
```
|
||||
|
||||
改配置前**先备份**,改完**回读校验类型**(见 [05-运维手册](05-运维手册) §五)。
|
||||
|
||||
---
|
||||
|
||||
## 附:定位手法(本次会话验证有效)
|
||||
|
||||
1. **先按用户可见文案 grep 找归属**:`grep -rl "<界面上的中文短语>" <app>/lib <profile>/node_modules`。
|
||||
若两边都搜不到,说明它来自**运行时下载/缓存**(例如市场插件),再按 `NSUserData` 缓存或
|
||||
`desktop-market/state.json` 之类线索去找提供方。
|
||||
2. **再读提供方自己的 CSS/DOM 结构**:拿到它声明的 z-index、定位方式与所在槽位
|
||||
(如 `renderSlot("shell.overlay")`),据此判断是「它太高」还是「我们太高」。
|
||||
3. **层叠问题先找 `isolation` / `transform` / `filter` / `contain`**:这些才是层叠上下文与包含块的真正来源,
|
||||
比 z-index 数值更关键。
|
||||
4. **像素证据要用原图量**:交给视觉模型的截图会被缩放(1920→1280),裁剪坐标 ≠ 屏幕坐标;
|
||||
用 PIL 在原图上量边界、比对 RGB 剖面,再下结论。
|
||||
@@ -0,0 +1,164 @@
|
||||
# 07 · 开发与测试
|
||||
|
||||
> 面向改代码的人(与 agent)。**先读 [02-系统架构](02-系统架构) §六「关键不变量」**,
|
||||
> 再动手改,能省掉大部分回退。
|
||||
|
||||
## 一、工程约定
|
||||
|
||||
| 项 | 约定 |
|
||||
| --- | --- |
|
||||
| 语言 | TypeScript **strict**(含 `noUncheckedIndexedAccess`)、ESM、导入写 `.ts` 扩展名 |
|
||||
| 构建 | `tsdown`(rolldown),host/client 双产物;懒加载 chunk 走 `src/client/chunks/` |
|
||||
| 包管理 | `pnpm`(11.x);`pnpm install` 会触发 `prepare` 构建 |
|
||||
| 测试 | `vitest`;默认 node 环境,需要 DOM 的 spec 用 `// @vitest-environment jsdom` |
|
||||
| 宿主 peer | `dsh-better-sidebar`(本仓库已改名)**永不打包**,必须留在 peer/neverBundle 一侧 |
|
||||
| 命名 | 信息层 tab id 前缀 `apes:`;插件名 `dsh-apes-sidebar` |
|
||||
| 颜色 | 只用 `--dsw-alias-*` 设计令牌(`var(--token, <回退>)`),不写死色值 |
|
||||
|
||||
## 二、目录结构(`main` 实况)
|
||||
|
||||
```
|
||||
src/
|
||||
index.ts HOST 入口:name / inject / apply(ctx, config),路由与围栏装配
|
||||
invariant.ts 零依赖不变量面(host/client 共用)
|
||||
context-types.ts cordis 服务面契约(ctx.sessions / sidebarRight / …)
|
||||
trust-fence.ts 请求信任围栏(Host 头 / 令牌)
|
||||
html-route.ts /sidebar/html 路由(路径围栏 + 体积上限)
|
||||
config.ts 挂载行配置 schema 与默认值(readLimit / mediaLimit / …)
|
||||
fs-*.ts git.ts … 文件系统、Git、会话、任务、终端等 host 能力
|
||||
client/
|
||||
index.tsx CLIENT 入口(apply):装配 store/service/native/apes
|
||||
service.ts ctx.betterSidebar 服务实现(对外 API + 能力清单)
|
||||
state.ts 侧边栏状态存储(store)
|
||||
native/ 原生右侧栏适配(tab 记录、tab 体、tab 芯片标题)
|
||||
builtins/ 内置面板:editor / git / subagent / sidechat / terminal / browser + 预览器
|
||||
sidebar/ 自绘底部工作台与开合控件
|
||||
apes/ 信息层三 tab(本 fork 增量:tabs.tsx / icons.tsx / locales.ts)
|
||||
changes/ diff/ 文件变动与 diff 视图
|
||||
chunks/ 懒加载 chunk:editor / locale / mermaid / terminal
|
||||
locales*.ts i18n(`locales.ts` + 19 个语言覆盖文件)
|
||||
*.module.css 组件样式(含 sidebar.module.css:面板宿主层契约)
|
||||
tests/ vitest specs(131 个文件,含 e2e/ 的挂载脚本型用例)
|
||||
scripts/ install / switch / e2e 挂载 / 发布辅助
|
||||
docs/ external-plugin-guide.md(对外契约)+ plans/(上游历史设计)+ wiki/(本百科源文件)
|
||||
```
|
||||
|
||||
## 三、门禁(提交前必跑)
|
||||
|
||||
```bash
|
||||
pnpm typecheck # tsc --noEmit(strict)
|
||||
pnpm build # rm -rf lib && tsc(d.ts) && tsdown
|
||||
pnpm test # vitest run
|
||||
```
|
||||
|
||||
当前基线(本次会话实测):`main` = 131 文件 / **1367 通过** + 9 skip;
|
||||
`sync/v0.24.1` = 141 文件 / **1515 通过** + 9 skip。全量 test 约 3–4 分钟。
|
||||
**任何一条不过就不要提交**;三条全绿才算完成。
|
||||
|
||||
## 四、测试体系(按层次)
|
||||
|
||||
| 层次 | 环境 | 手法 | 代表 spec |
|
||||
| --- | --- | --- | --- |
|
||||
| 宿主插件形状 / 对外契约 | node | 断言导出面与语义(含与 `package.json` 的版本一致性) | `smoke.spec.ts` · `plugin-shape.spec.ts` · `service.spec.ts` · `consumer-types.ts` |
|
||||
| 原生侧边栏适配 | node | 用「类型为真、运行时为假」的 mock 注册表 | `native-surface.spec.ts` |
|
||||
| 面板宿主与层叠契约 | node + jsdom | CSS 断言 + DOM 挂载点解析 | `panel-host-css.spec.ts` · `panel-host-parent.spec.ts` |
|
||||
| 预览交付 | node + jsdom | 纯逻辑(`<base>` 注入)+ 客户端挂载(stub `fetch`,`createRoot` + `act`) | `html-preview.spec.ts` · `html-preview-frame.spec.tsx` |
|
||||
| 沙箱契约 | node | 对渲染结果做**字符串断言**(服务端渲染把 `srcDoc` 输出为 camelCase `srcDoc=""`,与客户端设置的真实属性 `srcdoc` 不同 —— 断言方式随之不同) | `sandbox-views.spec.tsx` |
|
||||
| 信息层 tab | node | mock `ctx.sessions.list` 与投影 | `apes-tabs.spec.ts` |
|
||||
| 桌面环境探测 | node | stub 全局(URL stamp / preload 标记) | `desktop-env.spec.ts` |
|
||||
|
||||
约定:
|
||||
- **不引入 jsdom 到默认环境**:需要 DOM 的 spec 在文件头写 `// @vitest-environment jsdom`。
|
||||
- **不依赖真实 DSH 宿主**:需要真宿主的用例归到 `tests/e2e/`(脚本型挂载),不进单元门禁。
|
||||
- 新增「契约」必须**同时新增 pin 它的 spec**(例如本文档提到的 25 层级、沙箱令牌、`srcDoc` 交付方式,
|
||||
都有对应断言)。改契约 = 改代码 + 改 spec,两者必须一起提交。
|
||||
|
||||
## 五、扩展点(对外服务)
|
||||
|
||||
其他插件通过 `ctx.betterSidebar` 接入:
|
||||
|
||||
```ts
|
||||
// 注册一个 tab 类型
|
||||
await ctx.betterSidebar.registerTab(descriptor)
|
||||
// 注册一个文件预览器
|
||||
await ctx.betterSidebar.registerFileViewer(viewer)
|
||||
```
|
||||
|
||||
- 服务按**能力清单**做前向兼容门控(能力只增不减),消费方应据此判断可用性而非版本号比较。
|
||||
- 完整契约、字段表与示例见仓库内 [docs/external-plugin-guide.md](../blob/main/docs/external-plugin-guide.md)。
|
||||
- 改服务面时:**只能新增**;删除或改语义需同步更新能力清单、指南与本百科。
|
||||
|
||||
## 六、上游同步:测量式 re-base(本项目的方法论)
|
||||
|
||||
**不 merge 上游**。上游每个版本差距可达数百文件,直接合并会引入无关变更并放大冲突面;
|
||||
本项目改用「先测量、再重建、后补增量」:
|
||||
|
||||
### 步骤
|
||||
|
||||
**① 测出自己的增量(把改名归一化掉)**
|
||||
|
||||
```python
|
||||
# 对每个变更文件:取出两边 blob,把改名归一化后比较
|
||||
old = git('show', f'{base}:{path}'); new = git('show', f'HEAD:{path}')
|
||||
rename_only = old.replace('dsh-better-sidebar', 'dsh-apes-sidebar') == new
|
||||
```
|
||||
→ 得到三张清单:**仅改名**(自动处理)/ **真改动** / **新增**。数量级参考:v0.19.1→v0.24.1 为
|
||||
78 / 11 / 10。
|
||||
|
||||
**② 确定改名的精确范围(不要重新推断)**
|
||||
|
||||
```bash
|
||||
# 仍保留上游名字的文件 = 有意保留(本仓库为 README.md、package.json、docs/plans/*)
|
||||
comm -12 <(git grep -l 'dsh-better-sidebar' <旧基线> | cut -d: -f2 | sort) \
|
||||
<(git grep -l 'dsh-better-sidebar' HEAD | cut -d: -f2 | sort)
|
||||
```
|
||||
其余文件只做子串替换 `dsh-better-sidebar` → `dsh-apes-sidebar`;**camelCase 服务名 `betterSidebar` 必须保留**。
|
||||
|
||||
**③ 造一个"改名归一化基座",让增量与改名解耦**
|
||||
|
||||
```bash
|
||||
git checkout -B tmp/norm-<旧基线> <旧基线 tag>
|
||||
# → 套同一口径改名 → commit
|
||||
git diff tmp/norm-<旧基线> HEAD -- . ':(exclude)pnpm-lock.yaml' > /tmp/apes-delta.patch
|
||||
# 校验:这条 diff 的文件清单必须恰好等于 ① 的「真改动 + 新增」
|
||||
```
|
||||
|
||||
**④ 在新基线上重建 + 三点合并**
|
||||
|
||||
```bash
|
||||
git checkout -B sync/<新基线 tag> <新基线 tag>
|
||||
# → 套同一口径改名 → commit("rename only")
|
||||
git apply -3 /tmp/apes-delta.patch # 冲突会以 <<<<<<< ours / >>>>>>> theirs 留存(命令本身不失败)
|
||||
```
|
||||
|
||||
**⑤ 逐条判读冲突(只有五种形态)**
|
||||
|
||||
| 形态 | 判定 |
|
||||
| --- | --- |
|
||||
| 版本/身份 JSON | 取**新基线**侧,再改成本 fork 的版本;两侧各自新增的元数据取**并集**(依赖、files 条目、`forkedFrom` 块) |
|
||||
| 上游搬走了某个 import | 取上游的新路径,保留本 fork 的新 import |
|
||||
| 上游新环境里的注册块 | 保留本 fork 的块,放进上游的新上下文 |
|
||||
| 同一函数两边都定义(上游上移/下移了它) | 删除本 fork 那份;用 `grep -c 'const <name>'` 断言只剩 1 处 |
|
||||
| 长文档(README) | 整体取上游原文,再机械套上本 fork 的两处增量:顶部 fork notice + 包名替换(**跳过** `github.com/<上游 org>` 与徽章域名行) |
|
||||
|
||||
**⑥ 迁移检查(API 漂移)+ 门禁**
|
||||
|
||||
- 先**解完所有冲突标记再** `pnpm install`(`prepare: tsdown` 会因为语法错误直接失败),
|
||||
并**删掉 lockfile 重新生成**(不要把旧 lockfile 带过来)。
|
||||
- 逐条跑 `pnpm typecheck` 修类型漂移。经验:漂移通常集中在**本 fork 自己新增的模块**里。
|
||||
上游会在 `context-types.ts` 的注释里写清迁移路径(例:0.1.6 的 `subagentsByParent`
|
||||
→ 0.1.7 的 `projectionsBySession[id].values.subagentCatalog`,`list.current` 被删除),照注释做即可。
|
||||
- 最后跑门禁三连,并且**用 `grep` 核对关键标记**(tab id、`srcDoc`、`dsh-desktop-content-viewport` 等)。
|
||||
|
||||
**⑦ 收尾(纪律)**
|
||||
|
||||
- 安全锚点:`backup/pre-sync-<新基线>` 分支 + `apes-v<版本>` tag,**推送远端**。
|
||||
- **不要把新线同步进正在使用的 profile**:peer 范围可能已跨 minor,同步过去只会「静默失效」,
|
||||
同时把本机环境弄脏。保持 profile 在已验证的线上。
|
||||
|
||||
## 七、提交与分支纪律
|
||||
|
||||
- 提交信息用 `type(scope): 摘要`(`feat` / `fix` / `chore` / `docs` / `refactor`),正文说明**为什么**与验证方式。
|
||||
- 一个提交一件事;re-base 类改动拆成「rename only」+「增量移植」两个提交,便于回溯与回滚。
|
||||
- 分支命名:`sync/<上游 tag>`(跟随上游)、`backup/pre-sync-<基线>`(锚点)、其余按需。
|
||||
- 提交前:门禁三连 + 关键标记 grep + (若涉及部署)提醒 profile 重装与完整重启。
|
||||
@@ -0,0 +1,82 @@
|
||||
# apes-sidebar 项目百科
|
||||
|
||||
> DeepSeek Harness(DSH)的**服务化右侧边栏工作台** + **apes 信息层**。
|
||||
> 本项目是 [`dsh-better-sidebar`](https://github.com/omdsh-dev/DSH-better-sidebar)(MIT)的 fork,
|
||||
> 在其「文件 / 文件变动 / 任务 / 侧边对话 / 终端 / 浏览器」工作台之上,追加三个信息层标签页:
|
||||
> **智能体(agents)/ 应用(apps)/ 画像(profile)**。
|
||||
|
||||
## 一、项目速览
|
||||
|
||||
| 项 | 值 |
|
||||
| --- | --- |
|
||||
| 仓库 | `figmar/apes-sidebar`(公开) |
|
||||
| 默认分支 | `main` |
|
||||
| 许可证 | MIT(fork 自 `omdsh-dev/DSH-better-sidebar`) |
|
||||
| 语言 / 构建 | TypeScript(strict)· `tsdown`(rolldown)· `pnpm` |
|
||||
| 宿主 | DSH Desktop(Electron 壳)或 DSH Web profile |
|
||||
| 上游跟随方式 | 不合并上游提交,而是**测量式 re-base**(见 [07-开发与测试](07-开发与测试)) |
|
||||
|
||||
## 二、两条版本线(重要)
|
||||
|
||||
本仓库同时维护两条互相独立、**不能同时挂载**的版本线(两条线都注册 `ctx.betterSidebar`,
|
||||
cordis entry id 冲突,第二个会被丢弃):
|
||||
|
||||
| 分支 | 版本 | 上游基线 | peer / 宿主要求 | 状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **`main`** | 1.2.0 | `dsh-better-sidebar` **v0.19.1**(`8753096`) | `^0.1.5-rc.1`(DSH **0.1.5-rc.1+**) | 已在本机 DSH Desktop 2.0.10(`dsh 0.1.5-rc.2`)实机运行 |
|
||||
| **`sync/v0.24.1`** | 1.3.0 | `dsh-better-sidebar` **v0.24.1**(`a2751cf`) | `^0.2.0-rc.1`(DSH **0.2.0-rc.1+**) | 构建与测试全绿,**待具备 0.2.0 宿主实机验证** |
|
||||
|
||||
> ⚠️ **宿主的兼容性预检是「静默禁用」,不报错、不加载、无日志。**
|
||||
> 宿主用 `semver.satisfies(host, peerRange, { includePrerelease: true })` 判定;caret 范围跨 minor
|
||||
> 不成立(`^0.1.5-rc.1` 在 `0.2.0` 宿主上为 false),于是整行被跳过。
|
||||
> **现象 = 插件"好像没装"**。选线请先看宿主版本,见 [04-部署指南](04-部署指南)。
|
||||
|
||||
## 三、文档地图
|
||||
|
||||
| 页面 | 内容 |
|
||||
| --- | --- |
|
||||
| [01-需求分析](01-需求分析) | **官方原生 vs 装了本包**:官方右列只有「文件树 + 只读文本预览」(附官方包证据),九个 tab 是一个整体需求;7 条需求变化洞察、U1–U6、已知限制 |
|
||||
| [02-系统架构](02-系统架构) | host/client 拆分、三层接口面、面板宿主层级契约、关键不变量 |
|
||||
| [03-功能说明](03-功能说明) | 全部标签页与预览器、信息层数据来源、i18n、主题令牌、安全边界 |
|
||||
| [04-部署指南](04-部署指南) | 环境矩阵、选线、profile 接线、构建、验收清单、卸载 |
|
||||
| [05-运维手册](05-运维手册) | 版本线与分支策略、回滚剧本、发布纪律、巡检、备份铁律 |
|
||||
| [06-故障排查](06-故障排查) | 症状 → 根因 → 处置(含全部实机验证过的案例) |
|
||||
| [07-开发与测试](07-开发与测试) | 工程约定、目录、门禁、测试体系、扩展点、上游 re-base 方法 |
|
||||
|
||||
## 四、快速开始(TL;DR)
|
||||
|
||||
```bash
|
||||
# 1) 构建(Node ≥ 20,pnpm)
|
||||
pnpm install && pnpm typecheck && pnpm build && pnpm test
|
||||
|
||||
# 2) 接到 DSH profile(以桌面 profile 为例,使用 file: 链接依赖)
|
||||
# 在 ~/.dsh/profiles/desktop/package.json 中:
|
||||
# "dsh-apes-sidebar": "file:/绝对路径/dsh-apes-sidebar"
|
||||
# 并在 dsh.profile.bundles 列表里加入 "dsh-apes-sidebar"
|
||||
|
||||
# 3) 让 profile 重新安装依赖,然后 ⌘Q 完整退出 DSH 再启动
|
||||
```
|
||||
|
||||
完整步骤与坑见 [04-部署指南](04-部署指南)。
|
||||
|
||||
## 五、仓库内的事实源
|
||||
|
||||
| 位置 | 说明 |
|
||||
| --- | --- |
|
||||
| `README.md` | 面向用户的功能与安装说明(主体沿用上游文档,顶部有 fork notice) |
|
||||
| `AGENTS.md` | 面向 agent/协作者的工程约定(构建、布局、硬性规则、测试约定)⚠️ 注意:本文件当前描述的是**另一套 from-scratch 实现**,与 `main` 的 fork 树不一致,以源码为准 |
|
||||
| `docs/external-plugin-guide.md` | 插件接入指南:`ctx.betterSidebar.registerTab / registerFileViewer` 的对外契约 |
|
||||
| `docs/plans/` | 上游历史设计文档(保持上游原样,未随 fork 改名) |
|
||||
| `docs/wiki/` | **本百科的源文件**(Gitea wiki 页面的可版本化副本,由 `scripts/push-wiki.sh` 推送) |
|
||||
| `scripts/switch.sh` | 两线切换脚本(`aps` ↔ `upstream`),含 `--status` |
|
||||
|
||||
## 六、谱系说明(如实交代)
|
||||
|
||||
`figmar/apes-sidebar` 这个仓库先后承载过同一产品族的三代实现:
|
||||
|
||||
1. **v1**:早期 JS bundle 版侧边栏插件(仓库描述里提到的「可配置显示名 / 预设 feyOS」即出自那一代);
|
||||
2. **v2**:from-scratch 重写(`src/index.ts` + `src/client/index.tsx` 结构,五个 `apes:*` tab),
|
||||
即 `AGENTS.md` 所描述的那一套,其产物不在当前 `main` 上;
|
||||
3. **当前线(v0.19.1 / v0.24.1 fork)**:即 `main` 与 `sync/v0.24.1` 上的代码——本百科描述的对象。
|
||||
|
||||
若需要把本百科改写成描述 v2 重写版,请告知,源文件在 `docs/wiki/` 可直接替换。
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"id": "dsh-external/dsh-apes-sidebar",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"main": "./lib/index.js",
|
||||
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
|
||||
"engines": {
|
||||
"dsh": ">=0.1.2-rc.1"
|
||||
"dsh": ">=0.1.5-rc.1"
|
||||
},
|
||||
"contributes": {
|
||||
"tools": [],
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// ESLint flat config(最小规则集):
|
||||
// - 基础:@eslint/js recommended + typescript-eslint recommended(非 type-checked 档,
|
||||
// 全量 type-checked 需要项目服务且更慢,当前收益不足以引入);
|
||||
// - react-hooks recommended:client 侧是 React 重镇,依赖数组豁免注释
|
||||
// (react-hooks/exhaustive-deps)由此规则消费;
|
||||
// - globals 按运行域分治:client bundle → browser,host/脚本 → node,tests → 混合。
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
// 构建产物 / 工具目录不参与 lint(node_modules 与点开头目录默认也不进)。
|
||||
{
|
||||
ignores: [
|
||||
'lib/',
|
||||
'dist/',
|
||||
'coverage/',
|
||||
'registry/',
|
||||
'playwright-report/',
|
||||
'test-results/',
|
||||
'node_modules/',
|
||||
'.worktrees/',
|
||||
],
|
||||
},
|
||||
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
|
||||
// react-hooks v7 的 recommended 捆绑了 React Compiler 语义规则(purity /
|
||||
// immutability / refs / set-state-in-effect 等):本仓库对 CodeMirror / xterm
|
||||
// 等命令式集成大量依赖 render 期读 ref、effect 内同步 setState 的既有模式,
|
||||
// 57 处存量报错均为噪音级误报,先整体关闭该档,只保留经典双规则
|
||||
// (rules-of-hooks / exhaustive-deps——后者消费代码里带理由的豁免注释)。
|
||||
{
|
||||
rules: Object.fromEntries(
|
||||
[
|
||||
'config',
|
||||
'error-boundaries',
|
||||
'gating',
|
||||
'globals',
|
||||
'immutability',
|
||||
'incompatible-library',
|
||||
'preserve-manual-memoization',
|
||||
'purity',
|
||||
'refs',
|
||||
'set-state-in-effect',
|
||||
'static-components',
|
||||
'unsupported-syntax',
|
||||
'use-memo',
|
||||
].map(rule => [`react-hooks/${rule}`, 'off']),
|
||||
),
|
||||
},
|
||||
|
||||
// 孤儿 eslint-disable 注释(规则未实际触发)直接报错,防止豁免残留。
|
||||
{ linterOptions: { reportUnusedDisableDirectives: 'error' } },
|
||||
|
||||
// no-unused-vars 对齐仓库既有约定:下划线前缀 = 刻意不用的占位(mock 桩等)。
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': ['error', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
}],
|
||||
},
|
||||
},
|
||||
|
||||
// client bundle 运行在浏览器(portal 侧边栏 / 各视图 / 拦截层)。
|
||||
{
|
||||
files: ['src/client/**/*.{js,mjs,cjs,ts,tsx,jsx}'],
|
||||
languageOptions: { globals: globals.browser },
|
||||
},
|
||||
|
||||
// host 侧插件入口与构建/运维脚本跑在 Node。eslint.config.js 自身不 ignore:
|
||||
// 它就是一个普通 ESM 脚本,走 js recommended + node globals 正常通过。
|
||||
{
|
||||
files: ['src/*.{js,mjs,cjs,ts}', 'scripts/**/*.{js,mjs,cjs,ts}', '*.config.ts', 'eslint.config.js'],
|
||||
languageOptions: { globals: globals.node },
|
||||
},
|
||||
|
||||
// tests:Node 里跑(vitest / fs fixtures),jsdom 组件测试又摸浏览器全局。
|
||||
{
|
||||
files: ['tests/**/*.{js,mjs,cjs,ts,tsx,jsx}'],
|
||||
languageOptions: { globals: { ...globals.node, ...globals.browser } },
|
||||
},
|
||||
)
|
||||
+53
-36
@@ -1,18 +1,12 @@
|
||||
{
|
||||
"name": "dsh-apes-sidebar",
|
||||
"version": "1.1.0",
|
||||
"description": "APE fork of dsh-better-sidebar v0.18.0: VSCode-like right sidebar (explorer / editor / terminal / git / browser) + apes:* information-layer tabs (agents / apps / profile). Forked from omdsh-dev/DSH-better-sidebar under MIT.",
|
||||
"version": "1.2.0",
|
||||
"description": "APE fork of dsh-better-sidebar (base v0.19.1): VSCode-like right sidebar (explorer / editor / terminal / git / browser) isolated per session, plus apes:* information-layer tabs (agents / apps / profile). Forked from omdsh-dev/DSH-better-sidebar under MIT.",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/omdsh-dev/DSH-better-sidebar"
|
||||
},
|
||||
"forkedFrom": {
|
||||
"name": "dsh-better-sidebar",
|
||||
"version": "0.18.0",
|
||||
"commit": "9e1a034",
|
||||
"url": "https://github.com/omdsh-dev/DSH-better-sidebar"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
@@ -55,6 +49,7 @@
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-slots",
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar-right",
|
||||
"@deepseek-ai/dsh-client-modules"
|
||||
],
|
||||
"platform": "web"
|
||||
@@ -72,11 +67,11 @@
|
||||
"src",
|
||||
"scripts/install.sh",
|
||||
"scripts/install.ps1",
|
||||
"scripts/switch.sh",
|
||||
"cordis.patch.yml",
|
||||
"README.md",
|
||||
"README_EN.md",
|
||||
"LICENSE"
|
||||
"LICENSE",
|
||||
"scripts/switch.sh"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "node -e \"require('node:fs').rmSync('lib',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && tsdown",
|
||||
@@ -86,10 +81,12 @@
|
||||
"watch": "tsdown --watch",
|
||||
"prepare": "tsdown",
|
||||
"test": "vitest run",
|
||||
"test:windows": "vitest run --maxWorkers=1",
|
||||
"test:mount": "bash scripts/e2e-mount.sh",
|
||||
"test:mount:aggregate": "bash scripts/e2e-aggregate-mount.sh",
|
||||
"clean": "node -e \"require('node:fs').rmSync('lib',{recursive:true,force:true})\"",
|
||||
"check:consumer-types": "bash scripts/check-consumer-types.sh",
|
||||
"lint": "eslint .",
|
||||
"switch": "bash scripts/switch.sh",
|
||||
"switch:status": "bash scripts/switch.sh --status",
|
||||
"switch:aps": "bash scripts/switch.sh aps",
|
||||
@@ -97,20 +94,21 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "^4.0.2",
|
||||
"@deepseek-ai/dsh-agent": "^0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-invariants": "^0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-llm": "^0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-session": "^0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-settings": "^0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-subagent": "^0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
|
||||
"@deepseek-ai/cordis": "^4.0.4",
|
||||
"@deepseek-ai/dsh-agent": "^0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar-right": "^0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-invariants": "^0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-llm": "^0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-session": "^0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-settings": "^0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-subagent": "^0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-tools": "^0.2.0-rc.2",
|
||||
"@huanlin/dsh-plugin-better-locale": "^0.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
@@ -118,6 +116,9 @@
|
||||
"peerDependenciesMeta": {
|
||||
"@huanlin/dsh-plugin-better-locale": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-client-ui-sidebar-right": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -155,11 +156,13 @@
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-agent": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-agent-loop": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-attachment": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-client-locale": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar-right": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-code-runtime": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-host-webserver": "0.1.5-rc.2",
|
||||
@@ -167,6 +170,7 @@
|
||||
"@deepseek-ai/dsh-llm": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-scope": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-session": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-session-persistence": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-session-projection": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-settings": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-subagent": "0.1.5-rc.2",
|
||||
@@ -174,27 +178,25 @@
|
||||
"@deepseek-ai/dsh-tools": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-user-approval": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-util-time": "0.1.5-rc.2",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@huanlin/dsh-plugin-better-locale": "^0.1.0",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@shikijs/langs": "^4.3.1",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.1",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@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",
|
||||
"anser": "^2.3.5",
|
||||
"shiki": "^4.3.1",
|
||||
"@shikijs/langs": "^4.3.1",
|
||||
"cordis": "^4.0.0-rc.8",
|
||||
"eslint": "^10.9.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.12.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"katex": "^0.16.47",
|
||||
"lightningcss": "^1.32.0",
|
||||
"mdast-util-from-markdown": "^2.0.3",
|
||||
"mdast-util-gfm": "^3.1.0",
|
||||
"mdast-util-math": "^3.0.0",
|
||||
@@ -205,6 +207,21 @@
|
||||
"micromark-util-character": "^2.1.1",
|
||||
"micromark-util-classify-character": "^2.0.1",
|
||||
"micromark-util-sanitize-uri": "^2.0.1",
|
||||
"micromark-util-symbol": "^2.0.1"
|
||||
"micromark-util-symbol": "^2.0.1",
|
||||
"micromark-util-types": "^2.0.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"shiki": "^4.3.1",
|
||||
"tsdown": "^0.22.2",
|
||||
"typescript": "^5.6.0",
|
||||
"typescript-eslint": "^8.69.0",
|
||||
"unrun": "^0.2.39",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"forkedFrom": {
|
||||
"name": "dsh-better-sidebar",
|
||||
"version": "0.19.1",
|
||||
"commit": "8753096",
|
||||
"url": "https://github.com/omdsh-dev/DSH-better-sidebar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
{
|
||||
"name": "dsh-apes-sidebar",
|
||||
"version": "1.0.0",
|
||||
"description": "APE fork of dsh-better-sidebar v0.18.0: VSCode-like right sidebar (explorer / editor / terminal / git / browser) + apes:* information-layer tabs (tasks / agents / apps / messages / profile). Forked from omdsh-dev/DSH-better-sidebar under MIT.",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/omdsh-dev/DSH-better-sidebar"
|
||||
},
|
||||
"forkedFrom": {
|
||||
"name": "dsh-better-sidebar",
|
||||
"version": "0.18.0",
|
||||
"commit": "9e1a034",
|
||||
"url": "https://github.com/omdsh-dev/DSH-better-sidebar"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./client/service": {
|
||||
"types": "./lib/types/client/service.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./client/api": {
|
||||
"types": "./lib/types/client/service.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"packageManager": "pnpm@11.8.0",
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
},
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-slots",
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-modules"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/client-registry.js",
|
||||
"lib/client-terminal.js",
|
||||
"lib/client-editor.js",
|
||||
"lib/client-mermaid.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"src",
|
||||
"scripts/install.sh",
|
||||
"scripts/install.ps1",
|
||||
"scripts/switch.sh",
|
||||
"cordis.patch.yml",
|
||||
"README.md",
|
||||
"README_EN.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"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:mount": "bash scripts/e2e-mount.sh",
|
||||
"test:mount:aggregate": "bash scripts/e2e-aggregate-mount.sh",
|
||||
"clean": "node -e \"require('node:fs').rmSync('lib',{recursive:true,force:true})\"",
|
||||
"check:consumer-types": "bash scripts/check-consumer-types.sh",
|
||||
"switch": "bash scripts/switch.sh",
|
||||
"switch:status": "bash scripts/switch.sh --status",
|
||||
"switch:aps": "bash scripts/switch.sh aps",
|
||||
"switch:upstream": "bash scripts/switch.sh upstream"
|
||||
},
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "^4.0.2",
|
||||
"@deepseek-ai/dsh-agent": "^0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-session": "^0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-settings": "^0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.1.2-rc.1",
|
||||
"@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": {
|
||||
"@codemirror/commands": "^6.10.4",
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-go": "^6.0.1",
|
||||
"@codemirror/lang-html": "^6.4.12",
|
||||
"@codemirror/lang-java": "^6.0.2",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-markdown": "^6.5.2",
|
||||
"@codemirror/lang-php": "^6.0.2",
|
||||
"@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",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
"@codemirror/search": "^6.7.1",
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/view": "^6.43.8",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"clsx": "^2.1.1",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-agent": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-attachment": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-client-locale": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-code-runtime": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-host-webserver": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-invariants": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-llm": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-scope": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-session": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-session-projection": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-settings": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-subagent": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-tools": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-user-approval": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-util-time": "0.1.2-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",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
{
|
||||
"name": "dsh-apes-sidebar",
|
||||
"version": "1.0.0",
|
||||
"description": "APE fork of dsh-better-sidebar v0.18.0: VSCode-like right sidebar (explorer / editor / terminal / git / browser) + apes:* information-layer tabs (tasks / agents / apps / messages / profile). Forked from omdsh-dev/DSH-better-sidebar under MIT.",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/omdsh-dev/DSH-better-sidebar"
|
||||
},
|
||||
"forkedFrom": {
|
||||
"name": "dsh-better-sidebar",
|
||||
"version": "0.18.0",
|
||||
"commit": "9e1a034",
|
||||
"url": "https://github.com/omdsh-dev/DSH-better-sidebar"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./client/service": {
|
||||
"types": "./lib/types/client/service.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./client/api": {
|
||||
"types": "./lib/types/client/service.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"packageManager": "pnpm@11.8.0",
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
},
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-slots",
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-modules"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/client-registry.js",
|
||||
"lib/client-terminal.js",
|
||||
"lib/client-editor.js",
|
||||
"lib/client-mermaid.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"src",
|
||||
"scripts/install.sh",
|
||||
"scripts/install.ps1",
|
||||
"scripts/switch.sh",
|
||||
"cordis.patch.yml",
|
||||
"README.md",
|
||||
"README_EN.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"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:mount": "bash scripts/e2e-mount.sh",
|
||||
"test:mount:aggregate": "bash scripts/e2e-aggregate-mount.sh",
|
||||
"clean": "node -e \"require('node:fs').rmSync('lib',{recursive:true,force:true})\"",
|
||||
"check:consumer-types": "bash scripts/check-consumer-types.sh",
|
||||
"switch": "bash scripts/switch.sh",
|
||||
"switch:status": "bash scripts/switch.sh --status",
|
||||
"switch:aps": "bash scripts/switch.sh aps",
|
||||
"switch:upstream": "bash scripts/switch.sh upstream"
|
||||
},
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "^4.0.2",
|
||||
"@deepseek-ai/dsh-agent": "^0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-invariants": "^0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-llm": "^0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-session": "^0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-settings": "^0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-subagent": "^0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
|
||||
"@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": {
|
||||
"@codemirror/commands": "^6.10.4",
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-go": "^6.0.1",
|
||||
"@codemirror/lang-html": "^6.4.12",
|
||||
"@codemirror/lang-java": "^6.0.2",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-markdown": "^6.5.2",
|
||||
"@codemirror/lang-php": "^6.0.2",
|
||||
"@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",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
"@codemirror/search": "^6.7.1",
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/view": "^6.43.8",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"clsx": "^2.1.1",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-agent": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-attachment": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-client-locale": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-code-runtime": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-host-webserver": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-invariants": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-llm": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-scope": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-session": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-session-projection": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-settings": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-subagent": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-system-prompt": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-tools": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-user-approval": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-util-time": "0.1.5-rc.2",
|
||||
"@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",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
Generated
+1557
-168
File diff suppressed because it is too large
Load Diff
+7
-7
@@ -1,8 +1,8 @@
|
||||
## =============================================================================
|
||||
# dsh-better-sidebar 一键安装脚本(官方 CLI 方式,Windows PowerShell 5.1+ / pwsh)
|
||||
# dsh-apes-sidebar 一键安装脚本(官方 CLI 方式,Windows PowerShell 5.1+ / pwsh)
|
||||
#
|
||||
# 通过 DSH 官方插件命令安装 npm 包并自动挂载:
|
||||
# dsh plugin --profile web add dsh-better-sidebar@<version>
|
||||
# dsh plugin --profile web add dsh-apes-sidebar@<version>
|
||||
#
|
||||
# 包内声明了 dsh.bundle.patch(cordis.patch.yml):CLI 的 bundle 协调会把它
|
||||
# 自动加进 profile 的 dsh.profile.bundles,下次启动即挂载——无需手动写
|
||||
@@ -52,7 +52,7 @@ param(
|
||||
[string]$Profile = 'web'
|
||||
)
|
||||
|
||||
$PKG = 'dsh-better-sidebar'
|
||||
$PKG = 'dsh-apes-sidebar'
|
||||
$REGISTRY = if ($env:REGISTRY) { $env:REGISTRY } else { 'https://registry.npmjs.org' }
|
||||
|
||||
# DSH_HOME:DSH_HOME 环境变量 > %USERPROFILE% > $HOME
|
||||
@@ -116,11 +116,11 @@ if (!/^\s*allowBuilds:\s*$/m.test(t)) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!/^\s*-\s+dsh-better-sidebar\s*$/m.test(t)) {
|
||||
if (!/^\s*-\s+dsh-apes-sidebar\s*$/m.test(t)) {
|
||||
if (/^\s*minimumReleaseAgeExclude:\s*$/m.test(t)) {
|
||||
t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - dsh-better-sidebar");
|
||||
t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - dsh-apes-sidebar");
|
||||
} else {
|
||||
t += "\nminimumReleaseAgeExclude:\n - dsh-better-sidebar\n";
|
||||
t += "\nminimumReleaseAgeExclude:\n - dsh-apes-sidebar\n";
|
||||
}
|
||||
}
|
||||
if (t !== before) fs.writeFileSync(p, t);
|
||||
@@ -233,7 +233,7 @@ if ($addCode -ne 0) {
|
||||
$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 'dsh-apes-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册。'
|
||||
Warn "若上面的 pnpm 输出提示 ignored build scripts,请确认 $WS_YML 的 allowBuilds 后重跑本脚本。"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Executable
+113
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# push-wiki.sh — 把 docs/wiki/*.md 推送/更新到本仓库的 Gitea wiki
|
||||
#
|
||||
# 为什么需要它:本项目的百科源文件**版本化在仓库里**(docs/wiki/),
|
||||
# Gitea wiki 只是它的发布面。改文档 = 改仓库文件 + 跑本脚本,日志与回溯都在 git 里。
|
||||
#
|
||||
# 行为:
|
||||
# · 幂等:页面不存在则 POST 新建,已存在则 PATCH 覆盖(标题 = 文件名去掉 .md)
|
||||
# · 凭据来自 `git credential fill`(与推代码同一套),脚本自身不存任何 token
|
||||
# · 凭据经临时 netrc 文件传给 curl(600 权限,退出即删),不进入 argv
|
||||
#
|
||||
# 用法:
|
||||
# bash scripts/push-wiki.sh # 推送全部页面
|
||||
# bash scripts/push-wiki.sh 05-运维手册 # 只推指定页面(按文件名/片段匹配)
|
||||
# WIKI_REMOTE=origin bash scripts/push-wiki.sh # 指定 remote(默认 gitea,缺失则 origin)
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
WIKI_DIR="$ROOT/docs/wiki"
|
||||
FILTER="${1:-}"
|
||||
|
||||
# ---- 解析 remote / owner / repo -------------------------------------------
|
||||
pick_remote() {
|
||||
for r in "${WIKI_REMOTE:-}" gitea origin; do
|
||||
[ -z "$r" ] && continue
|
||||
if git remote get-url "$r" >/dev/null 2>&1; then echo "$r"; return 0; fi
|
||||
done
|
||||
echo "无法找到可用的 git remote(试过: ${WIKI_REMOTE:-<空>} gitea origin)" >&2
|
||||
return 1
|
||||
}
|
||||
REMOTE="$(pick_remote)"
|
||||
URL="$(git remote get-url "$REMOTE")"
|
||||
case "$URL" in
|
||||
https://*) ;;
|
||||
*) echo "本脚本只支持 https remote(当前: $URL)" >&2; exit 1 ;;
|
||||
esac
|
||||
HOST="$(printf '%s' "$URL" | sed -E 's#^https://([^/]+)/.*#\1#')"
|
||||
SLUG="$(printf '%s' "$URL" | sed -E 's#^https://[^/]+/##; s#\.git$##')"
|
||||
OWNER="${SLUG%%/*}"
|
||||
REPO="${SLUG#*/}"
|
||||
API="https://$HOST/api/v1/repos/$OWNER/$REPO/wiki"
|
||||
|
||||
echo "wiki 目标 : $OWNER/$REPO @ $HOST (remote: $REMOTE)"
|
||||
echo "源目录 : docs/wiki"
|
||||
echo
|
||||
|
||||
# ---- 凭据 -----------------------------------------------------------------
|
||||
CRED="$(printf 'protocol=https\nhost=%s\n\n' "$HOST" | git credential fill 2>/dev/null || true)"
|
||||
USER="$(printf '%s\n' "$CRED" | sed -n 's/^username=//p')"
|
||||
PASS="$(printf '%s\n' "$CRED" | sed -n 's/^password=//p')"
|
||||
if [ -z "$PASS" ]; then
|
||||
echo "未从 git credential helper 取到 $HOST 的凭据(先确保 git push 能成功)" >&2
|
||||
exit 1
|
||||
fi
|
||||
NETRC="$(mktemp)"
|
||||
chmod 600 "$NETRC"
|
||||
trap 'rm -f "$NETRC"' EXIT
|
||||
printf 'machine %s\nlogin %s\npassword %s\n' "$HOST" "$USER" "$PASS" > "$NETRC"
|
||||
|
||||
json_body() { # $1 = file
|
||||
python3 - "$1" <<'PY'
|
||||
import base64, json, sys, pathlib
|
||||
p = pathlib.Path(sys.argv[1])
|
||||
print(json.dumps({
|
||||
"title": p.stem,
|
||||
"content_base64": base64.b64encode(p.read_text(encoding="utf-8").encode("utf-8")).decode("ascii"),
|
||||
}, ensure_ascii=False))
|
||||
PY
|
||||
}
|
||||
|
||||
api() { # $1 = method, $2 = path, $3 = body(optional) -> 输出 HTTP 码
|
||||
local method="$1" path="$2" body="${3:-}"
|
||||
if [ -n "$body" ]; then
|
||||
curl -s --netrc-file "$NETRC" -o /tmp/push-wiki-resp.json -w '%{http_code}' \
|
||||
-X "$method" -H 'Content-Type: application/json' --data-binary "$body" "$API$path"
|
||||
else
|
||||
curl -s --netrc-file "$NETRC" -o /tmp/push-wiki-resp.json -w '%{http_code}' -X "$method" "$API$path"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- 逐页推送 -------------------------------------------------------------
|
||||
shopt -s nullglob
|
||||
files=("$WIKI_DIR"/*.md)
|
||||
[ ${#files[@]} -gt 0 ] || { echo "docs/wiki 下没有 .md 文件" >&2; exit 1; }
|
||||
|
||||
ok=0; fail=0
|
||||
for f in "${files[@]}"; do
|
||||
title="$(basename "$f" .md)"
|
||||
if [ -n "$FILTER" ] && [[ "$title" != *"$FILTER"* ]]; then continue; fi
|
||||
body="$(json_body "$f")"
|
||||
|
||||
code="$(api POST /new "$body")"
|
||||
if [ "$code" = "201" ]; then
|
||||
echo " + $title (新建)"; ok=$((ok+1)); continue
|
||||
fi
|
||||
enc="$(python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1], safe=""))' "$title")"
|
||||
code="$(api PATCH "/page/$enc" "$body")"
|
||||
if [ "$code" = "200" ]; then
|
||||
echo " ~ $title (更新)"; ok=$((ok+1))
|
||||
else
|
||||
echo " ! $title 失败(HTTP $code): $(head -c 200 /tmp/push-wiki-resp.json 2>/dev/null)" >&2
|
||||
fail=$((fail+1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "完成:成功 $ok 页,失败 $fail 页"
|
||||
[ "$fail" -eq 0 ] || exit 1
|
||||
echo "查看: https://$HOST/$OWNER/$REPO/wiki"
|
||||
+3
-3
@@ -158,9 +158,9 @@ async function classifyTarget(raw: string, cwd: string): Promise<{ kind: AgentOp
|
||||
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)}`)
|
||||
if (code === 'ENOENT') throw new Error(`"${raw}" does not exist (resolved to "${target}")`, { cause: error })
|
||||
if (code === 'EACCES' || code === 'EPERM') throw new Error(`"${target}" is not readable`, { cause: error })
|
||||
throw new Error(`cannot open "${target}": ${error instanceof Error ? error.message : String(error)}`, { cause: error })
|
||||
}
|
||||
const title = basenameOf(target)
|
||||
return { kind: info.isDirectory() ? 'folder' : 'file', target, title: title === '' ? raw : title }
|
||||
|
||||
+146
-30
@@ -134,6 +134,13 @@ export interface AgentTerminalSnapshot {
|
||||
exitCode?: number | null
|
||||
/** Exit signal name if the process was killed by a signal; null otherwise. */
|
||||
exitSignal?: string | null
|
||||
|
||||
/**
|
||||
* The model's active `terminal_wait_for` on this terminal (the sidebar
|
||||
* renders the wait banner from it). Present only while a wait is
|
||||
* registered; carries the LATEST wait when several overlap.
|
||||
*/
|
||||
waiting?: { needle: string; since: number }
|
||||
}
|
||||
|
||||
/** Map a POSIX signal number to its conventional name (best-effort). */
|
||||
@@ -149,22 +156,56 @@ function signalNameOf(signal: number | null | undefined): string | null {
|
||||
return SIGNAL_NAMES[signal] ?? `signal ${signal}`
|
||||
}
|
||||
|
||||
/** Locate the first occurrence of `needle` in `transcript`, returning its line/column. */
|
||||
function locateNeedle(transcript: string, needle: string): { line: number; column: number } | undefined {
|
||||
/**
|
||||
* Compile a wait needle into a RegExp. The needle is treated as a JavaScript
|
||||
* regular expression; a pattern that fails to compile ( e.g. an unbalanced
|
||||
* group typed as a literal ) degrades to verbatim substring matching so
|
||||
* legacy literal needles keep working.
|
||||
*/
|
||||
function compileNeedle(needle: string): RegExp | null {
|
||||
try {
|
||||
return new RegExp(needle)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the first occurrence of `needle` in `transcript`, returning its
|
||||
* line/column plus the actual matched text — for alternation patterns
|
||||
* ( e.g. `(BUILD_OK|BUILD_FAIL)` ) the match tells which alternative hit.
|
||||
* `re` is the precompiled form of `needle` (from {@link compileNeedle});
|
||||
* `null` means verbatim substring matching.
|
||||
*/
|
||||
function locateNeedle(
|
||||
transcript: string,
|
||||
needle: string,
|
||||
re: RegExp | null,
|
||||
): { line: number; column: number; match: string } | undefined {
|
||||
if (needle === '') return undefined
|
||||
const idx = transcript.indexOf(needle)
|
||||
if (idx === -1) return undefined
|
||||
// Regex hit → use the match index/text; literal fallback → indexOf with
|
||||
// the needle itself as the matched text.
|
||||
let hit: { index: number; text: string } | undefined
|
||||
if (re !== null) {
|
||||
re.lastIndex = 0
|
||||
const m = re.exec(transcript)
|
||||
hit = m === null ? undefined : { index: m.index, text: m[0] }
|
||||
} else {
|
||||
const idx = transcript.indexOf(needle)
|
||||
hit = idx === -1 ? undefined : { index: idx, text: needle }
|
||||
}
|
||||
if (hit === undefined) return undefined
|
||||
// Walk the transcript up to the match index, counting newlines to derive
|
||||
// the 0-based line; the column is the offset within that line.
|
||||
let line = 0
|
||||
let lineStart = 0
|
||||
for (let i = 0; i < idx; i += 1) {
|
||||
for (let i = 0; i < hit.index; i += 1) {
|
||||
if (transcript.charCodeAt(i) === 0x0a /* \n */) {
|
||||
line += 1
|
||||
lineStart = i + 1
|
||||
}
|
||||
}
|
||||
return { line, column: idx - lineStart }
|
||||
return { line, column: hit.index - lineStart, match: hit.text }
|
||||
}
|
||||
|
||||
/** One live agent terminal. */
|
||||
@@ -189,6 +230,8 @@ export interface AgentTerminalHandle {
|
||||
exitCode?: number | null
|
||||
/** Exit signal number once known (POSIX only; undefined on Windows). */
|
||||
exitSignal?: number | null
|
||||
/** Active wait_for registrations (skip bookkeeping; empty while idle). */
|
||||
waits: AgentTerminalActiveWait[]
|
||||
}
|
||||
|
||||
/** Read result shape (mirrors the official tool-pty terminal_read contract). */
|
||||
@@ -203,17 +246,32 @@ export interface AgentTerminalReadResult {
|
||||
lineEnd: number
|
||||
}
|
||||
|
||||
/** One active wait_for registration on a handle (banner + skip bookkeeping). */
|
||||
export interface AgentTerminalActiveWait {
|
||||
/** The needle being awaited (shown on the sidebar wait banner). */
|
||||
needle: string
|
||||
/** Epoch ms when the wait registered (age display / debugging). */
|
||||
since: number
|
||||
/** Flipped by `skipWait()`; the waiting poll returns `skipped` within one tick. */
|
||||
skipped: boolean
|
||||
}
|
||||
|
||||
/** Outcome of {@link AgentPtyRegistry.waitFor}. */
|
||||
export type AgentTerminalWaitResult =
|
||||
| {
|
||||
/** The needle was found in the transcript. */
|
||||
kind: 'found'
|
||||
/** The matched substring. */
|
||||
/** The pattern that was awaited. */
|
||||
needle: string
|
||||
/** 0-based line index (in the retained transcript) where the needle first appeared. */
|
||||
line: number
|
||||
/** 0-based column index within that line where the match starts. */
|
||||
column: number
|
||||
/**
|
||||
* The text that actually matched — for multi-outcome patterns
|
||||
* ( e.g. `(BUILD_OK|BUILD_FAIL)` ) this tells which alternative matched.
|
||||
*/
|
||||
match: string
|
||||
/** Elapsed wall-clock milliseconds from the wait start to the match. */
|
||||
elapsedMs: number
|
||||
}
|
||||
@@ -237,6 +295,12 @@ export type AgentTerminalWaitResult =
|
||||
/** The exit signal name, if the process was killed by a signal. */
|
||||
exitSignal?: string | null
|
||||
}
|
||||
| {
|
||||
/** The user skipped the wait from the sidebar banner. */
|
||||
kind: 'skipped'
|
||||
/** The needle that was awaited. */
|
||||
needle: string
|
||||
}
|
||||
|
||||
/** Snapshot projection of a handle (drops the pty reference and transcript). */
|
||||
export function snapshotOf(handle: AgentTerminalHandle): AgentTerminalSnapshot {
|
||||
@@ -250,6 +314,8 @@ export function snapshotOf(handle: AgentTerminalHandle): AgentTerminalSnapshot {
|
||||
out.exitCode = handle.exitCode ?? null
|
||||
out.exitSignal = signalNameOf(handle.exitSignal)
|
||||
}
|
||||
const active = handle.waits.at(-1)
|
||||
if (active !== undefined) out.waiting = { needle: active.needle, since: active.since }
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -310,6 +376,7 @@ export class AgentPtyRegistry {
|
||||
pty,
|
||||
transcript: '',
|
||||
exited: false,
|
||||
waits: [],
|
||||
}
|
||||
pty.onData((data) => {
|
||||
handle.transcript += data
|
||||
@@ -456,10 +523,16 @@ export class AgentPtyRegistry {
|
||||
* make event-driven wakeups unreliable. A 50ms poll is fast enough for
|
||||
* interactive use and simple enough to be obviously correct.
|
||||
* @param uuid - terminal to watch.
|
||||
* @param needle - substring to search for (case-sensitive, verbatim).
|
||||
* @param needle - JavaScript regular expression to search for
|
||||
* (case-sensitive); a pattern that fails to compile falls back to
|
||||
* verbatim substring matching. May cover several outcomes at once
|
||||
* ( e.g. `(BUILD_OK|BUILD_FAIL)` for build success vs failure ) — the
|
||||
* returned `match` reports the text that actually matched, so callers
|
||||
* can tell which outcome hit.
|
||||
* @param timeoutMs - max wait; default 10000 (10s). Clamped to ≥100ms.
|
||||
* @param signal - caller-owned cancellation; aborts the wait re-throwing.
|
||||
* @returns one of `found` / `timeout` / `exited`.
|
||||
* A wait can also be skipped by the user from the sidebar banner (`skipWait`), which resolves it with `{kind:'skipped'}`.
|
||||
* @returns one of `found` / `timeout` / `exited` / `skipped`.
|
||||
*/
|
||||
async waitFor(
|
||||
uuid: string,
|
||||
@@ -474,39 +547,82 @@ export class AgentPtyRegistry {
|
||||
const timeout = Math.max(100, Math.floor(timeoutMs))
|
||||
const start = Date.now()
|
||||
const deadline = start + timeout
|
||||
// Compile the needle once (regex semantics; invalid patterns degrade to
|
||||
// verbatim matching) and reuse the compiled form across every poll.
|
||||
const re = compileNeedle(needle)
|
||||
// Fast path: already exited, or the needle is already in the transcript
|
||||
// (a `terminal_send` may have produced the expected output before this
|
||||
// call even started).
|
||||
if (handle.exited) {
|
||||
return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
|
||||
}
|
||||
const firstHit = locateNeedle(handle.transcript, needle)
|
||||
const firstHit = locateNeedle(handle.transcript, needle, re)
|
||||
if (firstHit !== undefined) {
|
||||
return { kind: 'found', needle, line: firstHit.line, column: firstHit.column, elapsedMs: Date.now() - start }
|
||||
return { kind: 'found', needle, line: firstHit.line, column: firstHit.column, match: firstHit.match, elapsedMs: Date.now() - start }
|
||||
}
|
||||
// Poll loop: check the transcript every 50ms, exit on match / exit /
|
||||
// abort / timeout. The handle is read live each iteration (its transcript
|
||||
// and exited fields mutate as the pty produces output).
|
||||
while (true) {
|
||||
if (signal?.aborted) signal.throwIfAborted()
|
||||
if (handle.exited) {
|
||||
return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
|
||||
// Register the active wait so the sidebar can show the wait banner (the
|
||||
// snapshot's `waiting` field rides the agent-terminals push). Registered
|
||||
// only after the fast paths: a wait that resolves instantly never
|
||||
// flashes the banner. The notify() makes every push subscriber (the
|
||||
// sidebar view) converge on the new waiting state immediately.
|
||||
const record: AgentTerminalActiveWait = { needle, since: start, skipped: false }
|
||||
handle.waits.push(record)
|
||||
this.notify()
|
||||
try {
|
||||
// Exit outranks a skip in the same tick: `skipWait` flips `record.skipped`
|
||||
// and the next poll observes it, but a pty that died first is the
|
||||
// objective fact (a skip is moot once the process is gone). The user
|
||||
// sees the same three things either way — the banner clears, `skipWait`
|
||||
// reports 0 for the resolved wait, and the tool result says the wait
|
||||
// ended — only the reported `kind` differs (see the design doc's
|
||||
// implementation-deviation record).
|
||||
while (true) {
|
||||
if (signal?.aborted) signal.throwIfAborted()
|
||||
if (handle.exited) {
|
||||
return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
|
||||
}
|
||||
if (record.skipped) {
|
||||
return { kind: 'skipped', needle }
|
||||
}
|
||||
const hit = locateNeedle(handle.transcript, needle, re)
|
||||
if (hit !== undefined) {
|
||||
return { kind: 'found', needle, line: hit.line, column: hit.column, match: hit.match, elapsedMs: Date.now() - start }
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
return { kind: 'timeout', needle, timeoutMs: timeout, totalLines: handle.transcript.split('\n').length }
|
||||
}
|
||||
await new Promise(resolve => {
|
||||
const t = setTimeout(resolve, 50)
|
||||
// Allow the Node process to exit even if the timer is pending.
|
||||
if (typeof t === 'object' && 'unref' in t) (t as { unref: () => void }).unref()
|
||||
})
|
||||
}
|
||||
const hit = locateNeedle(handle.transcript, needle)
|
||||
if (hit !== undefined) {
|
||||
return { kind: 'found', needle, line: hit.line, column: hit.column, elapsedMs: Date.now() - start }
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
return { kind: 'timeout', needle, timeoutMs: timeout, totalLines: handle.transcript.split('\n').length }
|
||||
}
|
||||
await new Promise(resolve => {
|
||||
const t = setTimeout(resolve, 50)
|
||||
// Allow the Node process to exit even if the timer is pending.
|
||||
if (typeof t === 'object' && 'unref' in t) (t as { unref: () => void }).unref()
|
||||
})
|
||||
} finally {
|
||||
const index = handle.waits.indexOf(record)
|
||||
if (index !== -1) handle.waits.splice(index, 1)
|
||||
this.notify()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark every active wait on one terminal as skipped (the sidebar banner's
|
||||
* skip button). Each waiting poll loop observes its record's flag within
|
||||
* one 50ms tick and returns `{kind:'skipped'}`. Idempotent: 0 when nothing
|
||||
* is waiting (a stale banner racing a wait that already resolved).
|
||||
* @returns the number of waits that transitioned to skipped.
|
||||
*/
|
||||
skipWait(uuid: string): number {
|
||||
const handle = this.expect(uuid)
|
||||
let count = 0
|
||||
for (const record of handle.waits) {
|
||||
if (!record.skipped) {
|
||||
record.skipped = true
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a POSIX signal to a terminal's foreground process.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Process-local live assistant stream buffer (DSH 0.1.5+).
|
||||
*
|
||||
* 0.1.2 appended a durable `assistant/chunk` session event for every model
|
||||
* delta, so the side-chat transcript and the inherited in-progress snapshot
|
||||
* could read streaming text straight out of the session log. 0.1.5 removed
|
||||
* that event: an in-flight attempt now publishes `agent/assistant-stream`
|
||||
* frames (start / chunk / end) that are NOT part of the log, and the durable
|
||||
* record lands only at settlement — `assistant/message` (with the exact
|
||||
* `stream` embedded) or `assistant/attempt` (a failed attempt that committed
|
||||
* no message, also with its `stream`).
|
||||
*
|
||||
* This module folds those frames into a bounded per-session buffer of
|
||||
* normalized chunks — the same information the old `assistant/chunk` events
|
||||
* carried — so the plugin keeps a live transcript and an honest in-progress
|
||||
* snapshot. The buffer is cleared when an attempt ends, so a settled step
|
||||
* never duplicates its durable message.
|
||||
*/
|
||||
import type { Context } from './context-types.ts'
|
||||
|
||||
/** One normalized live delta, keyed by its attempt and dense position. */
|
||||
export interface AssistantLiveChunk {
|
||||
/** The attempt these chunks belong to (DSH `LlmAttemptId`). */
|
||||
readonly attemptId: string
|
||||
/** The turn the attempt belongs to. */
|
||||
readonly turn: number
|
||||
/** The step the attempt belongs to. */
|
||||
readonly step: number
|
||||
/** Dense zero-based position within the attempt (DSH's own index). */
|
||||
readonly index: number
|
||||
/** Safe-integer timestamp of the frame (reused by the durable stream). */
|
||||
readonly time: number
|
||||
/** The raw model stream chunk (`text-delta` / `reasoning-delta` / …). */
|
||||
readonly chunk: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** The plugin's read face over the live frames of every session. */
|
||||
export interface AssistantLiveBuffer {
|
||||
/**
|
||||
* The live chunks of one session's active attempt, in index order.
|
||||
* @param sessionId - the session whose attempt is streaming.
|
||||
* @returns the chunks; empty when nothing is streaming for that session.
|
||||
*/
|
||||
chunksFor(sessionId: string): readonly AssistantLiveChunk[]
|
||||
/** Stop observing the agent stream. */
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
/** Per-attempt buffer ceiling; beyond it the oldest deltas are dropped. */
|
||||
export const LIVE_CHUNK_CAP = 4000
|
||||
|
||||
/** How many sessions may hold a live buffer at once (the oldest is dropped). */
|
||||
export const LIVE_SESSION_CAP = 64
|
||||
|
||||
/** One session's active attempt as it accumulates. */
|
||||
interface Attempt {
|
||||
readonly attemptId: string
|
||||
readonly turn: number
|
||||
readonly step: number
|
||||
readonly chunks: AssistantLiveChunk[]
|
||||
/** The next dense frame index this attempt accepts (independent of the
|
||||
* window below, which drops the oldest chunks once the cap is reached). */
|
||||
nextIndex: number
|
||||
}
|
||||
|
||||
/** A frame's `chunk` payload must be a JSON record to be worth buffering. */
|
||||
function chunkOf(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: undefined
|
||||
}
|
||||
|
||||
/** A finite non-negative integer, or undefined. */
|
||||
function countOf(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold `agent/assistant-stream` frames into per-session live buffers.
|
||||
*
|
||||
* Frames arrive for every attached agent, so the buffer is keyed by the
|
||||
* emitting session and ignores anything it cannot identify. An out-of-order
|
||||
* or mismatched chunk drops the attempt rather than splicing a gap: a
|
||||
* transcript with a hole is worse than one that settles at the next durable
|
||||
* event.
|
||||
* @param ctx - host plugin context (its `on` subscribes the session feed).
|
||||
* @param cap - per-attempt chunk ceiling.
|
||||
* @returns the read face and a disposer unbinding the listener.
|
||||
*/
|
||||
export function createAssistantLiveBuffer(ctx: Context, cap: number = LIVE_CHUNK_CAP): AssistantLiveBuffer {
|
||||
const attempts = new Map<string, Attempt>()
|
||||
const off = ctx.on('agent/assistant-stream', (payload) => {
|
||||
const record = payload as { agent?: unknown; frame?: unknown } | null
|
||||
const frame = record?.frame as Record<string, unknown> | undefined
|
||||
if (frame === undefined) return
|
||||
const session = (record?.agent as { session?: { id?: unknown } } | undefined)?.session
|
||||
const sessionId = typeof session?.id === 'string' ? session.id : undefined
|
||||
if (sessionId === undefined) return
|
||||
const type = frame['type']
|
||||
if (type === 'end') {
|
||||
attempts.delete(sessionId)
|
||||
return
|
||||
}
|
||||
const attemptId = typeof frame['attemptId'] === 'string' ? frame.attemptId : undefined
|
||||
if (attemptId === undefined) return
|
||||
if (type === 'start') {
|
||||
const turn = countOf(frame['turn'])
|
||||
const step = countOf(frame['step'])
|
||||
if (turn === undefined || step === undefined) return
|
||||
const known = attempts.has(sessionId)
|
||||
attempts.delete(sessionId)
|
||||
// Only a NEW session may evict the oldest tracked one.
|
||||
if (!known && attempts.size >= LIVE_SESSION_CAP) {
|
||||
const oldest = attempts.keys().next().value
|
||||
if (oldest !== undefined) attempts.delete(oldest)
|
||||
}
|
||||
attempts.set(sessionId, { attemptId, turn, step, chunks: [], nextIndex: 0 })
|
||||
return
|
||||
}
|
||||
if (type !== 'chunk') return
|
||||
const attempt = attempts.get(sessionId)
|
||||
if (attempt === undefined || attempt.attemptId !== attemptId) return
|
||||
const index = countOf(frame['index'])
|
||||
const chunk = chunkOf(frame['chunk'])
|
||||
// Dense positions only: a gap means the buffer missed frames (a
|
||||
// replacement attempt, a reconnect) and its text would be wrong.
|
||||
if (index === undefined || chunk === undefined || index !== attempt.nextIndex) {
|
||||
attempts.delete(sessionId)
|
||||
return
|
||||
}
|
||||
attempt.nextIndex = index + 1
|
||||
const time = countOf(frame['time']) ?? 0
|
||||
attempt.chunks.push({ attemptId, turn: attempt.turn, step: attempt.step, index, time, chunk })
|
||||
if (attempt.chunks.length > cap) attempt.chunks.splice(0, attempt.chunks.length - cap)
|
||||
})
|
||||
return {
|
||||
chunksFor: sessionId => attempts.get(sessionId)?.chunks ?? [],
|
||||
dispose: () => { off() },
|
||||
}
|
||||
}
|
||||
+78
-3
@@ -8,12 +8,13 @@
|
||||
* very content it shows. Rendering goes through the shared {@link DiffFiles}
|
||||
* renderer — the same one the changes tab's inline preview uses.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SessionScope } from './api.ts'
|
||||
import { api } from './api.ts'
|
||||
import type { SidebarDiffRef } from './state.ts'
|
||||
import { DiffFiles } from './diff/DiffFiles.tsx'
|
||||
import { displayPath, foldRowsFromContents, type DiffFile, type DiffRow, type FoldSegment } from './diff/rows.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { resolveSidebarPath } from './produced-files.ts'
|
||||
import css from './sidebar.module.css'
|
||||
@@ -30,6 +31,11 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [data, setData] = useState<DiffData | null>(null)
|
||||
const [tick, setTick] = useState(0)
|
||||
// The staged flag of the side ACTUALLY rendered: when the requested side's
|
||||
// diff came back empty the load falls back to the other side, and the fold
|
||||
// expansion must read that side's revisions (else the sliced line numbers
|
||||
// land on the wrong contents).
|
||||
const [effectiveStaged, setEffectiveStaged] = useState<boolean | null>(null)
|
||||
|
||||
const refresh = useCallback((): void => { setTick(value => value + 1) }, [])
|
||||
|
||||
@@ -39,6 +45,7 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setData(null)
|
||||
setEffectiveStaged(null)
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
if (diff.kind === 'commit') {
|
||||
@@ -53,7 +60,10 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
|
||||
// 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, diff.worktree)
|
||||
if (other.diff !== '') result = other
|
||||
if (other.diff !== '') {
|
||||
result = other
|
||||
if (!cancelled) setEffectiveStaged(!diff.staged)
|
||||
}
|
||||
}
|
||||
if (result.diff !== '') {
|
||||
if (!cancelled) setData({ diff: result.diff })
|
||||
@@ -82,6 +92,71 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
|
||||
return () => { cancelled = true }
|
||||
}, [sessionId, cwd, diff, tick])
|
||||
|
||||
// ── On-demand git fold expansion: a fold's hidden rows come from both
|
||||
// sides' full contents (git.show / fsRead), fetched ONCE per file so
|
||||
// sibling folds share the request, then sliced by each fold's line
|
||||
// ranges. The cache dies with the ref or a refresh tick. ──────────────
|
||||
const foldContents = useRef(new Map<string, Promise<{ old: string; new: string }>>())
|
||||
useEffect(() => { foldContents.current = new Map() }, [diff, tick])
|
||||
const foldLoader = useMemo(() => {
|
||||
const sidesOf = (file: DiffFile): Promise<{ old: string; new: string }> => {
|
||||
// Both sides empty cannot cover a non-empty fold — treat it as a failed
|
||||
// fetch so the fold degrades to the unavailable marker instead of
|
||||
// silently expanding to nothing (the symptom of a bad rev or path
|
||||
// reading null on both sides).
|
||||
const ofSides = (oldContent: string | null, newContent: string | null): { old: string; new: string } => {
|
||||
if ((oldContent ?? '') === '' && (newContent ?? '') === '') throw new Error('no content on either side')
|
||||
return { old: oldContent ?? '', new: newContent ?? '' }
|
||||
}
|
||||
const fetchSides = async (): Promise<{ old: string; new: string }> => {
|
||||
const scope: SessionScope = { sessionId, cwd, ...(diff.repoRoot !== undefined ? { repoRoot: diff.repoRoot } : {}) }
|
||||
if (diff.kind === 'commit') {
|
||||
// The patch's -m --first-parent shape: old side from the parent,
|
||||
// new side from the commit (a root commit's parent read fails → '').
|
||||
const [oldSide, newSide] = await Promise.all([
|
||||
file.oldPath === '/dev/null'
|
||||
? Promise.resolve({ content: null })
|
||||
: api.gitShow(scope, `${diff.hashFull}^`, displayPath(file.oldPath), diff.worktree),
|
||||
file.newPath === '/dev/null'
|
||||
? Promise.resolve({ content: null })
|
||||
: api.gitShow(scope, diff.hashFull, displayPath(file.newPath), diff.worktree),
|
||||
])
|
||||
return ofSides(oldSide.content, newSide.content)
|
||||
}
|
||||
// Worktree change: staged is HEAD vs index, unstaged is index vs
|
||||
// worktree (the worktree side reads the live file).
|
||||
const staged = effectiveStaged ?? diff.staged
|
||||
if (staged) {
|
||||
const [oldSide, newSide] = await Promise.all([
|
||||
file.oldPath === '/dev/null'
|
||||
? Promise.resolve({ content: null })
|
||||
: api.gitShow(scope, 'HEAD', displayPath(file.oldPath), diff.worktree),
|
||||
file.newPath === '/dev/null'
|
||||
? Promise.resolve({ content: null })
|
||||
: api.gitShow(scope, ':0', displayPath(file.newPath), diff.worktree),
|
||||
])
|
||||
return ofSides(oldSide.content, newSide.content)
|
||||
}
|
||||
const [oldSide, worktree] = await Promise.all([
|
||||
file.oldPath === '/dev/null'
|
||||
? Promise.resolve({ content: null })
|
||||
: api.gitShow(scope, ':0', displayPath(file.oldPath), diff.worktree),
|
||||
api.fsRead(scope, resolveSidebarPath(diff.repoRoot ?? diff.worktree ?? cwd, displayPath(file.newPath))).catch(() => null),
|
||||
])
|
||||
return ofSides(oldSide.content, worktree !== null && worktree.kind === 'text' ? worktree.content : null)
|
||||
}
|
||||
const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath)
|
||||
let promise = foldContents.current.get(path)
|
||||
if (promise === undefined) {
|
||||
promise = fetchSides()
|
||||
foldContents.current.set(path, promise)
|
||||
}
|
||||
return promise
|
||||
}
|
||||
return (file: DiffFile, segment: FoldSegment): Promise<readonly DiffRow[]> =>
|
||||
sidesOf(file).then(sides => foldRowsFromContents(segment, sides.old, sides.new))
|
||||
}, [sessionId, cwd, diff, effectiveStaged])
|
||||
|
||||
return (
|
||||
<div className={css.gitDiffTab}>
|
||||
<div className={css.gitDiffTabHeader}>
|
||||
@@ -104,7 +179,7 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
|
||||
<>
|
||||
{data.untracked !== undefined
|
||||
? <DiffFiles diff="" untrackedPath={diff.kind === 'worktree' ? diff.path : ''} untrackedContent={data.untracked} />
|
||||
: <DiffFiles diff={data.diff} />}
|
||||
: <DiffFiles diff={data.diff} resolveFold={foldLoader} />}
|
||||
{data.diff === '' && data.untracked === undefined && (
|
||||
<div className={css.gitEmpty}>{t('diffEmpty')}</div>
|
||||
)}
|
||||
|
||||
@@ -40,8 +40,9 @@ import { TreePanel } from './TreePanel.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { resolveSidebarPath } from './produced-files.ts'
|
||||
import { closePathTabs, retargetPathTabs } from './tree-mutations.ts'
|
||||
import type { EditorToolbarControls, EditorToolbarState, FileViewerDescriptor } from './service.ts'
|
||||
import { firstLeaf, insertLeafAt, leafWithTab, mintTabId, treeOf, type SidebarStore, type SidebarTab } from './state.ts'
|
||||
import { firstLeaf, insertLeafAt, leafWithTab, mintTabId, type SidebarStore, type SidebarTab } from './state.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
type EditorLoad =
|
||||
@@ -177,8 +178,7 @@ export function EditorHost(props: {
|
||||
*/
|
||||
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 pane = leafWithTab(state.bottomSplits, tab.id) ?? firstLeaf(state.bottomSplits)
|
||||
const fresh: SidebarTab = {
|
||||
id: mintTabId(),
|
||||
type: 'editor',
|
||||
@@ -186,8 +186,8 @@ export function EditorHost(props: {
|
||||
path: absolute,
|
||||
meta: { treeOpen: false },
|
||||
}
|
||||
const { node, leafId } = insertLeafAt(state[key], pane.id, 'row', fresh, false)
|
||||
return { ...state, [key]: node, activePane: leafId }
|
||||
const { node, leafId } = insertLeafAt(state.bottomSplits, pane.id, 'row', fresh, false)
|
||||
return { ...state, bottomSplits: node, activePane: leafId }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -225,6 +225,16 @@ export function EditorHost(props: {
|
||||
})
|
||||
}
|
||||
|
||||
// Tree mutations reconcile the OPEN tabs (both split trees, the bottom
|
||||
// panel, free windows): a rename retargets its tab to the new path; a
|
||||
// delete closes tabs at or under the removed path. See tree-mutations.ts.
|
||||
const onPathRenamed = (oldPath: string, newPath: string): void => {
|
||||
retargetPathTabs(ctx, store, oldPath, newPath)
|
||||
}
|
||||
const onPathDeleted = (path: string): void => {
|
||||
closePathTabs(ctx, store, path)
|
||||
}
|
||||
|
||||
// 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).
|
||||
@@ -338,6 +348,9 @@ export function EditorHost(props: {
|
||||
}
|
||||
apply(planFirstMatch(ctx.get('betterSidebar')?.matchFileViewer(path), mediaUrlOf))
|
||||
return () => { cancelled = true; controller.abort() }
|
||||
// The deps are deliberately granular: the scope object's identity churns,
|
||||
// only its sessionId / cwd fields gate the (re)fetch.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scope.sessionId, scope.cwd, path, ctx, showEmpty, isDir, reloadSeq])
|
||||
|
||||
// Save-then-refresh in preview mode (issue #167 part C): the edge into
|
||||
@@ -386,6 +399,9 @@ export function EditorHost(props: {
|
||||
onOpenWith={openWith}
|
||||
onToggleOpenWithPin={toggleOpenWithPin}
|
||||
onReferenceFile={onReferenceFile}
|
||||
onPathRenamed={onPathRenamed}
|
||||
onPathDeleted={onPathDeleted}
|
||||
service={ctx.get('betterSidebar')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -467,7 +483,7 @@ export function EditorHost(props: {
|
||||
? <FenceErrorNotice store={store} onDisabled={() => { setReloadSeq(sequence => sequence + 1) }} />
|
||||
: <div className={css.editorError}>{load.message}</div>)}
|
||||
{!showEmpty && load.status === 'binary' && <BinaryDownload scope={scope} path={path} />}
|
||||
{!showEmpty && load.status === 'ready' && createElement(load.viewer.component, {
|
||||
{!showEmpty && load.status === 'ready' && load.viewer.component !== undefined && createElement(load.viewer.component, {
|
||||
ctx, store, scope, path, title,
|
||||
viewerId: load.viewer.id,
|
||||
content: load.content,
|
||||
@@ -508,6 +524,9 @@ export function EditorHost(props: {
|
||||
onOpenWith={openWith}
|
||||
onToggleOpenWithPin={toggleOpenWithPin}
|
||||
onReferenceFile={onReferenceFile}
|
||||
onPathRenamed={onPathRenamed}
|
||||
onPathDeleted={onPathDeleted}
|
||||
service={ctx.get('betterSidebar')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+248
-21
@@ -24,17 +24,21 @@ import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type
|
||||
import { createPortal } from 'react-dom'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconChevronRightOutline14, IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16,
|
||||
IconLinkOutline16, Menu, type MenuEntry, type MenuItem, writeClipboard,
|
||||
Button, IconChevronRightOutline14, IconCloseFill14, IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16,
|
||||
IconEditOutline16, IconLinkOutline16, IconTrashOutline16, Menu, Modal, 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 { VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc'
|
||||
import { api, downloadUrl, isOutsideWorkspaceMessage, type FsEntry } from './api.ts'
|
||||
import { FenceErrorNotice } from './FenceErrorNotice.tsx'
|
||||
import { builtinFileIcon, builtinFolderIcon } from './file-icons.tsx'
|
||||
import { IconUploadOutline16, IconVscode16 } from './icons.tsx'
|
||||
import { isImeComposition } from './ime-guard.ts'
|
||||
import { useSubmenuFlip } from './menu-flip.ts'
|
||||
import type { OpenWithTarget } from './open-with.ts'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { t } from './locales.ts'
|
||||
import type { BetterSidebarService } from './service.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
import { uploadItemsFromDrop, uploadItemsFromFiles, type UploadItem } from './upload.ts'
|
||||
import css from './sidebar.module.css'
|
||||
@@ -133,20 +137,65 @@ export function FileTree(props: {
|
||||
onToggleOpenWithPin?: (targetId: string) => void
|
||||
/** Insert `@<relative path>` into the composer draft (file vs directory). */
|
||||
onReferenceFile: (path: string, isDir: boolean) => void
|
||||
/** A rename landed (old row path → new path): the caller retargets open tabs. */
|
||||
onPathRenamed?: (oldPath: string, newPath: string) => void
|
||||
/** A delete landed: the caller closes tabs at or under the removed path. */
|
||||
onPathDeleted?: (path: string, isDir: boolean) => 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
|
||||
/**
|
||||
* The sidebar registry service: when present, externally registered file
|
||||
* icons (`registerFileIcon`) outrank the host's file-type artwork on file rows.
|
||||
* Absent → the built-ins alone (the host always passes it today).
|
||||
*/
|
||||
service?: BetterSidebarService
|
||||
}) {
|
||||
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, refreshTick, onUploadRequest, busy } = props
|
||||
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, onPathRenamed, onPathDeleted, refreshTick, onUploadRequest, busy, service } = props
|
||||
const [data, setData] = useState<Record<string, LevelData>>({})
|
||||
/**
|
||||
* Registry revision for the file-icon feature: bumps on ANY registry
|
||||
* change (register/dispose of tabs, viewers, or icons — one listener
|
||||
* set) so rows re-resolve their icons. The value itself is unread; the
|
||||
* state bump IS the re-render trigger.
|
||||
*/
|
||||
const [, setIconsVersion] = useState(0)
|
||||
useEffect(
|
||||
() => service?.subscribe(() => { setIconsVersion(version => version + 1) }),
|
||||
[service],
|
||||
)
|
||||
/**
|
||||
* One file row's leading glyph. The service resolver owns the whole
|
||||
* chain (registered specific ext → built-in glyph map → registered
|
||||
* global default → stock VscFile, with per-factory crash isolation);
|
||||
* without a service the built-in map alone applies.
|
||||
*/
|
||||
const fileRowIcon = (path: string): ReactNode =>
|
||||
service !== undefined ? service.fileIcon(path, 14) : builtinFileIcon(path, 14)
|
||||
|
||||
/**
|
||||
* One directory row's leading glyph: the registered `'folder'` /
|
||||
* `'folder-open'` icon when present, else the built-in VSCodicons glyphs.
|
||||
*/
|
||||
const dirRowIcon = (path: string, open: boolean): ReactNode =>
|
||||
service !== undefined ? service.folderIcon(path, open, 14) : builtinFolderIcon(open, 14)
|
||||
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)
|
||||
// The row menu's "open with" submenu is the one submenu that can tower past
|
||||
// the viewport; publish its flip geometry for layout.css while it is open.
|
||||
useSubmenuFlip(rowMenu)
|
||||
/** The row being renamed inline: its path plus the edit buffer. */
|
||||
const [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null)
|
||||
/** The delete awaiting the confirmation modal's yes. */
|
||||
const [confirmDelete, setConfirmDelete] = useState<{ path: string; isDir: boolean; name: string } | null>(null)
|
||||
/** The last mutation failure (dismissable strip above the tree). */
|
||||
const [actionError, setActionError] = useState<string | 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). */
|
||||
@@ -268,6 +317,63 @@ export function FileTree(props: {
|
||||
loadDir(dir)
|
||||
}, [loadDir])
|
||||
|
||||
/**
|
||||
* Settle the tree after one rename/delete landed at `prefix`: drop every
|
||||
* cached level at or under the old path, collapse the now-stale expanded
|
||||
* directories below it, and reload the immediate parent so the new shape
|
||||
* shows without a full refresh tick.
|
||||
*/
|
||||
const pruneTree = (prefix: string): void => {
|
||||
const parent = parentOf(prefix)
|
||||
const under = (key: string): boolean => key === prefix || key.startsWith(`${prefix}/`) || key.startsWith(`${prefix}\\`)
|
||||
for (const key of Object.keys(dataRef.current)) {
|
||||
if (under(key)) delete dataRef.current[key]
|
||||
}
|
||||
if (parent !== prefix) delete dataRef.current[parent]
|
||||
setData({ ...dataRef.current })
|
||||
for (const dir of expanded) {
|
||||
if (under(dir)) onToggle(dir)
|
||||
}
|
||||
if (parent !== prefix) retryDir(parent)
|
||||
}
|
||||
|
||||
/** Client-side twin of the server's name rule (the server re-validates). */
|
||||
const validRename = (name: string): boolean =>
|
||||
name !== '' && name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\')
|
||||
|
||||
/** Commit the inline rename: trim, no-op guard, then fs.rename + settle. */
|
||||
const commitRename = (path: string, raw: string): void => {
|
||||
setRenaming(null)
|
||||
const name = raw.trim()
|
||||
if (cwd === undefined || name === baseName(path) || !validRename(name)) {
|
||||
if (name !== baseName(path) && !validRename(name)) setActionError(t('renameInvalid'))
|
||||
return
|
||||
}
|
||||
api.fsRename({ sessionId, cwd }, path, name)
|
||||
.then((result) => {
|
||||
setActionError(null)
|
||||
pruneTree(path)
|
||||
onPathRenamed?.(path, result.path)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setActionError(error instanceof Error ? error.message : String(error))
|
||||
})
|
||||
}
|
||||
|
||||
/** Run the confirmed delete: fs.remove + settle + close affected tabs. */
|
||||
const performDelete = (target: { path: string; isDir: boolean }): void => {
|
||||
if (cwd === undefined) return
|
||||
api.fsRemove({ sessionId, cwd }, target.path)
|
||||
.then(() => {
|
||||
setActionError(null)
|
||||
pruneTree(target.path)
|
||||
onPathDeleted?.(target.path, target.isDir)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setActionError(error instanceof Error ? error.message : String(error))
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -373,13 +479,14 @@ export function FileTree(props: {
|
||||
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. */
|
||||
* reveal gets the folder glyph, custom editors a generic code mark.
|
||||
* The compact menu's icon slot is 14px, so every mark renders at 14. */
|
||||
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} />
|
||||
if (target.kind === 'reveal') return <VscFolderOpened size={14} />
|
||||
if (target.id === 'vscode') return <IconVscode16 size={14} />
|
||||
if (target.id === 'cursor') return <SiCursor size={14} />
|
||||
if (target.id === 'zed') return <SiZedindustries size={14} />
|
||||
return <IconCodeOutline16 size={14} />
|
||||
}
|
||||
const pinned = openWithTargets
|
||||
.filter(target => pinnedIds.includes(target.id))
|
||||
@@ -412,7 +519,7 @@ export function FileTree(props: {
|
||||
onToggleOpenWithPin?.(target.id)
|
||||
}}
|
||||
>
|
||||
{pinnedNow ? <VscPinned size={14} /> : <VscPin size={14} />}
|
||||
{pinnedNow ? <VscPinned size={12} /> : <VscPin size={12} />}
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
@@ -433,7 +540,7 @@ export function FileTree(props: {
|
||||
<IconChevronRightOutline14 size={14} className={css.openWithChevron} aria-hidden />
|
||||
</span>
|
||||
),
|
||||
icon: <VscLinkExternal size={16} />,
|
||||
icon: <VscLinkExternal size={14} />,
|
||||
submenu,
|
||||
},
|
||||
]
|
||||
@@ -447,6 +554,58 @@ export function FileTree(props: {
|
||||
const expandedSet = useMemo(() => new Set(expanded), [expanded])
|
||||
const revealedSet = useMemo(() => new Set(revealed), [revealed])
|
||||
|
||||
/**
|
||||
* Focus + select the rename editor once, on mount. The callback identity
|
||||
* must stay STABLE (useCallback []): an inline arrow re-runs on every
|
||||
* render (detach null → attach el), and focus()/select() mid-keystroke
|
||||
* would reselect the whole buffer while typing. A plain <input>, not the
|
||||
* primitives Input — that one forwards no ref, and focus+select is the
|
||||
* entire point here.
|
||||
*/
|
||||
const renameInputRef = useCallback((el: HTMLInputElement | null): void => {
|
||||
if (el !== null) {
|
||||
el.focus()
|
||||
el.select()
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** The inline rename editor replacing one row (dirs and files alike):
|
||||
* same indent/icon/height for a seamless swap, Enter/blur commits,
|
||||
* Escape cancels, IME composition keys never reach the handlers (the
|
||||
* shared isImeComposition guard). */
|
||||
const renderRenameRow = (entry: FsEntry, depth: number): ReactNode => (
|
||||
<div
|
||||
key={entry.path}
|
||||
className={clsx(css.explorerRow, css.explorerRenaming)}
|
||||
style={{ paddingLeft: depth * 22 + 6 }}
|
||||
>
|
||||
{entry.isDir
|
||||
? dirRowIcon(entry.path, expandedSet.has(entry.path))
|
||||
: fileRowIcon(entry.path)}
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
className={css.explorerRenameInput}
|
||||
value={renaming?.value ?? ''}
|
||||
aria-label={t('rename')}
|
||||
spellCheck={false}
|
||||
onChange={(event) => {
|
||||
setRenaming(prev => prev === null ? prev : { ...prev, value: event.target.value })
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (isImeComposition(event)) return
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
commitRename(entry.path, renaming?.value ?? '')
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
setRenaming(null)
|
||||
}
|
||||
}}
|
||||
onBlur={() => { commitRename(entry.path, renaming?.value ?? '') }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderLevel = (dir: string, depth: number): ReactNode => {
|
||||
const level = data[dir]
|
||||
if (level === undefined) {
|
||||
@@ -471,6 +630,10 @@ export function FileTree(props: {
|
||||
}
|
||||
const entries = level.entries ?? []
|
||||
return entries.map(entry => {
|
||||
// The row being renamed renders as its editor (no button semantics:
|
||||
// an editor is not a click target — and a nested interactive inside
|
||||
// role="button" would be invalid anyway).
|
||||
if (renaming?.path === entry.path) return renderRenameRow(entry, depth)
|
||||
if (entry.isDir) {
|
||||
const isOpen = expandedSet.has(entry.path)
|
||||
return (
|
||||
@@ -496,7 +659,7 @@ export function FileTree(props: {
|
||||
onDrop={(event) => { handleDirDrop(event, entry.path) }}
|
||||
onContextMenu={(event) => { openRowMenu(event, entry.path, true) }}
|
||||
>
|
||||
{isOpen ? <VscFolderOpened size={14} /> : <VscFolder size={14} />}
|
||||
{dirRowIcon(entry.path, isOpen)}
|
||||
<span className={css.explorerName}>{entry.name}</span>
|
||||
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
|
||||
{rowActions(entry)}
|
||||
@@ -529,7 +692,7 @@ export function FileTree(props: {
|
||||
onDrop={(event) => { handleFileDrop(event, entry.path) }}
|
||||
onContextMenu={(event) => { openRowMenu(event, entry.path, false) }}
|
||||
>
|
||||
<VscFile size={14} />
|
||||
{fileRowIcon(entry.path)}
|
||||
<span className={css.explorerName}>{entry.name}</span>
|
||||
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
|
||||
{rowActions(entry)}
|
||||
@@ -551,6 +714,22 @@ export function FileTree(props: {
|
||||
<div className={css.explorerEmpty}>{t('noSession')}</div>
|
||||
) : (
|
||||
<>
|
||||
{/* The last mutation failure (rename/delete): dismissable, raw
|
||||
server text — the same show-the-truth policy the fence notice
|
||||
uses. Any later action or a fresh attempt clears it. */}
|
||||
{actionError !== null && (
|
||||
<div className={css.explorerActionError} role="alert">
|
||||
<span className={css.explorerActionErrorText}>{actionError}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.explorerActionErrorClose}
|
||||
aria-label={t('dismiss')}
|
||||
onClick={() => { setActionError(null) }}
|
||||
>
|
||||
<IconCloseFill14 />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={clsx(css.explorerRow, dropTarget === root && css.explorerRowDropTarget)}
|
||||
style={{ paddingLeft: 6 }}
|
||||
@@ -558,7 +737,7 @@ export function FileTree(props: {
|
||||
onDrop={(event) => { handleDirDrop(event, root) }}
|
||||
onContextMenu={(event) => { openRowMenu(event, root, true) }}
|
||||
>
|
||||
<VscFolderOpened size={14} />
|
||||
{dirRowIcon(root, true)}
|
||||
<span className={css.explorerName}>{baseName(root)}</span>
|
||||
{copiedPath === root
|
||||
? <span className={css.explorerCopied}>{t('copied')}</span>
|
||||
@@ -650,22 +829,31 @@ export function FileTree(props: {
|
||||
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} /> }]
|
||||
? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: <IconCodeOutline16 size={14} /> }]
|
||||
: []),
|
||||
...(rowMenu?.isDir === false && onOpenFileSide !== undefined
|
||||
? [{ id: 'open-side', label: t('openFileSide'), icon: <VscFolderOpened size={16} /> }]
|
||||
? [{ id: 'open-side', label: t('openFileSide'), icon: <VscFolderOpened size={14} /> }]
|
||||
: []),
|
||||
...openWithEntries(),
|
||||
// Download applies to files only (the host route refuses directories).
|
||||
...(rowMenu?.isDir === false
|
||||
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={16} /> }]
|
||||
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={14} /> }]
|
||||
: []),
|
||||
// Upload into a directory (incl. the workspace root row).
|
||||
...(rowMenu?.isDir === true
|
||||
? [{ id: 'upload-here', label: t('uploadHere'), icon: <IconUploadOutline16 size={16} /> }]
|
||||
? [{ id: 'upload-here', label: t('uploadHere'), icon: <IconUploadOutline16 size={14} /> }]
|
||||
: []),
|
||||
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
|
||||
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
|
||||
// Explorer mutations close the menu; the workspace ROOT row is the
|
||||
// session itself — never renamable or deletable (server double-guards).
|
||||
...(rowMenu !== null && rowMenu.path !== cwd
|
||||
? [
|
||||
{ id: 'mutate-sep', type: 'separator' } as MenuEntry,
|
||||
{ id: 'rename', label: t('rename'), icon: <IconEditOutline16 size={14} /> },
|
||||
{ id: 'delete', label: t('delete'), icon: <IconTrashOutline16 size={14} />, danger: true },
|
||||
]
|
||||
: []),
|
||||
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={16} /> },
|
||||
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={16} /> },
|
||||
]}
|
||||
onSelect={(id) => {
|
||||
const target = rowMenu
|
||||
@@ -692,16 +880,55 @@ export function FileTree(props: {
|
||||
fileInputRef.current?.click()
|
||||
return
|
||||
}
|
||||
if (id === 'rename') {
|
||||
setRenaming({ path: target.path, value: baseName(target.path) })
|
||||
return
|
||||
}
|
||||
if (id === 'delete') {
|
||||
setConfirmDelete({ path: target.path, isDir: target.isDir, name: baseName(target.path) })
|
||||
return
|
||||
}
|
||||
copyPath(
|
||||
id === 'relative' ? relativeTo(cwd ?? '', target.path) : target.path,
|
||||
target.path,
|
||||
)
|
||||
}}
|
||||
portal
|
||||
compact
|
||||
align="start"
|
||||
getAnchorRect={() => (rowMenu === null ? null : new DOMRect(rowMenu.x, rowMenu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
/>
|
||||
|
||||
{/* The delete confirmation: destructive and permanent (no host trash),
|
||||
so it always lands here first — the same Cancel/Confirm shape the
|
||||
git lens uses for discard/revert/cherry-pick. */}
|
||||
<Modal
|
||||
open={confirmDelete !== null}
|
||||
onClose={() => { setConfirmDelete(null) }}
|
||||
title={confirmDelete === null ? '' : t('deleteTitle', { name: confirmDelete.name })}
|
||||
closeLabel={t('cancel')}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" onClick={() => { setConfirmDelete(null) }}>{t('cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
const pending = confirmDelete
|
||||
if (pending === null) return
|
||||
setConfirmDelete(null)
|
||||
performDelete(pending)
|
||||
}}
|
||||
>
|
||||
{t('delete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<p className={css.explorerConfirmDesc}>
|
||||
{confirmDelete === null ? '' : t(confirmDelete.isDir ? 'deleteDescDir' : 'deleteDescFile')}
|
||||
</p>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,337 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* The one HTML-preview frame both preview surfaces render.
|
||||
*
|
||||
* Why this is not a plain `src` to the `/sidebar/html` route: on DSH Desktop
|
||||
* the host answers 403 `forbidden` to every request that does not carry the
|
||||
* Electron renderer capability (`x-dsh-desktop-renderer`), and the shell
|
||||
* attaches that header only to MAIN-frame and SAME-ORIGIN-subframe traffic
|
||||
* (`requestComesFromRendererOrigin`). A frame sandboxed WITHOUT
|
||||
* allow-same-origin is an opaque origin at request time, so its document
|
||||
* request never carries the capability and the preview shows nothing but the
|
||||
* bare refusal.
|
||||
*
|
||||
* So the document is fetched by the MAIN frame — which does carry it — and
|
||||
* handed to the frame through `srcdoc`. The `sandbox` attribute stays exactly
|
||||
* as before: the preview still runs in an opaque origin, and the route's own
|
||||
* workspace fence still bounds what can be fetched.
|
||||
*
|
||||
* Two consequences worth knowing:
|
||||
* - sandboxed: an opaque origin cannot make network requests either, so a
|
||||
* self-contained document previews completely while a multi-file one shows
|
||||
* its markup with the relative assets missing (inlining them is a separate
|
||||
* job, not this component's);
|
||||
* - unsandboxed (the warned per-surface unlock, or the setting): the srcdoc
|
||||
* frame inherits the GUI's origin — which is precisely what that escape
|
||||
* hatch already meant — and there its relative references resolve and load
|
||||
* like any other same-origin request.
|
||||
*
|
||||
* A fetch that fails hands the frame the route URL instead, so the route's
|
||||
* own answer (its JSON error, or the refusal) stays visible rather than being
|
||||
* replaced by a blank pane.
|
||||
*/
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { HTML_IFRAME_SANDBOX, withPreviewBase } from './html-preview.ts'
|
||||
|
||||
/** One preview frame. */
|
||||
export function HtmlPreviewFrame(props: {
|
||||
/** The `/sidebar/html/<sessionId>/<path…>` route URL of the SAVED file. */
|
||||
url: string
|
||||
/** Whether the opaque-origin sandbox applies (the default for both surfaces). */
|
||||
sandboxed: boolean
|
||||
className?: string
|
||||
title: string
|
||||
}): ReactNode {
|
||||
const { url, sandboxed, className, title } = props
|
||||
const [doc, setDoc] = useState<string | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
setDoc(null)
|
||||
setFailed(false)
|
||||
void fetch(url, { signal: controller.signal })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error(String(response.status))
|
||||
return response.text()
|
||||
})
|
||||
.then((text) => { setDoc(text) })
|
||||
.catch((error: unknown) => {
|
||||
// An abort is this effect's own cleanup, not a failure.
|
||||
if ((error as { name?: string }).name !== 'AbortError') setFailed(true)
|
||||
})
|
||||
return () => { controller.abort() }
|
||||
}, [url])
|
||||
// The delivery path depends only on what we have: the fetched markup, the
|
||||
// route's own answer after a failed fetch, or an empty frame while loading.
|
||||
const delivery = doc !== null
|
||||
? { srcDoc: withPreviewBase(doc, url) }
|
||||
: failed ? { src: url } : { srcDoc: '' }
|
||||
return (
|
||||
<iframe
|
||||
{...delivery}
|
||||
className={className}
|
||||
sandbox={sandboxed ? HTML_IFRAME_SANDBOX : undefined}
|
||||
referrerPolicy="no-referrer"
|
||||
allow=""
|
||||
title={title}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+17
-13
@@ -10,32 +10,31 @@
|
||||
* 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.
|
||||
* open in a new tab with noopener, and local media goes through the
|
||||
* session-scoped `/sidebar/file` media route — markdown-syntax image
|
||||
* destinations are rewritten via `markdown-images.ts` and `src` attributes
|
||||
* through `resolveLocalMediaDest` (the same trust fence).
|
||||
*/
|
||||
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 { resolveLocalMediaDest, rewriteLocalImageUrls } from './markdown-images.ts'
|
||||
import {
|
||||
analyzeHtmlSegment,
|
||||
type AnalyzedMarkdownHtml,
|
||||
} from './markdown-html.ts'
|
||||
import { splitMermaidBlocks, type MermaidMarkdownProps } from './mermaid-blocks.ts'
|
||||
import { splitMermaidBlocks } 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,
|
||||
)
|
||||
* legacy no-HTML preview path in TextEditor. Defined in mermaid-lazy.tsx (a
|
||||
* light module) so core-bundle consumers can import the stub without
|
||||
* dragging DOMPurify along. */
|
||||
export { LazyMermaidMarkdown } from './mermaid-lazy.tsx'
|
||||
import { LazyMermaidMarkdown } from './mermaid-lazy.tsx'
|
||||
|
||||
/** Everything the sanitizers need to resolve local media + scope the route. */
|
||||
export interface MarkdownHtmlMedia {
|
||||
@@ -235,7 +234,12 @@ export function MarkdownDocument({ info, media, codeLabels }: MarkdownDocumentPr
|
||||
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}`
|
||||
const raw = defs === '' ? segment.text : `${segment.text}\n\n${defs}`
|
||||
// MarkdownText drops non-http(s) image destinations (chat-security
|
||||
// stance), so rewrite local ones into /sidebar/file media URLs first —
|
||||
// the same trust fence the sanitized HTML leaves below go through.
|
||||
// Idempotent: already-absolute media URLs pass through untouched.
|
||||
const text = rewriteLocalImageUrls(raw, media.scope, media.path, media.origin)
|
||||
return {
|
||||
kind: 'markdown',
|
||||
text,
|
||||
|
||||
@@ -40,6 +40,9 @@ export function PdfView(props: { scope: SessionScope; path: string; title: strin
|
||||
controller.abort()
|
||||
if (objectUrl !== undefined) URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
// Granular scope fields: the scope object's identity churns, only its
|
||||
// sessionId / cwd fields gate the fetch.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* 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
|
||||
* viewer/editor shows a strip inside ITS OWN pane; the workbench shell, the
|
||||
* other tabs, and the panel itself stay alive (issue #31 — a tab crash
|
||||
* must never take down the whole sidebar).
|
||||
*
|
||||
|
||||
@@ -54,13 +54,9 @@ import clsx from 'clsx'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
clampWidthPercent,
|
||||
TITLE_BAR_STRIP_MAX,
|
||||
TITLE_BAR_STRIP_MIN,
|
||||
WIDTH_PERCENT_MAX,
|
||||
WIDTH_PERCENT_MIN,
|
||||
type SidebarPrefs,
|
||||
type TitleBarScheme,
|
||||
} from '../prefs-shared.ts'
|
||||
import { api } from './api.ts'
|
||||
import { parsePrefs } from './prefs.ts'
|
||||
@@ -573,7 +569,6 @@ export function SettingsBody(props: {
|
||||
*/
|
||||
export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
const [prefs, setPrefs] = useState<SidebarPrefs>(() => store.getPrefs())
|
||||
const [widthDraft, setWidthDraft] = useState<string>(String(store.getPrefs().defaultWidthPercent))
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// Which feature's secondary settings popup is open (null = closed).
|
||||
const [settingsFor, setSettingsFor] = useState<TabDescriptor | FileViewerDescriptor | null>(null)
|
||||
@@ -625,9 +620,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
if (cancelled) return
|
||||
revisionRef.current = view.revision
|
||||
if (dirtyRef.current) return
|
||||
const next = parsePrefs(view.value)
|
||||
setPrefs(next)
|
||||
setWidthDraft(String(next.defaultWidthPercent))
|
||||
setPrefs(parsePrefs(view.value))
|
||||
}).catch(() => { /* the store's defaults stay authoritative */ })
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
@@ -658,9 +651,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
|
||||
/** Settle one commit: success adopts the server values, failure reverts. */
|
||||
const applyOutcome = (previous: SidebarPrefs, outcome: { ok: boolean; prefs: SidebarPrefs }): void => {
|
||||
const settled = outcome.ok ? outcome.prefs : previous
|
||||
setPrefs(settled)
|
||||
setWidthDraft(String(settled.defaultWidthPercent))
|
||||
setPrefs(outcome.ok ? outcome.prefs : previous)
|
||||
}
|
||||
|
||||
/** Optimistically apply one pref patch, then commit (revert on failure). */
|
||||
@@ -673,10 +664,6 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
void commit(patch).then(outcome => applyOutcome(previous, outcome))
|
||||
}
|
||||
|
||||
const onToggle = (next: boolean): void => {
|
||||
applyPref({ openByDefault: next })
|
||||
}
|
||||
|
||||
/** Flip one per-tab enable switch (merge into the tabsEnabled map). */
|
||||
const onToggleTab = (id: string, next: boolean): void => {
|
||||
applyPref({ tabsEnabled: { ...optimisticRef.current.tabsEnabled, [id]: next } })
|
||||
@@ -779,20 +766,6 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
return raw
|
||||
}
|
||||
|
||||
const commitWidth = (): void => {
|
||||
const parsed = Number(widthDraft)
|
||||
if (!Number.isFinite(parsed)) {
|
||||
setWidthDraft(String(prefs.defaultWidthPercent))
|
||||
return
|
||||
}
|
||||
const clamped = clampWidthPercent(parsed)
|
||||
const previous = prefs
|
||||
setPrefs({ ...previous, defaultWidthPercent: clamped })
|
||||
setWidthDraft(String(clamped))
|
||||
setError(null)
|
||||
void commit({ defaultWidthPercent: clamped }).then(outcome => applyOutcome(previous, outcome))
|
||||
}
|
||||
|
||||
/**
|
||||
* One SMALL toggle card for the responsive inventory grid: the card's main
|
||||
* area is the switch (click to flips, visual state IS the state), the icon
|
||||
@@ -868,51 +841,6 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
{/* 常规: the DSH settings-row recipe — title/desc left, control right. */}
|
||||
<div className={css.group}>
|
||||
<div className={css.groupHeading}>{t('settingsGeneralTitle')}</div>
|
||||
<div className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsOpenTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsOpenDesc')}</span>
|
||||
</span>
|
||||
<Switch
|
||||
label={t('settingsOpenTitle')}
|
||||
checked={prefs.openByDefault}
|
||||
onChange={onToggle}
|
||||
/>
|
||||
</div>
|
||||
<div className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsWidthTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsWidthDesc')}</span>
|
||||
</span>
|
||||
<span className={css.control}>
|
||||
<Input
|
||||
type="number"
|
||||
className={css.percentInput}
|
||||
value={widthDraft}
|
||||
min={WIDTH_PERCENT_MIN}
|
||||
max={WIDTH_PERCENT_MAX}
|
||||
step={1}
|
||||
aria-label={t('settingsWidthTitle')}
|
||||
onChange={event => { setWidthDraft(event.currentTarget.value) }}
|
||||
onBlur={commitWidth}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur()
|
||||
}}
|
||||
/>
|
||||
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsOpenPathTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsOpenPathDesc')}</span>
|
||||
</span>
|
||||
<Switch
|
||||
label={t('settingsOpenPathTitle')}
|
||||
checked={prefs.interceptOpenPath}
|
||||
onChange={(next) => { applyPref({ interceptOpenPath: next }) }}
|
||||
/>
|
||||
</div>
|
||||
<div className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsOpenToolsTitle')}</span>
|
||||
@@ -947,9 +875,11 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
...getShellPresets().map(preset => ({
|
||||
value: `preset:${preset.id}`,
|
||||
title: preset.title,
|
||||
// The preset desc is i18n-friendly (string or () => string)
|
||||
// — resolve it like every other settings text here.
|
||||
desc: preset.detect?.(detectedEnv) === true
|
||||
? `${preset.desc}(${t('settingsSchemeDetectedSuffix')})`
|
||||
: preset.desc,
|
||||
? `${textOf(preset.desc)}(${t('settingsSchemeDetectedSuffix')})`
|
||||
: textOf(preset.desc),
|
||||
})),
|
||||
{ value: 'custom', title: t('settingsSchemeCustomTitle'), desc: t('settingsSchemeCustomDesc') },
|
||||
]}
|
||||
|
||||
+46
-15
@@ -58,6 +58,7 @@ import {
|
||||
sideThreadRows,
|
||||
threadHasCompletedTurn,
|
||||
threadTrailingPending,
|
||||
type SidechatLiveEvent,
|
||||
type SidechatThreadInfo,
|
||||
} from '../sidechat-core.ts'
|
||||
import {
|
||||
@@ -69,6 +70,7 @@ import {
|
||||
type SidechatTranscriptRow,
|
||||
} from './sidechat-transcript.ts'
|
||||
import { api } from './api.ts'
|
||||
import { usePolling } from './use-polling.ts'
|
||||
import { t } from './locales.ts'
|
||||
import type { SessionScope } from './api.ts'
|
||||
import type { SidebarTab } from './state.ts'
|
||||
@@ -111,6 +113,8 @@ const inFlightStarts = new Set<string>()
|
||||
* the afterSeq delta and never re-download what they already hold). */
|
||||
interface ThreadCache {
|
||||
entries: SidebarHistoryEntry[]
|
||||
/** The CURRENT attempt's live deltas (replaced every pull, never merged). */
|
||||
live: SidechatLiveEvent[]
|
||||
}
|
||||
|
||||
/** Row-render labels (locale-dependent, memoized once per mount). */
|
||||
@@ -382,7 +386,7 @@ export function SideChatView(props: {
|
||||
const [info, setInfo] = useState<SidechatThreadInfo | null>(null)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
const cacheRef = useRef<ThreadCache>({ entries: [] })
|
||||
const cacheRef = useRef<ThreadCache>({ entries: [], live: [] })
|
||||
// The previous poll's rows (see the mapping's reuse pass below).
|
||||
const prevRowsRef = useRef<SidechatTranscriptRow[]>([])
|
||||
const controllerRef = useRef<AbortController | null>(null)
|
||||
@@ -456,14 +460,18 @@ export function SideChatView(props: {
|
||||
try {
|
||||
const cache = cacheRef.current
|
||||
const afterSeq = cache.entries.at(-1)?.event.seq
|
||||
const { events } = await api.sidechatEvents(childId, afterSeq, controller.signal)
|
||||
const { events, live } = await api.sidechatEvents(childId, afterSeq, controller.signal)
|
||||
// The live set is the CURRENT attempt, so it replaces whatever the
|
||||
// previous pull held; a settled step drops out of the host buffer and
|
||||
// its durable assistant/message takes over in the mapping.
|
||||
cache.live = live ?? []
|
||||
if (events.length > 0) {
|
||||
// Wire events arrive as parsed JSON; the mirror narrows data to the
|
||||
// record the mapping reads.
|
||||
const incoming = events.map(event => ({ event: event as SidebarSessionEvent }))
|
||||
cache.entries = mergeBySeq(cache.entries, incoming)
|
||||
setRevision(value => value + 1)
|
||||
}
|
||||
setRevision(value => value + 1)
|
||||
} catch {
|
||||
// Aborted by a newer pull or a wire failure: keep the last rows.
|
||||
}
|
||||
@@ -481,7 +489,7 @@ export function SideChatView(props: {
|
||||
// Reset the transcript cache whenever the binding changes, then focus
|
||||
// the composer — it owns the first message of a fresh thread.
|
||||
useEffect(() => {
|
||||
cacheRef.current = { entries: [] }
|
||||
cacheRef.current = { entries: [], live: [] }
|
||||
prevRowsRef.current = []
|
||||
controllerRef.current?.abort()
|
||||
setError(null)
|
||||
@@ -493,17 +501,25 @@ export function SideChatView(props: {
|
||||
}
|
||||
}, [threadId, fetchInfo])
|
||||
|
||||
// Poll while the tab is visible and the thread runs.
|
||||
// One transcript pull on every input change (attach, visibility flip,
|
||||
// run-state flip — the last one catches a thread's terminal state once it
|
||||
// stops running).
|
||||
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])
|
||||
// `running` is not read here, but re-triggering this pull on run-state
|
||||
// flips is load-bearing (see above); the badge fetch rides the ticks.
|
||||
}, [visible, threadId, running, fetchThread])
|
||||
|
||||
// Poll while the tab is visible and the thread runs: transcript deltas +
|
||||
// badge refresh on a fixed cadence. Each pull self-guards (fetchThread
|
||||
// aborts its predecessor; a late settle keeps the last rows).
|
||||
const pollTick = useCallback(async (): Promise<void> => {
|
||||
if (threadId === undefined) return
|
||||
void fetchThread(threadId)
|
||||
void fetchInfo(threadId)
|
||||
}, [threadId, fetchThread, fetchInfo])
|
||||
usePolling(visible && running && threadId !== undefined, pollTick, { intervalMs: POLL_MS })
|
||||
|
||||
useEffect(() => () => { controllerRef.current?.abort() }, [])
|
||||
|
||||
@@ -523,8 +539,24 @@ export function SideChatView(props: {
|
||||
// The previous poll's rows ride into the mapping so unchanged rows keep
|
||||
// their object identity (see reuseRows): the 2s poll re-renders only the
|
||||
// changed tail instead of re-parsing markdown for the whole transcript.
|
||||
// The live deltas are appended AFTER the durable events (they are the
|
||||
// in-flight tail), and a delta whose step already settled durably is
|
||||
// dropped: the durable message is authoritative and would otherwise render
|
||||
// twice.
|
||||
const rows = useMemo(() => {
|
||||
const next = threadId === undefined ? [] : transcriptRows(cacheRef.current.entries, prevRowsRef.current)
|
||||
const cache = cacheRef.current
|
||||
const settled = new Set(
|
||||
cache.entries
|
||||
.filter(entry => entry.event.type === 'assistant/message')
|
||||
.map(entry => {
|
||||
const data = entry.event.data as { turn?: unknown; step?: unknown }
|
||||
return `${String(data.turn)}:${String(data.step)}`
|
||||
}),
|
||||
)
|
||||
const live = cache.live
|
||||
.filter(event => !settled.has(`${String(event.data.turn)}:${String(event.data.step)}`))
|
||||
.map(event => ({ event: event as SidebarSessionEvent }))
|
||||
const next = threadId === undefined ? [] : transcriptRows([...cache.entries, ...live], prevRowsRef.current)
|
||||
prevRowsRef.current = next
|
||||
return next
|
||||
},
|
||||
@@ -574,7 +606,6 @@ export function SideChatView(props: {
|
||||
}
|
||||
}
|
||||
return items
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [threads])
|
||||
|
||||
const growComposer = (): void => {
|
||||
@@ -688,7 +719,7 @@ export function SideChatView(props: {
|
||||
)}
|
||||
items={menuItems}
|
||||
selectedId={threadId}
|
||||
onSelect={(id) => { id === '$new' ? openNewThread() : openExistingThread(id) }}
|
||||
onSelect={(id) => { if (id === '$new') openNewThread(); else openExistingThread(id) }}
|
||||
onClose={() => { setMenuOpen(false) }}
|
||||
align="end"
|
||||
portal
|
||||
|
||||
+228
-1226
File diff suppressed because it is too large
Load Diff
+26
-37
@@ -53,6 +53,7 @@ import {
|
||||
type TreeJob,
|
||||
} from './subagent-jobs.ts'
|
||||
import { api, type JobOutputResult } from './api.ts'
|
||||
import { usePolling } from './use-polling.ts'
|
||||
import { IconStopOutline16 } from './icons.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import css from './SubagentView.module.css'
|
||||
@@ -180,52 +181,30 @@ function SubagentLiveLines(props: { live: LastActivity | undefined }) {
|
||||
/**
|
||||
* 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.
|
||||
* request at a time (the shared poller's self-scheduling mode arms the next
|
||||
* tick only after the previous request settles, so a slow host never sees
|
||||
* abort/restart storms); a response settling after the poller stopped (page
|
||||
* hidden, tree re-rooted) is dropped via the aborted signal.
|
||||
*/
|
||||
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])
|
||||
const poll = useCallback(async (signal: AbortSignal): Promise<void> => {
|
||||
if (rootId === undefined) return
|
||||
const result = await api.subagentsLive(rootId, signal)
|
||||
if (!signal.aborted) setLive(result.live)
|
||||
}, [rootId])
|
||||
usePolling(rootId !== undefined && active, poll, {
|
||||
intervalMs: POLL_MS,
|
||||
mode: 'self-scheduling',
|
||||
immediate: true,
|
||||
})
|
||||
|
||||
return live
|
||||
}
|
||||
@@ -415,6 +394,9 @@ function JobOutputPane(props: {
|
||||
if (!active || !isJobLive(job)) return
|
||||
const timer = window.setInterval(() => { void load() }, JOB_POLL_MS)
|
||||
return () => { window.clearInterval(timer) }
|
||||
// isJobLive reads only job.status; whole-job identity churns on every
|
||||
// catalog refresh and must not restart the poll interval.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [load, active, job.status])
|
||||
|
||||
useEffect(() => () => { controllerRef.current?.abort() }, [])
|
||||
@@ -425,6 +407,8 @@ function JobOutputPane(props: {
|
||||
if (!isJobLive(job) || typeof state !== 'object' || state.text.length === 0) return
|
||||
const pre = preRef.current
|
||||
if (pre !== null) pre.scrollTop = pre.scrollHeight
|
||||
// Same as the poll effect above: only the status transition matters.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state, job.status])
|
||||
|
||||
return (
|
||||
@@ -656,7 +640,9 @@ export function SubagentView(props: {
|
||||
useCallback(() => sessions.list.getSnapshot(), [sessions]),
|
||||
)
|
||||
const byId = list.byId
|
||||
const catalogs = list.subagentsByParent ?? {}
|
||||
// Memoized so the empty-catalog fallback keeps a stable identity — a fresh
|
||||
// `{}` per render would invalidate every catalog-dependent memo/effect.
|
||||
const catalogs = useMemo(() => list.subagentsByParent ?? {}, [list.subagentsByParent])
|
||||
|
||||
// The topology root: the main agent of the current session's tree.
|
||||
const rootId = useMemo(() => rootAncestor(byId, sessionId), [byId, sessionId])
|
||||
@@ -680,6 +666,9 @@ export function SubagentView(props: {
|
||||
if (rootId === undefined || !active) return
|
||||
observe(rootId, true)
|
||||
return () => {
|
||||
// The cleanup must release everything observed AT cleanup time (the set
|
||||
// mutates as branches open), so reading the ref here is the point.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
for (const parentSessionId of observedRef.current) {
|
||||
sessions.setSubagentCatalogOpen?.(parentSessionId, false)
|
||||
}
|
||||
|
||||
+12
-14
@@ -4,9 +4,8 @@
|
||||
* 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. 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).
|
||||
* tab opens the tab context menu (close / close others / close to the left /
|
||||
* close to the right, the close ones scoped to this pane).
|
||||
*/
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
import type { SidebarTab } from './state.ts'
|
||||
import { isAgentTabId } from './state.ts'
|
||||
import { isPinnedVirtualTab } from './pinned.ts'
|
||||
import { useSubmenuFlip } from './menu-flip.ts'
|
||||
import { IconPinOutline16 } from './icons.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
@@ -67,9 +67,6 @@ 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
|
||||
@@ -84,12 +81,15 @@ export function TabBar(props: {
|
||||
getTabBadge?: (tab: SidebarTab) => ReactNode
|
||||
}) {
|
||||
const {
|
||||
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, onFloatTab, onPinTab, getTabIcon, getTabBadge,
|
||||
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, 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 the git lens/FileTree).
|
||||
const [tabMenu, setTabMenu] = useState<{ tabId: string; x: number; y: number } | null>(null)
|
||||
// The strip sits at the panel's top, so the pin submenu must grow downward;
|
||||
// the flip hook derives that from the cursor y (upper half → "down").
|
||||
useSubmenuFlip(tabMenu)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
// The context target's index in the render-time tab snapshot; -1 when the
|
||||
@@ -265,6 +265,7 @@ export function TabBar(props: {
|
||||
setMenuOpen(false)
|
||||
}}
|
||||
portal
|
||||
compact
|
||||
align="end"
|
||||
anchor={(
|
||||
<button
|
||||
@@ -293,9 +294,8 @@ export function TabBar(props: {
|
||||
// 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
|
||||
// pin entry at all. Pinned VIRTUAL tabs (injected from other
|
||||
// sessions) get a stripped menu: only Unpin + Close (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
|
||||
@@ -320,7 +320,6 @@ export function TabBar(props: {
|
||||
]
|
||||
}
|
||||
return [
|
||||
{ id: 'float', label: t('moveToFreeWindow') },
|
||||
...pinEntries,
|
||||
{ id: 'close', label: t('close') },
|
||||
{ id: 'closeOthers', label: t('closeOtherTabs'), ...(tabs.length <= 1 ? { disabled: true } : {}) },
|
||||
@@ -334,9 +333,7 @@ export function TabBar(props: {
|
||||
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') {
|
||||
if (id === 'pinWorkspace') {
|
||||
onPinTab?.(target.tabId, 'workspace')
|
||||
} else if (id === 'pinGlobal') {
|
||||
onPinTab?.(target.tabId, 'global')
|
||||
@@ -355,6 +352,7 @@ export function TabBar(props: {
|
||||
}
|
||||
}}
|
||||
portal
|
||||
compact
|
||||
align="start"
|
||||
getAnchorRect={() => (tabMenu === null ? null : new DOMRect(tabMenu.x, tabMenu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
|
||||
+55
-13
@@ -37,6 +37,7 @@ import { FitAddon } from '@xterm/addon-fit'
|
||||
import { writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { t } from './locales.ts'
|
||||
import { ONE_DARK, ONE_LIGHT } from './one-dark-palette.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'
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
shouldActivateTerminalLink,
|
||||
openTerminalUrl,
|
||||
} from './terminal-links.ts'
|
||||
import { TerminalWaitBanner } from './TerminalWaitBanner.tsx'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** How many consecutive unreasoned failures before showing the error banner. */
|
||||
@@ -60,6 +62,13 @@ const FAILURE_LIMIT = 3
|
||||
*/
|
||||
const PTY_DEPS_MISSING = 'pty-deps-missing'
|
||||
|
||||
/**
|
||||
* The WS close-reason prefix the host sends when the CONFIGURED shell was
|
||||
* not found (mirror of src/index.ts wsCloseReasonOf; wire contract, keep the
|
||||
* literal in lockstep). The view renders a localized, actionable banner.
|
||||
*/
|
||||
const SHELL_NOT_FOUND_PREFIX = 'shell-not-found:'
|
||||
|
||||
/** The degraded-mode payload rendered by {@link TerminalDepsBanner}. */
|
||||
type TerminalDepsInfo = Extract<TerminalDepsStatus, { ok: false }>
|
||||
|
||||
@@ -67,24 +76,24 @@ 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
|
||||
* blends with the panel in both schemes; the 16 ANSI colors are the same
|
||||
* designed palettes the app's code surfaces use (one-dark family for dark,
|
||||
* one-light family for light), read live so a scheme flip re-themes in
|
||||
* place.
|
||||
* designed syntax families the app's code surfaces use — one-dark for
|
||||
* dark, one-light for light (one-dark-palette.ts, shared with the
|
||||
* CodeMirror themes) — read live so a scheme flip re-themes in place.
|
||||
*/
|
||||
const ANSI_DARK: Record<string, string> = {
|
||||
black: '#282c34', red: '#e06c75', green: '#98c379', yellow: '#e5c07b',
|
||||
blue: '#61afef', magenta: '#c678dd', cyan: '#56b6c2', white: '#abb2bf',
|
||||
brightBlack: '#5c6370', brightRed: '#e06c75', brightGreen: '#98c379',
|
||||
brightYellow: '#e5c07b', brightBlue: '#61afef', brightMagenta: '#c678dd',
|
||||
brightCyan: '#56b6c2', brightWhite: '#ffffff',
|
||||
black: ONE_DARK.black, red: ONE_DARK.red, green: ONE_DARK.green, yellow: ONE_DARK.yellow,
|
||||
blue: ONE_DARK.blue, magenta: ONE_DARK.magenta, cyan: ONE_DARK.cyan, white: ONE_DARK.gray,
|
||||
brightBlack: ONE_DARK.faintGray, brightRed: ONE_DARK.red, brightGreen: ONE_DARK.green,
|
||||
brightYellow: ONE_DARK.yellow, brightBlue: ONE_DARK.blue, brightMagenta: ONE_DARK.magenta,
|
||||
brightCyan: ONE_DARK.cyan, brightWhite: ONE_DARK.white,
|
||||
}
|
||||
|
||||
const ANSI_LIGHT: Record<string, string> = {
|
||||
black: '#383a42', red: '#e45649', green: '#50a14f', yellow: '#c18401',
|
||||
blue: '#0184bc', magenta: '#a626a4', cyan: '#0997b3', white: '#a0a1a7',
|
||||
brightBlack: '#4f525e', brightRed: '#e45649', brightGreen: '#50a14f',
|
||||
brightYellow: '#c18401', brightBlue: '#0184bc', brightMagenta: '#a626a4',
|
||||
brightCyan: '#0997b3', brightWhite: '#fafafa',
|
||||
black: ONE_LIGHT.black, red: ONE_LIGHT.red, green: ONE_LIGHT.green, yellow: ONE_LIGHT.yellow,
|
||||
blue: ONE_LIGHT.blue, magenta: ONE_LIGHT.magenta, cyan: ONE_LIGHT.cyan, white: ONE_LIGHT.gray,
|
||||
brightBlack: ONE_LIGHT.faintGray, brightRed: ONE_LIGHT.red, brightGreen: ONE_LIGHT.green,
|
||||
brightYellow: ONE_LIGHT.yellow, brightBlue: ONE_LIGHT.blue, brightMagenta: ONE_LIGHT.magenta,
|
||||
brightCyan: ONE_LIGHT.cyan, brightWhite: ONE_LIGHT.offWhite,
|
||||
}
|
||||
|
||||
/** The xterm theme for the current scheme (surface from tokens, ANSI curated). */
|
||||
@@ -115,6 +124,27 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
||||
const [fatal, setFatal] = useState<string | null>(null)
|
||||
const [depsFatal, setDepsFatal] = useState<TerminalDepsInfo | null>(null)
|
||||
const [lastUrl, setLastUrl] = useState<string | null>(null)
|
||||
// Agent terminals only: the model's active terminal_wait_for (mirrored
|
||||
// from the host's agent-terminals push into the store) drives the wait
|
||||
// banner. Read + subscribe like the font prefs above; the banner vanishes
|
||||
// when the host's push drops the waiting field (skip / exit / abort all
|
||||
// converge through the same push). getSnapshot() is {sessionId, state?,
|
||||
// prefs} — the state may be briefly undefined around session switches.
|
||||
const agentUuid = isAgentTabId(tabId) ? agentUuidOf(tabId) : null
|
||||
const [waiting, setWaiting] = useState<{ needle: string; since: number } | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
if (agentUuid === null) return
|
||||
const read = (): void => {
|
||||
const next = store.getSnapshot().state?.agentWaits?.[agentUuid]
|
||||
setWaiting(prev => {
|
||||
const nextValue = next === undefined ? undefined : { needle: next.needle, since: next.since }
|
||||
if (prev?.needle === nextValue?.needle && prev?.since === nextValue?.since) return prev
|
||||
return nextValue
|
||||
})
|
||||
}
|
||||
read()
|
||||
return store.subscribe(read)
|
||||
}, [agentUuid, store])
|
||||
const connectRef = useRef<(() => void) | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -241,6 +271,12 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
||||
})
|
||||
return
|
||||
}
|
||||
// The configured shell could not be found (settings page or yaml):
|
||||
// a localized banner beats the raw English close reason.
|
||||
if (event.code === 1011 && event.reason.startsWith(SHELL_NOT_FOUND_PREFIX)) {
|
||||
setFatal(t('terminalShellNotFound', { name: event.reason.slice(SHELL_NOT_FOUND_PREFIX.length) || '?' }))
|
||||
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 !== '') {
|
||||
@@ -372,6 +408,12 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
||||
|
||||
return (
|
||||
<div className={css.terminalWrap}>
|
||||
{agentUuid !== null && waiting !== undefined && (
|
||||
<TerminalWaitBanner
|
||||
needle={waiting.needle}
|
||||
onSkip={() => { void api.agentSkipWait(agentUuid).catch(() => { /* 跳过失败时 banner 留存,可重试 */ }) }}
|
||||
/>
|
||||
)}
|
||||
{depsFatal !== null && (
|
||||
<TerminalDepsBanner deps={depsFatal} onRetry={() => { setDepsFatal(null); connectRef.current?.() }} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* The "Agent 正在等待 {needle}" wait banner: rendered at the top of an
|
||||
* agent-owned terminal's view while the model blocks in terminal_wait_for.
|
||||
* The skip button asks the host to abort every active wait on the terminal
|
||||
* (`agent-pty.skip-wait`); the banner disappears when the host's next
|
||||
* agent-terminals push drops the waiting field — no optimistic UI. Kept in
|
||||
* its own module (no xterm imports) so jsdom tests can render it directly.
|
||||
*/
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** Cap the needle shown inline; the full text rides the title tooltip. */
|
||||
const NEEDLE_DISPLAY_CAP = 80
|
||||
|
||||
/** Truncate one needle for inline display (title attr carries the full text).
|
||||
* Cut by Unicode code points, not UTF-16 code units — a naive slice can split
|
||||
* a surrogate pair at the cutoff and render a dangling replacement char. */
|
||||
export function truncateNeedle(needle: string): string {
|
||||
const points = Array.from(needle)
|
||||
return points.length > NEEDLE_DISPLAY_CAP
|
||||
? `${points.slice(0, NEEDLE_DISPLAY_CAP - 1).join('')}…`
|
||||
: needle
|
||||
}
|
||||
|
||||
export function TerminalWaitBanner(props: { needle: string; onSkip: () => void }) {
|
||||
const { needle, onSkip } = props
|
||||
return (
|
||||
<div className={css.terminalWaitBanner}>
|
||||
<span
|
||||
className={css.terminalWaitNeedle}
|
||||
title={needle}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={t('terminalWaitBanner', { needle })}
|
||||
>
|
||||
{t('terminalWaitBanner', { needle: truncateNeedle(needle) })}
|
||||
</span>
|
||||
<button type="button" className={css.terminalRetry} onClick={onSkip}>
|
||||
{t('terminalSkipWait')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+21
-23
@@ -31,27 +31,18 @@ import { SandboxStatusBar } from './SandboxStatusBar.tsx'
|
||||
import { appendToDraft } from './conversation-draft.ts'
|
||||
import { useSelectionPopup } from './selection-popup.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 { HtmlPreviewFrame } from './HtmlPreviewFrame.tsx'
|
||||
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 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
|
||||
* could read session data) and NO allow-top-navigation (a previewed page
|
||||
* must not hijack the GUI). The user can disable the sandbox per-feature
|
||||
* in the side card settings (warned); the toggle below reflects it.
|
||||
*/
|
||||
export const HTML_IFRAME_SANDBOX = 'allow-scripts allow-popups allow-downloads allow-modals'
|
||||
|
||||
/** Per-file preview scroll memory. Module-level so it survives viewer
|
||||
* remounts: the save-then-switch-to-preview reload (EditorHost #215 case B)
|
||||
* rebuilds the whole TextEditor instance, and without this the preview
|
||||
@@ -117,13 +108,16 @@ export function TextEditor(props: FileViewerProps) {
|
||||
setDirty(false)
|
||||
setSaveState('idle')
|
||||
selectionPopup.hide()
|
||||
// hide() reads a live ref; the reset must fire only on a content (file)
|
||||
// swap, and the hook object's identity churns on every render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [content])
|
||||
|
||||
// A different file switches the remembered preview scroll position to that
|
||||
// file's own entry (first open: none, so the preview starts at the top).
|
||||
useEffect(() => {
|
||||
previewScrollRef.current = previewScrollMemory.get(previewScrollKey(scope, path)) ?? 0
|
||||
}, [path])
|
||||
}, [scope, path])
|
||||
|
||||
// Create the CodeMirror editor once the content is loaded. The view owns
|
||||
// the document; React only tracks dirty state through the update listener
|
||||
@@ -220,6 +214,7 @@ export function TextEditor(props: FileViewerProps) {
|
||||
// The keymap's save() reads live refs; scope/path are stable for a
|
||||
// tab's lifetime, and the dark flip is handled by the reconfigure
|
||||
// effect below (recreating the view here would drop the draft).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [content, path])
|
||||
|
||||
// Scheme flip: re-theme in place (the compartment holds only the
|
||||
@@ -276,6 +271,9 @@ export function TextEditor(props: FileViewerProps) {
|
||||
view.requestMeasure()
|
||||
})
|
||||
})
|
||||
// The reveal reads the live document/view refs; only the flip into
|
||||
// preview triggers it.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mode])
|
||||
|
||||
// Snapshot the live document into the draft whenever the preview needs
|
||||
@@ -340,9 +338,11 @@ export function TextEditor(props: FileViewerProps) {
|
||||
() => (markdown && mode === 'preview' ? splitMermaidBlocks(previewMdText) : []),
|
||||
[markdown, mode, previewMdText],
|
||||
)
|
||||
/** 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. */
|
||||
/** Raw-HTML analysis (block runs lifted out + inline gate). Non-null for
|
||||
* every markdown preview, so the render below always takes the split
|
||||
* renderer — its markdown runs rewrite local image destinations internally
|
||||
* (see MarkdownHtml.tsx). The legacy single-pass branches (fed the
|
||||
* pre-rewritten `previewText`) are dead in the current wiring. */
|
||||
const htmlInfo = useMemo(
|
||||
() => (markdown && mode === 'preview' ? analyzeMarkdownHtml(previewMdText) : null),
|
||||
[markdown, mode, previewMdText],
|
||||
@@ -545,16 +545,14 @@ export function TextEditor(props: FileViewerProps) {
|
||||
onUnlock={() => { setLocalUnlock(true) }}
|
||||
onRestore={() => { setLocalUnlock(false) }}
|
||||
/>
|
||||
{/* Route-src (never srcdoc — a srcdoc frame inherits the parent
|
||||
origin when unsandboxed; the route URL keeps the frame
|
||||
cross-origin by construction). The preview shows the SAVED
|
||||
file; the draft is only visible in edit mode. */}
|
||||
<iframe
|
||||
{/* The preview shows the SAVED file; the draft is only visible in
|
||||
edit mode. HtmlPreviewFrame owns both the delivery path
|
||||
(main-frame fetch -> srcdoc, which a sandboxed frame could not
|
||||
reach on its own) and the sandbox tokens. */}
|
||||
<HtmlPreviewFrame
|
||||
className={css.editorHtml}
|
||||
src={htmlUrl(scope, path)}
|
||||
sandbox={htmlNoSandbox ? undefined : HTML_IFRAME_SANDBOX}
|
||||
referrerPolicy="no-referrer"
|
||||
allow=""
|
||||
url={htmlUrl(scope, path)}
|
||||
sandboxed={!htmlNoSandbox}
|
||||
title={path}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -19,6 +19,7 @@ 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 type { BetterSidebarService } from './service.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
import { FileTree } from './FileTree.tsx'
|
||||
import { IconUploadOutline16 } from './icons.tsx'
|
||||
@@ -63,11 +64,17 @@ export function TreePanel(props: {
|
||||
onOpenWith?: (targetId: string, path: string) => void
|
||||
onToggleOpenWithPin?: (targetId: string) => void
|
||||
onReferenceFile: (path: string, isDir: boolean) => void
|
||||
/** A tree rename landed (passed through to FileTree for tab retargeting). */
|
||||
onPathRenamed?: (oldPath: string, newPath: string) => void
|
||||
/** A tree delete landed (passed through to FileTree for tab closing). */
|
||||
onPathDeleted?: (path: string, isDir: boolean) => void
|
||||
/** Full-window presentation: the panel fills its host instead of docking
|
||||
* at a fixed width. */
|
||||
full?: boolean
|
||||
/** The sidebar registry service (file-icon registrations; passed through to FileTree). */
|
||||
service?: BetterSidebarService
|
||||
}) {
|
||||
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, full } = props
|
||||
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, onPathRenamed, onPathDeleted, full, service } = props
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -252,9 +259,12 @@ export function TreePanel(props: {
|
||||
onOpenWith={onOpenWith}
|
||||
onToggleOpenWithPin={onToggleOpenWithPin}
|
||||
onReferenceFile={onReferenceFile}
|
||||
onPathRenamed={onPathRenamed}
|
||||
onPathDeleted={onPathDeleted}
|
||||
refreshTick={refreshTick}
|
||||
onUploadRequest={startUpload}
|
||||
busy={busy}
|
||||
service={service}
|
||||
/>
|
||||
) : (
|
||||
<div className={css.explorerBody}>
|
||||
|
||||
@@ -43,7 +43,7 @@ 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
|
||||
const { 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
|
||||
@@ -56,8 +56,9 @@ export function PluginListBody(props: { service: BetterSidebarService; kind: Plu
|
||||
const needle = query.trim().toLowerCase()
|
||||
const matches = (entry: PluginEntry): boolean => {
|
||||
if (needle === '') return true
|
||||
const name = typeof entry.name === 'function' ? entry.name() : entry.name
|
||||
const description = typeof entry.description === 'function' ? entry.description() : entry.description
|
||||
return entry.name.toLowerCase().includes(needle)
|
||||
return name.toLowerCase().includes(needle)
|
||||
|| entry.id.toLowerCase().includes(needle)
|
||||
|| description.toLowerCase().includes(needle)
|
||||
}
|
||||
@@ -93,8 +94,12 @@ export function PluginListBody(props: { service: BetterSidebarService; kind: Plu
|
||||
window.open(entry.url, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
/** One catalog row (extracted so the group render stays flat). */
|
||||
const renderEntry = (entry: PluginEntry): ReactNode => (
|
||||
/** One catalog row (extracted so the group render stays flat). The name
|
||||
* resolves like the description (string or () => string) so it follows
|
||||
* the active locale; a plain-string entry keeps its raw name. */
|
||||
const renderEntry = (entry: PluginEntry): ReactNode => {
|
||||
const name = typeof entry.name === 'function' ? entry.name() : entry.name
|
||||
return (
|
||||
<div key={entry.id} className={css.pluginEntry}>
|
||||
<div className={css.pluginEntryHead}>
|
||||
{/* The name is a BUTTON on the same window.open path as the
|
||||
@@ -104,16 +109,16 @@ export function PluginListBody(props: { service: BetterSidebarService; kind: Plu
|
||||
<button
|
||||
type="button"
|
||||
className={css.pluginName}
|
||||
aria-label={`${t('openPlugin')}: ${entry.name}`}
|
||||
aria-label={`${t('openPlugin')}: ${name}`}
|
||||
onClick={() => { jump(entry) }}
|
||||
>
|
||||
{entry.name}
|
||||
{name}
|
||||
</button>
|
||||
<span className={css.pluginEntryActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.pluginJumpBtn}
|
||||
aria-label={`${t('openPlugin')}: ${entry.name}`}
|
||||
aria-label={`${t('openPlugin')}: ${name}`}
|
||||
onClick={() => { jump(entry) }}
|
||||
>
|
||||
{t('openPlugin')}
|
||||
@@ -121,7 +126,7 @@ export function PluginListBody(props: { service: BetterSidebarService; kind: Plu
|
||||
<button
|
||||
type="button"
|
||||
className={css.pluginCopyBtn}
|
||||
aria-label={`${t('copyInstall')}: ${entry.name}`}
|
||||
aria-label={`${t('copyInstall')}: ${name}`}
|
||||
onClick={() => { copy(entry) }}
|
||||
>
|
||||
{copiedId === entry.id ? t('copied') : t('copy')}
|
||||
@@ -133,7 +138,8 @@ export function PluginListBody(props: { service: BetterSidebarService; kind: Plu
|
||||
</div>
|
||||
<code className={css.pluginInstall}>{entry.install}</code>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.pluginList}>
|
||||
|
||||
@@ -33,8 +33,6 @@ export const apesZh = {
|
||||
apesProfileNoCwd: '未知',
|
||||
apesProfileRunning: '运行中',
|
||||
apesProfileIdle: '空闲',
|
||||
/** Guide-page capsule description (official right sidebar's entry list). */
|
||||
apesProfileGuideDesc: '当前会话的身份与工作目录',
|
||||
// Apps view
|
||||
apesAppsNoFeed: '当前 DSH 版本未提供工作区应用数据源',
|
||||
apesAppsWorkspace: '工作区',
|
||||
@@ -56,7 +54,6 @@ export const apesEn: Record<keyof typeof apesZh, string> = {
|
||||
apesProfileNoCwd: 'unknown',
|
||||
apesProfileRunning: 'running',
|
||||
apesProfileIdle: 'idle',
|
||||
apesProfileGuideDesc: "This session's identity and working directory",
|
||||
apesAppsNoFeed: 'This DSH version exposes no workspace-app feed',
|
||||
apesAppsWorkspace: 'Workspace',
|
||||
}
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
/**
|
||||
* Native right-sidebar bridge (Phase A spike) — registers apes:* tabs as
|
||||
* OFFICIAL `@deepseek-ai/dsh-client-ui-sidebar-right` tab types through the
|
||||
* public two-stage protocol its README documents:
|
||||
*
|
||||
* 1. TYPE — `ctx.sidebarRightTabs.register({ id, kind, priority, title, guide? })`
|
||||
* a static declaration with no runtime hooks; the `id` is the type's
|
||||
* identity in the tab system AND the key its body registers under.
|
||||
* 2. BODY — `ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register(
|
||||
* { name: 'sidebar.right.pane.tab', key: <id> }, Body))`
|
||||
* the keyed seat dispatches a tab's body to the type registered for its
|
||||
* `kind`; the body reads `useTabInfo()` (injected by the seat) for
|
||||
* `{ sidebar, panel, tab }`.
|
||||
*
|
||||
* ACTIVATION ORDER — `sidebarRightTabs` is installed by that package's own
|
||||
* `apply()` through `ctx.reflect.provide(...)`, so it may not exist yet when
|
||||
* our plugin activates. We therefore reach it through `ctx.inject(['sidebarRightTabs'], …)`
|
||||
* (the same pattern intercept.tsx uses for the async `remote.session`
|
||||
* namespace): the callback runs when the service exists and re-runs after a
|
||||
* remount, so the bridge is order-independent and HMR-safe. Deployments
|
||||
* without the official right sidebar simply never see the callback — the
|
||||
* fork's own shell stays the only surface, which is also what the render-time
|
||||
* `guide`/type registration tolerates.
|
||||
*
|
||||
* SCOPE — the spike registers exactly ONE tab (`apes:profile` / 画像) to prove
|
||||
* the path end-to-end before deciding on the full nine-tab migration. The
|
||||
* existing `service.registerTab` route in `tabs.tsx` is deliberately left
|
||||
* untouched, so both surfaces coexist during the spike and the whole spike
|
||||
* reverts by deleting this file plus its one call site.
|
||||
*
|
||||
* Side effect worth knowing (inherent to the official default-page rule): the
|
||||
* dock's default page depends on how many guide entries are registered —
|
||||
* exactly one auto-opens it, zero or many open the guide page. Our guide entry
|
||||
* therefore turns a fresh-empty dock from "Files" into the guide page listing
|
||||
* every entry capsule.
|
||||
*/
|
||||
import { useMemo, type ReactNode } from 'react'
|
||||
import { IconUserOutline16 } from './icons.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import { ApesProfileView } from './tabs.tsx'
|
||||
import type { TabComponentProps } from '../service.ts'
|
||||
|
||||
/** The apes type's identity in the official tab system. Package-scoped so it
|
||||
* can never collide with a native or third-party registration. */
|
||||
const PROFILE_ID = 'dsh-apes-sidebar/profile'
|
||||
/** The kind the panel opens the tab with (`openTab(kind)`); `apes:` prefixed so
|
||||
* it reads as ours in the official registry. */
|
||||
const PROFILE_KIND = 'apes:profile'
|
||||
|
||||
/** The official registry's definition shape (the subset we use). */
|
||||
interface NativeTabDefinition {
|
||||
id: string
|
||||
kind: string
|
||||
priority?: 'builtin' | 'extension' | 'fallback'
|
||||
title: () => string
|
||||
guide?: Array<{
|
||||
order: number
|
||||
title: () => string
|
||||
description?: () => string
|
||||
icon?: (props: { size?: number; className?: string }) => ReactNode
|
||||
}>
|
||||
}
|
||||
|
||||
/** The registry service face the official package provides. */
|
||||
interface NativeTabRegistry {
|
||||
register(definition: NativeTabDefinition): () => void
|
||||
}
|
||||
|
||||
/** The slots service face (structural subset of `@deepseek-ai/dsh-client-ui-slots`). */
|
||||
interface NativeSlots {
|
||||
inject(key: string, callback: () => () => void): () => void
|
||||
register(options: {
|
||||
name: string
|
||||
key?: string
|
||||
locale?: string
|
||||
inject?: (...args: unknown[]) => Record<string, unknown>
|
||||
}, component: (props: Record<string, unknown>) => ReactNode): () => void
|
||||
}
|
||||
|
||||
/** What the seat hands a tab body in addition to our own injected props. */
|
||||
interface NativeTabBodyProps {
|
||||
useTabInfo?: () => unknown
|
||||
sessionId?: string
|
||||
ctx: TabComponentProps['ctx']
|
||||
}
|
||||
|
||||
/** The tab info the seat's `useTabInfo()` hook returns (the subset we read). */
|
||||
interface NativeTabInfo {
|
||||
sidebar?: { expanded?: boolean; fullscreen?: boolean }
|
||||
panel?: { id?: string }
|
||||
tab?: { id?: string; title?: string; visible?: boolean }
|
||||
}
|
||||
|
||||
/** Fallback reader for an absent seat hook (never in production — the seat
|
||||
* always injects `tabInfo`; this keeps the body hook-safe either way, since
|
||||
* the fallback calls no hooks of its own). */
|
||||
function fallbackTabInfo(): NativeTabInfo {
|
||||
return { sidebar: { expanded: true }, panel: { id: '' }, tab: { visible: true } }
|
||||
}
|
||||
|
||||
/**
|
||||
* The official surface's body for 画像: the SAME `ApesProfileView` the fork
|
||||
* already ships, re-bound to the official tab scope. The view reads only
|
||||
* `props.ctx` (the sessions feed) and `props.scope` (sessionId / cwd), so the
|
||||
* shell-only props are filled with placeholders — which is the spike's whole
|
||||
* point: the panel content is shared, never duplicated.
|
||||
*/
|
||||
function ProfileNativeBody(props: NativeTabBodyProps): ReactNode {
|
||||
const useInfo = props.useTabInfo ?? fallbackTabInfo
|
||||
const info = useInfo() as NativeTabInfo | undefined
|
||||
const sessionId = props.sessionId
|
||||
?? props.ctx.sessions.list.getSnapshot().current
|
||||
?? ''
|
||||
const scope = useMemo(() => ({ sessionId }), [sessionId])
|
||||
const viewProps = {
|
||||
ctx: props.ctx,
|
||||
scope,
|
||||
visible: info?.tab?.visible ?? true,
|
||||
store: undefined,
|
||||
tab: {
|
||||
id: info?.tab?.id ?? PROFILE_ID,
|
||||
type: PROFILE_KIND,
|
||||
title: info?.tab?.title ?? t('apesProfileTitle'),
|
||||
},
|
||||
} as unknown as TabComponentProps
|
||||
return <ApesProfileView {...viewProps} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the apes:profile tab as an official right-sidebar tab type.
|
||||
* @param ctx — the client context whose `inject` waits for the registry service.
|
||||
* @returns a disposer that disposes the inject fiber (which unwinds both stages).
|
||||
*/
|
||||
export function registerApesNativeTabs(ctx: unknown): () => void {
|
||||
const anyCtx = ctx as {
|
||||
inject?: (deps: string[], callback: (fctx: unknown) => void) => { dispose(): void }
|
||||
}
|
||||
if (typeof anyCtx.inject !== 'function') return () => {}
|
||||
const fiber = anyCtx.inject(['sidebarRightTabs'], (fctx: unknown) => {
|
||||
const inner = fctx as {
|
||||
get?: (name: string) => unknown
|
||||
slots?: NativeSlots
|
||||
effect?: (callback: () => () => void, label?: string) => unknown
|
||||
}
|
||||
const registry = inner.get?.('sidebarRightTabs') as NativeTabRegistry | undefined
|
||||
const slots = inner.slots
|
||||
if (registry === undefined || slots === undefined || typeof inner.effect !== 'function') return
|
||||
inner.effect(() => {
|
||||
const disposers: Array<() => void> = []
|
||||
try {
|
||||
// Stage 1 — the type. A page type: it recognizes no resource address
|
||||
// and is opened by kind; `extension` is the band for types contributed
|
||||
// from outside the product (highest rank, so it can also take over a
|
||||
// builtin of the same kind).
|
||||
disposers.push(registry.register({
|
||||
id: PROFILE_ID,
|
||||
kind: PROFILE_KIND,
|
||||
priority: 'extension',
|
||||
title: () => t('apesProfileTitle'),
|
||||
guide: [{
|
||||
order: 70,
|
||||
title: () => t('apesProfileTitle'),
|
||||
description: () => t('apesProfileGuideDesc'),
|
||||
icon: IconUserOutline16,
|
||||
}],
|
||||
}))
|
||||
// Stage 2 — the body, dispatched through the keyed seat. `inject`
|
||||
// hands the body the client context the view reads its feed from.
|
||||
disposers.push(slots.inject('sidebar.right.pane.tab', () => slots.register({
|
||||
name: 'sidebar.right.pane.tab',
|
||||
key: PROFILE_ID,
|
||||
inject: () => ({ ctx }),
|
||||
}, ProfileNativeBody as unknown as (props: Record<string, unknown>) => ReactNode)))
|
||||
} catch (error) {
|
||||
// A half-registered bridge must never take the fork's own sidebar
|
||||
// down: unwind what landed and let the own-shell surface carry on.
|
||||
console.error('[dsh-apes-sidebar] native right-sidebar registration failed:', error)
|
||||
}
|
||||
return () => {
|
||||
for (const dispose of disposers.reverse()) {
|
||||
try { dispose() } catch { /* already disposed */ }
|
||||
}
|
||||
}
|
||||
}, 'dsh-apes-sidebar: apes native tab (profile)')
|
||||
})
|
||||
return () => { void fiber.dispose() }
|
||||
}
|
||||
+24
-3
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { encodeHtmlUrl } from '../html-route.ts'
|
||||
import type { LastActivity } from '../subagent-activity.ts'
|
||||
import type { SidechatLogEvent, SidechatThreadInfo } from '../sidechat-core.ts'
|
||||
import type { SidechatLiveEvent, SidechatLogEvent, SidechatThreadInfo } from '../sidechat-core.ts'
|
||||
import type { SidebarSessionEvent } from '../context-types.ts'
|
||||
import type { BrowserProbeResult } from './browser.ts'
|
||||
|
||||
@@ -278,6 +278,15 @@ export const api = {
|
||||
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 })),
|
||||
/** Rename one tree row within its directory (single-segment name; the
|
||||
* server refuses existing destinations, the workspace root, and — while
|
||||
* the fence is armed — anything resolving outside the workspace). */
|
||||
fsRename: (scope: SessionScope, path: string, name: string) =>
|
||||
call<{ path: string }>('fs.rename', scopePayload(scope, { path, name })),
|
||||
/** Permanently delete one tree row (recursive for directories; a symlink
|
||||
* row unlinks the link only). The UI confirms before calling this. */
|
||||
fsRemove: (scope: SessionScope, path: string) =>
|
||||
call<{ path: string }>('fs.remove', scopePayload(scope, { path })),
|
||||
/** 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) =>
|
||||
@@ -307,6 +316,11 @@ export const api = {
|
||||
/** Full patch text of one commit (diff display for the history rows). */
|
||||
gitCommitDiff: (scope: SessionScope, hash: string, worktree?: string, signal?: AbortSignal) =>
|
||||
call<{ diff: string }>('git.commit-diff', gitPayload(scope, worktree, { hash }), signal),
|
||||
/** One file's content at a revision (`git show <rev>:<path>`); null when the
|
||||
* revision has no such path. The diff views' on-demand hunk-fold expansion
|
||||
* reads both sides' full contents through this. */
|
||||
gitShow: (scope: SessionScope, rev: string, path: string, worktree?: string, signal?: AbortSignal) =>
|
||||
call<{ content: string | null }>('git.show', gitPayload(scope, worktree, { rev, path }), signal),
|
||||
/** The session's file-tool events for the changes tab's session lens: the
|
||||
* `tool/call` + `tool/result` rows past `afterSeq` (0 = whole window),
|
||||
* capped to the recent window host-side. The client runtime exposes no
|
||||
@@ -332,6 +346,10 @@ 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 }),
|
||||
/** Skip every active terminal_wait_for on one agent terminal (the wait
|
||||
* banner's skip button). Idempotent: {skipped:0} when none is active. */
|
||||
agentSkipWait: (uuid: string) =>
|
||||
call<{ ok: true; skipped: number }>('agent-pty.skip-wait', { 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). */
|
||||
@@ -376,9 +394,12 @@ export const api = {
|
||||
call<SidechatThreadInfo>('sidechat.info', { childId }),
|
||||
/** One transcript pull of a Side Chat thread: the thread's OWN events
|
||||
* (the inherited seed is cut host-side and never crosses the wire).
|
||||
* `afterSeq` narrows the response to the delta beyond it (poll tail). */
|
||||
* `afterSeq` narrows the response to the delta beyond it (poll tail).
|
||||
* `live` is the thread's in-flight model deltas (DSH 0.1.5 publishes them
|
||||
* outside the session log) — the CURRENT attempt on every pull, never a
|
||||
* delta, so the caller replaces its live set instead of appending. */
|
||||
sidechatEvents: (childId: string, afterSeq?: number, signal?: AbortSignal) =>
|
||||
call<{ events: SidechatLogEvent[] }>('sidechat.events', {
|
||||
call<{ events: SidechatLogEvent[]; live: SidechatLiveEvent[] }>('sidechat.events', {
|
||||
childId,
|
||||
...(afterSeq !== undefined ? { afterSeq } : {}),
|
||||
}, signal),
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Colors for the built-in tab glyphs (src/client/builtins/tab-icons.tsx).
|
||||
*
|
||||
* Every declaration resolves to a `--dsw-alias-*` token, so all skins stay in
|
||||
* charge — including the token's own fallback chain, which keeps the glyph
|
||||
* legible on a host that does not define the brand/state families. The plugin
|
||||
* never writes a color literal here; `tests/theme.spec.ts` enforces both
|
||||
* halves of that rule.
|
||||
*
|
||||
* The chips sit on the panel surface, so a token that is itself translucent
|
||||
* would wash out at 14px; each rule therefore prefers an opaque state/brand
|
||||
* token and falls back to the primary ink.
|
||||
*/
|
||||
|
||||
.files {
|
||||
color: var(--dsw-alias-brand-primary, var(--dsw-alias-label-primary));
|
||||
}
|
||||
|
||||
.changes {
|
||||
color: var(--dsw-alias-state-success-primary, var(--dsw-alias-brand-primary, var(--dsw-alias-label-primary)));
|
||||
}
|
||||
|
||||
.tasks {
|
||||
color: var(--dsw-alias-state-warn-primary, var(--dsw-alias-label-primary));
|
||||
}
|
||||
|
||||
.sidechat {
|
||||
color: var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary, var(--dsw-alias-label-primary)));
|
||||
}
|
||||
|
||||
.terminal {
|
||||
/* The shell chip is ink, not accent. Its glyph is the widest in the set and
|
||||
renders a step smaller than its siblings (see tab-icons.tsx). */
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.browser {
|
||||
color: var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary, var(--dsw-alias-label-primary)));
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* The built-in tab glyphs, in color.
|
||||
*
|
||||
* Every built-in tab type and the incoming 8th (changes) declare their icon
|
||||
* here, so the three surfaces that draw a tab's glyph — the bottom workbench's
|
||||
* tab strip, the native right Sidebar's guide capsules and its tab chips — all
|
||||
* read the same colorful glyph from `descriptor.icon`.
|
||||
*
|
||||
* The color always arrives from a theme token, never from the plugin: the
|
||||
* glyph is a VSCodicon drawn in `currentColor`, and the wrapper class supplies
|
||||
* that color. A skin therefore keeps control of everything (the guide's §12);
|
||||
* this module is what `tests/theme.spec.ts` checks for color literals.
|
||||
*
|
||||
* `files` is the one exception in kind rather than in color: it renders DSH's
|
||||
* own folder artwork (`FileTypeIcon`), matching the file rows this tab shows.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { FileTypeIcon } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import styles from './tab-icons.module.css'
|
||||
import {
|
||||
VscCommentDiscussion,
|
||||
VscGitCommit,
|
||||
VscGlobe,
|
||||
VscLayers,
|
||||
VscTerminal,
|
||||
} from 'react-icons/vsc'
|
||||
/** The styled wrapper classes; typed so a renamed rule fails the build. */
|
||||
const css = styles as Record<'files' | 'changes' | 'tasks' | 'sidechat' | 'terminal' | 'browser', string>
|
||||
|
||||
/** One tab type's glyph, sized by the caller's surface (14px in a strip). */
|
||||
export type TabIcon = (size: number) => ReactNode
|
||||
|
||||
/** Surround a glyph with the class that hands it its token-driven color. */
|
||||
function themed(className: string, glyph: ReactNode): ReactNode {
|
||||
return <span className={className}>{glyph}</span>
|
||||
}
|
||||
|
||||
/** The Files tab: the host's folder artwork, like the rows it opens. */
|
||||
export const filesTabIcon: TabIcon = (size) => (
|
||||
<span className={css.files}>
|
||||
<FileTypeIcon kind="folder" size={size} />
|
||||
</span>
|
||||
)
|
||||
|
||||
/** Changes / diff: the commit glyph, green like the diff affordances. */
|
||||
export const changesTabIcon: TabIcon = (size) =>
|
||||
themed(css.changes, <VscGitCommit size={size} />)
|
||||
|
||||
/**
|
||||
* Tasks (subagents and background jobs) — the live-activity amber. The glyph
|
||||
* is layered sheets, not a checklist: this page lists RUNNING work (subagent
|
||||
* sessions plus the host's background jobs), not a to-do list.
|
||||
*/
|
||||
export const tasksTabIcon: TabIcon = (size) =>
|
||||
themed(css.tasks, <VscLayers size={size} />)
|
||||
|
||||
/** Side chat — the conversational/secondary accent. */
|
||||
export const sidechatTabIcon: TabIcon = (size) =>
|
||||
themed(css.sidechat, <VscCommentDiscussion size={size} />)
|
||||
|
||||
/**
|
||||
* Terminal — primary ink, the shell is text. Rendered one step down from the
|
||||
* strip's 14px: the VSCodicon terminal is a wide filled rectangle and read
|
||||
* heavier than its neighbours at full size.
|
||||
*/
|
||||
export const terminalTabIcon: TabIcon = (size) =>
|
||||
themed(css.terminal, <VscTerminal size={Math.max(10, Math.round(size * 0.85))} />)
|
||||
|
||||
/** Browser — the same secondary accent as the side chat's sibling surfaces. */
|
||||
export const browserTabIcon: TabIcon = (size) =>
|
||||
themed(css.browser, <VscGlobe size={size} />)
|
||||
@@ -8,8 +8,11 @@
|
||||
* `browser:<n>` the same way (no quota). The editor IS the files window
|
||||
* (the old standalone explorer merged into it).
|
||||
*/
|
||||
import { IconCodeOutline16, IconFolderOpen16, IconNewChatOutline16, IconPanelLeftOutline16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconCodeOutline16, IconPanelLeftOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from '../../context-types.ts'
|
||||
import {
|
||||
browserTabIcon, changesTabIcon, filesTabIcon, sidechatTabIcon, tasksTabIcon, terminalTabIcon,
|
||||
} from './tab-icons.tsx'
|
||||
import { allLeaves, isAgentTabId, type SidebarState } from '../state.ts'
|
||||
import { t } from '../locales.ts'
|
||||
import { openSidebarFile } from '../intercept.tsx'
|
||||
@@ -22,7 +25,6 @@ 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, IconFloatWindowOutline16, IconPanelBottomOutline16 } 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'
|
||||
@@ -72,7 +74,7 @@ function terminalUuid(): string {
|
||||
|
||||
/** Count UI-owned terminals (agent:` tabs excluded — they are the model's). */
|
||||
function uiTerminalCount(state: SidebarState): number {
|
||||
return allLeaves(state.splits)
|
||||
return allLeaves(state.bottomSplits)
|
||||
.flatMap(leaf => leaf.tabs)
|
||||
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
|
||||
}
|
||||
@@ -82,11 +84,12 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
|
||||
return [
|
||||
{
|
||||
id: 'editor',
|
||||
description: () => t('guideDescFiles'),
|
||||
// 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} />,
|
||||
icon: filesTabIcon,
|
||||
order: 10,
|
||||
hidden: false,
|
||||
dedupeKey: (tab) => tab.path,
|
||||
@@ -148,39 +151,15 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
|
||||
// log, and the git status needs a fetch — both stay out of the badge).
|
||||
id: 'git',
|
||||
title: () => t('changes'),
|
||||
icon: (size: number) => <IconDiffOutline16 size={size} />,
|
||||
description: () => t('guideDescGit'),
|
||||
icon: changesTabIcon,
|
||||
order: 20,
|
||||
single: true,
|
||||
badge: (_ctx, scope) => {
|
||||
const count = opCountOf(scope.sessionId)
|
||||
return count === undefined || count === 0 ? null : count
|
||||
},
|
||||
// Declarative settings: the diff-open picker (free window vs docked
|
||||
// pane) renders as an iconed select row under the changes card's gear
|
||||
// in the Side card settings page.
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'changesDiffFloat',
|
||||
type: 'select',
|
||||
title: () => t('changesDiffOpenTitle'),
|
||||
desc: () => t('changesDiffOpenDesc'),
|
||||
options: [
|
||||
{
|
||||
value: true,
|
||||
icon: (size: number) => <IconFloatWindowOutline16 size={size} />,
|
||||
title: () => t('changesDiffOpenFloat'),
|
||||
desc: () => t('changesDiffOpenFloatDesc'),
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
icon: (size: number) => <IconPanelBottomOutline16 size={size} />,
|
||||
title: () => t('changesDiffOpenPane'),
|
||||
desc: () => t('changesDiffOpenPaneDesc'),
|
||||
},
|
||||
],
|
||||
}],
|
||||
},
|
||||
component: ({ ctx, store, scope, tab, visible, onOpenFile, onOpenDiff }) => (
|
||||
component: ({ ctx, store, scope, tab, visible, onOpenDiff }) => (
|
||||
<ChangesTab
|
||||
ctx={ctx}
|
||||
store={store}
|
||||
@@ -195,7 +174,8 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
|
||||
{
|
||||
id: 'subagent',
|
||||
title: () => t('subagent'),
|
||||
icon: (size: number) => <IconThinkOutline16 size={size} />,
|
||||
description: () => t('guideDescSubagent'),
|
||||
icon: tasksTabIcon,
|
||||
order: 30,
|
||||
single: true,
|
||||
// Declarative settings: the auto-open switches render under this row in
|
||||
@@ -223,7 +203,8 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
|
||||
{
|
||||
id: 'sidechat',
|
||||
title: () => t('sideChat'),
|
||||
icon: (size: number) => <IconNewChatOutline16 size={size} />,
|
||||
description: () => t('guideDescSidechat'),
|
||||
icon: sidechatTabIcon,
|
||||
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
|
||||
@@ -268,7 +249,8 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
|
||||
{
|
||||
id: 'terminal',
|
||||
title: () => t('terminal'),
|
||||
icon: (size: number) => <IconTerminalOutline16 size={size} />,
|
||||
description: () => t('guideDescTerminal'),
|
||||
icon: terminalTabIcon,
|
||||
order: 40,
|
||||
available: (_ctx, _scope, state) => uiTerminalCount(state) < TERMINAL_LIMIT,
|
||||
// Declarative settings: the model-facing terminal tools switch, the
|
||||
@@ -332,7 +314,8 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
|
||||
{
|
||||
id: 'browser',
|
||||
title: () => t('browser'),
|
||||
icon: (size: number) => <IconGlobeOutline16 size={size} />,
|
||||
description: () => t('guideDescBrowser'),
|
||||
icon: browserTabIcon,
|
||||
order: 50,
|
||||
// Declarative settings: the sandbox escape hatch, the link-takeover
|
||||
// MASTER switch, and the per-protocol takeover switches (http on /
|
||||
@@ -376,7 +359,7 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
|
||||
{
|
||||
id: 'diff',
|
||||
title: () => t('changes'),
|
||||
icon: (size: number) => <IconDiffOutline16 size={size} />,
|
||||
icon: changesTabIcon,
|
||||
order: -1,
|
||||
hidden: true,
|
||||
dedupeKey: (tab) => tab.id,
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
/** Stable selector for the DSH AppFrame conversation slot. */
|
||||
const CENTER_COLUMN_SELECTOR = '#root [data-slot="conversation"]'
|
||||
/**
|
||||
* Anchor selectors for the DSH AppFrame conversation slot host, newest first:
|
||||
*
|
||||
* - DSH 0.1.5-alpha.2 replaced the root `conversation` slot with a root-scoped
|
||||
* keyed `main` slot, so the conversation renders under `main.conversation`
|
||||
* (ConversationPanel -> `renderSlot('main.conversation')`);
|
||||
* - alpha.1 and earlier spell the same host `conversation`.
|
||||
*
|
||||
* Both keys are accepted deliberately so one build spans the alpha.1 → alpha.2
|
||||
* boundary; only one exists in any given host.
|
||||
*/
|
||||
const CENTER_COLUMN_SELECTOR = '#root [data-slot="main.conversation"], #root [data-slot="conversation"]'
|
||||
|
||||
/** Match Sidebar's existing last-resort retry cadence (issue #248). */
|
||||
export const CENTER_COLUMN_REVALIDATE_MS = 1500
|
||||
@@ -27,6 +37,22 @@ const validatedAt = new WeakMap<HTMLElement, number>()
|
||||
*/
|
||||
const documentStyleState = new WeakMap<Document, string | null>()
|
||||
|
||||
/**
|
||||
* From the matched conversation slot host, walk up through transparent slot
|
||||
* hosts to the real column: DSH slot hosts render with `display: contents`,
|
||||
* so on alpha.2 the `main.conversation` host (and any further keyed slot
|
||||
* wrappers above it) contributes no box, and the center column is the first
|
||||
* ancestor whose computed display is NOT `contents`.
|
||||
*/
|
||||
function columnOfSlotHost(host: Element): HTMLElement | null {
|
||||
const view = host.ownerDocument.defaultView
|
||||
let node = host.parentElement
|
||||
while (node !== null && (view?.getComputedStyle(node).display ?? '') === 'contents') {
|
||||
node = node.parentElement
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the AppFrame center column while keeping streaming mutations cheap.
|
||||
*
|
||||
@@ -74,7 +100,8 @@ export function resolveCenterColumn(
|
||||
}
|
||||
|
||||
const query = options.query ?? (() => doc.querySelector(CENTER_COLUMN_SELECTOR))
|
||||
const col = query()?.parentElement as HTMLElement | null | undefined
|
||||
const host = query()
|
||||
const col = host === null || host === undefined ? null : columnOfSlotHost(host)
|
||||
if (col === null || col === undefined || !col.isConnected) return undefined
|
||||
|
||||
validatedAt.set(col, now)
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
* (repository truth: staged/unstaged files, commit box, history) and the
|
||||
* session round (agent truth: every file the model read, wrote, or edited).
|
||||
* Both lenses preview their selections in a shared resizable bottom pane
|
||||
* ({@link DiffPane}); git targets expand into the dedicated diff tab —
|
||||
* docked in a pane, or floated as a free window per the tab's setting.
|
||||
* The active lens and the pane height persist in the tab's meta, so the tab
|
||||
* ({@link DiffPane}); git targets expand into the dedicated diff tab docked
|
||||
* in the workbench's diff pane. The active lens and the pane height persist
|
||||
* in the tab's meta, so the tab
|
||||
* reopens exactly where it was left.
|
||||
*
|
||||
* The session events ride the host's `changes.ops` route (the client
|
||||
@@ -18,7 +18,8 @@ import type { SidebarSessionEvent } from '../../context-types.ts'
|
||||
import type { TabComponentProps } from '../service.ts'
|
||||
import { t } from '../locales.ts'
|
||||
import { api } from '../api.ts'
|
||||
import { floatTab, type SidebarDiffRef } from '../state.ts'
|
||||
import { usePolling } from '../use-polling.ts'
|
||||
import type { SidebarDiffRef } from '../state.ts'
|
||||
import { GitLens } from './GitLens.tsx'
|
||||
import { SessionLens } from './SessionLens.tsx'
|
||||
import { DiffPane, diffTabOf, type ChangesPreview } from './DiffPane.tsx'
|
||||
@@ -93,6 +94,9 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
|
||||
// only while nothing has ever loaded.
|
||||
if (generation === pollGen.current) setOpsError(true)
|
||||
}
|
||||
// Granular scope fields: the scope object's identity churns, only its
|
||||
// sessionId / cwd fields gate the poll target.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scope.sessionId, scope.cwd])
|
||||
useEffect(() => {
|
||||
pollGen.current += 1
|
||||
@@ -101,12 +105,11 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
|
||||
seqRef.current = 0
|
||||
setOpsError(false)
|
||||
}, [scope.sessionId])
|
||||
useEffect(() => {
|
||||
void pull()
|
||||
if (!visible) return
|
||||
const timer = window.setInterval(() => { void pull() }, 2_500)
|
||||
return () => { window.clearInterval(timer) }
|
||||
}, [visible, pull])
|
||||
// One pull on every input change — mount, scope change, and visibility
|
||||
// flip all re-pull (a hidden tab still catches up on the flip); only the
|
||||
// poll CADENCE below is gated by visibility.
|
||||
useEffect(() => { void pull() }, [visible, pull])
|
||||
usePolling(visible, pull, { intervalMs: 2_500 })
|
||||
// tick only forces the re-render; the fold reads the ref directly.
|
||||
void tick
|
||||
const ops = opsRef.current
|
||||
@@ -134,18 +137,11 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
|
||||
setPreview({ kind: 'op', path, op, prior: knownContentBefore(ops, path, op) })
|
||||
}
|
||||
|
||||
/** Expand the current git preview into the dedicated diff tab: docked
|
||||
* into the shell's diff pane, or floated as a free window centered on
|
||||
* the viewport when the tab's diff-open setting asks for it (default). */
|
||||
/** Expand the current git preview into the dedicated diff tab, docked
|
||||
* into the workbench's diff pane. */
|
||||
const expandPreview = (): void => {
|
||||
if (preview?.kind !== 'git') return
|
||||
const diffTab = diffTabOf(preview.ref)
|
||||
onOpenDiff?.(diffTab)
|
||||
if (store.getPrefs().changesDiffFloat !== false) {
|
||||
const x = Math.round(window.innerWidth / 2)
|
||||
const y = Math.round(window.innerHeight / 2)
|
||||
store.reduce(state => floatTab(state, diffTab.id, x, y))
|
||||
}
|
||||
onOpenDiff?.(diffTabOf(preview.ref))
|
||||
}
|
||||
|
||||
const previewKey = (target: ChangesPreview): string => target.kind === 'git'
|
||||
|
||||
@@ -9,26 +9,37 @@
|
||||
* diff tab via the shell.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
|
||||
import { IconCloseOutline16, IconRefreshOutline16, IconRightUpOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconCloseOutline16, IconRefreshOutline16, IconRightUpOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SessionScope } from '../api.ts'
|
||||
import { api } from '../api.ts'
|
||||
import { api, htmlUrl } from '../api.ts'
|
||||
import { t } from '../locales.ts'
|
||||
import { baseName } from '../paths.ts'
|
||||
import { resolveSidebarPath } from '../produced-files.ts'
|
||||
import { HtmlPreviewFrame } from '../HtmlPreviewFrame.tsx'
|
||||
import type { SidebarDiffRef, SidebarTab } from '../state.ts'
|
||||
import { DiffRows, ReadRows } from '../diff/DiffRows.tsx'
|
||||
import { PdfView } from '../PdfView.tsx'
|
||||
import { DiffFiles } from '../diff/DiffFiles.tsx'
|
||||
import { langOfPath } from '../diff/highlight.ts'
|
||||
import { buildDiffSegments, diffLines, diffStats, parseUnifiedDiff, unifiedSegments, type DiffRow } from '../diff/rows.ts'
|
||||
import { parseReadLines, type FileOp } from './ops.ts'
|
||||
import { buildDiffSegments, diffLines, diffStats, displayPath, foldRowsFromContents, parseUnifiedDiff, unifiedSegments, type DiffFile, type DiffRow, type FoldSegment } from '../diff/rows.ts'
|
||||
import { parseReadContent, parseReadLines, type FileOp } from './ops.ts'
|
||||
import { redactText } from '../redact.ts'
|
||||
import { rewriteLocalImageUrls } from '../markdown-images.ts'
|
||||
import { markdownTextProps } from '../markdown-labels.tsx'
|
||||
import { splitMermaidBlocks } from '../mermaid-blocks.ts'
|
||||
import { LazyMermaidMarkdown } from '../mermaid-lazy.tsx'
|
||||
import { createFrameBatcher } from '../frame-batcher.ts'
|
||||
import css from './changes.module.css'
|
||||
import diffCss from '../diff/diff.module.css'
|
||||
|
||||
/** The drag handle height clamp (px) and keyboard-resize step. */
|
||||
/** Drag handle height clamp (px) and keyboard-resize step. */
|
||||
const HEIGHT_MIN = 140
|
||||
const HEIGHT_STEP = 24
|
||||
|
||||
/** The redaction preference, persisted under the repo's sidebar storage
|
||||
* prefix (see state.ts's `dsh-sidebar:v1`). */
|
||||
const REDACTION_KEY = 'dsh-sidebar:v1:redaction'
|
||||
|
||||
/** What the pane is showing right now. */
|
||||
export type ChangesPreview =
|
||||
| { kind: 'git'; ref: SidebarDiffRef }
|
||||
@@ -55,6 +66,60 @@ function diffOf(op: FileOp, prior: string | undefined): readonly DiffRow[] {
|
||||
return []
|
||||
}
|
||||
|
||||
/** The render view of one html op target. Extracted (and exported) so the
|
||||
* always-sandboxed contract is pinned directly by the sandbox spec — this
|
||||
* surface has NO no-sandbox escape hatch, so it always takes the srcdoc
|
||||
* delivery path. */
|
||||
export function HtmlRenderPreview(props: { src: string; title: string }) {
|
||||
return (
|
||||
<div className={css.htmlPane}>
|
||||
<HtmlPreviewFrame
|
||||
className={css.htmlFrame}
|
||||
url={props.src}
|
||||
sandboxed
|
||||
title={props.title}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One header pill toggle — the redaction / reading / render toggles share
|
||||
* the shape (on-state styling + aria-pressed). */
|
||||
function PaneToggle(props: { on: boolean; label: string; title?: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={css.mdToggle}
|
||||
data-on={props.on ? 'true' : undefined}
|
||||
aria-pressed={props.on}
|
||||
title={props.title}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
{props.label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** The reading-mode body of one markdown op target: the shared MarkdownText
|
||||
* pass (local image destinations already rewritten to the media route by
|
||||
* the caller). Mermaid fences render through the same chunk-resident
|
||||
* renderer the editor preview uses (one MarkdownText pass with the fences
|
||||
* lifted out); the plain path stays byte-for-byte for documents without
|
||||
* any. */
|
||||
function MdReadingView(props: { text: string }) {
|
||||
const codeLabels = { copyLabel: t('copy'), copiedLabel: t('copied') }
|
||||
const hasMermaid = splitMermaidBlocks(props.text).some((block) => block.kind === 'mermaid')
|
||||
return (
|
||||
<div className={css.paneBody}>
|
||||
<div className={css.mdBody}>
|
||||
{hasMermaid
|
||||
? <LazyMermaidMarkdown text={props.text} codeLabels={codeLabels} />
|
||||
: <MarkdownText {...markdownTextProps(props.text, codeLabels)} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The diff tab a git preview expands into (the shell owns placement). */
|
||||
export function diffTabOf(ref: SidebarDiffRef): SidebarTab {
|
||||
if (ref.kind === 'worktree') {
|
||||
@@ -92,7 +157,19 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [diffText, setDiffText] = useState<string | null>(null)
|
||||
const [untracked, setUntracked] = useState<string | undefined>(undefined)
|
||||
// The staged flag of the side ACTUALLY rendered: when the requested side's
|
||||
// diff came back empty the load falls back to the other side, and the fold
|
||||
// expansion must read that side's revisions (else the sliced line numbers
|
||||
// land on the wrong contents).
|
||||
const [effectiveStaged, setEffectiveStaged] = useState<boolean | null>(null)
|
||||
const gitRef = target.kind === 'git' ? target.ref : null
|
||||
// The scope every git call of this target shares (repoRoot folded in when
|
||||
// the ref carries one, exactly like the load effect's paneScope).
|
||||
const gitScope = useMemo<SessionScope>(() => ({
|
||||
sessionId: scope.sessionId,
|
||||
cwd: scope.cwd,
|
||||
...(gitRef?.repoRoot !== undefined ? { repoRoot: gitRef.repoRoot } : {}),
|
||||
}), [scope.sessionId, scope.cwd, gitRef?.repoRoot])
|
||||
|
||||
useEffect(() => {
|
||||
if (gitRef === null) return
|
||||
@@ -106,6 +183,7 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
|
||||
setError(null)
|
||||
setDiffText(null)
|
||||
setUntracked(undefined)
|
||||
setEffectiveStaged(null)
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
if (gitRef.kind === 'commit') {
|
||||
@@ -118,7 +196,10 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
|
||||
// The requested side is empty — try the OTHER side once (the change
|
||||
// may have moved sides after the preview target was minted).
|
||||
const other = await api.gitDiff(paneScope, gitRef.path, !gitRef.staged, gitRef.worktree)
|
||||
if (other.diff !== '') result = other
|
||||
if (other.diff !== '') {
|
||||
result = other
|
||||
if (!cancelled) setEffectiveStaged(!gitRef.staged)
|
||||
}
|
||||
}
|
||||
if (result.diff !== '') {
|
||||
if (!cancelled) setDiffText(result.diff)
|
||||
@@ -145,10 +226,119 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
|
||||
return () => { cancelled = true }
|
||||
}, [gitRef, scope.sessionId, scope.cwd, tick])
|
||||
|
||||
// ── On-demand git fold expansion: a fold's hidden rows come from both
|
||||
// sides' full contents (git.show / fsRead), fetched ONCE per file so
|
||||
// sibling folds share the request, then sliced by each fold's line
|
||||
// ranges. The cache dies with the target or a refresh tick. ───────────
|
||||
const foldContents = useRef(new Map<string, Promise<{ old: string; new: string }>>())
|
||||
useEffect(() => { foldContents.current = new Map() }, [gitRef, tick])
|
||||
const foldLoader = useMemo(() => {
|
||||
if (gitRef === null) return undefined
|
||||
const sidesOf = (file: DiffFile): Promise<{ old: string; new: string }> => {
|
||||
// Both sides empty cannot cover a non-empty fold — treat it as a failed
|
||||
// fetch so the fold degrades to the unavailable marker instead of
|
||||
// silently expanding to nothing (the symptom of a bad rev or path
|
||||
// reading null on both sides).
|
||||
const ofSides = (oldContent: string | null, newContent: string | null): { old: string; new: string } => {
|
||||
if ((oldContent ?? '') === '' && (newContent ?? '') === '') throw new Error('no content on either side')
|
||||
return { old: oldContent ?? '', new: newContent ?? '' }
|
||||
}
|
||||
const fetchSides = async (): Promise<{ old: string; new: string }> => {
|
||||
if (gitRef.kind === 'commit') {
|
||||
// The patch's -m --first-parent shape: old side from the parent,
|
||||
// new side from the commit (a root commit's parent read fails → '').
|
||||
const [oldSide, newSide] = await Promise.all([
|
||||
file.oldPath === '/dev/null'
|
||||
? Promise.resolve({ content: null })
|
||||
: api.gitShow(gitScope, `${gitRef.hashFull}^`, displayPath(file.oldPath), gitRef.worktree),
|
||||
file.newPath === '/dev/null'
|
||||
? Promise.resolve({ content: null })
|
||||
: api.gitShow(gitScope, gitRef.hashFull, displayPath(file.newPath), gitRef.worktree),
|
||||
])
|
||||
return ofSides(oldSide.content, newSide.content)
|
||||
}
|
||||
// Worktree change: staged is HEAD vs index, unstaged is index vs
|
||||
// worktree (the worktree side reads the live file).
|
||||
const staged = effectiveStaged ?? gitRef.staged
|
||||
if (staged) {
|
||||
const [oldSide, newSide] = await Promise.all([
|
||||
file.oldPath === '/dev/null'
|
||||
? Promise.resolve({ content: null })
|
||||
: api.gitShow(gitScope, 'HEAD', displayPath(file.oldPath), gitRef.worktree),
|
||||
file.newPath === '/dev/null'
|
||||
? Promise.resolve({ content: null })
|
||||
: api.gitShow(gitScope, ':0', displayPath(file.newPath), gitRef.worktree),
|
||||
])
|
||||
return ofSides(oldSide.content, newSide.content)
|
||||
}
|
||||
const [oldSide, worktree] = await Promise.all([
|
||||
file.oldPath === '/dev/null'
|
||||
? Promise.resolve({ content: null })
|
||||
: api.gitShow(gitScope, ':0', displayPath(file.oldPath), gitRef.worktree),
|
||||
api.fsRead(gitScope, resolveSidebarPath(gitRef.repoRoot ?? gitRef.worktree ?? scope.cwd, displayPath(file.newPath))).catch(() => null),
|
||||
])
|
||||
return ofSides(oldSide.content, worktree !== null && worktree.kind === 'text' ? worktree.content : null)
|
||||
}
|
||||
const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath)
|
||||
let promise = foldContents.current.get(path)
|
||||
if (promise === undefined) {
|
||||
promise = fetchSides()
|
||||
foldContents.current.set(path, promise)
|
||||
}
|
||||
return promise
|
||||
}
|
||||
return (file: DiffFile, segment: FoldSegment): Promise<readonly DiffRow[]> =>
|
||||
sidesOf(file).then(sides => foldRowsFromContents(segment, sides.old, sides.new))
|
||||
}, [gitRef, gitScope, effectiveStaged, scope])
|
||||
|
||||
// ── Op target material (pure snapshots; the prior content came with the
|
||||
// target so a running op shows what is already known). ────────────────
|
||||
const op = target.kind === 'op' ? target.op : null
|
||||
const prior = target.kind === 'op' ? target.prior : undefined
|
||||
const opRaw = target.kind === 'op' ? target.op : null
|
||||
const priorRaw = target.kind === 'op' ? target.prior : undefined
|
||||
// Secret redaction: on by default, toggle persists per browser (localStorage).
|
||||
// Every op payload consumer below (diff rows, read rows, markdown source,
|
||||
// error text) renders from the REDACTED shape, so masked payloads are the
|
||||
// only thing that can reach the DOM while the toggle is on. Display-only:
|
||||
// session events and the fs layer keep their original bytes.
|
||||
const [redactionOn, setRedactionOn] = useState((): boolean => {
|
||||
try { return localStorage.getItem(REDACTION_KEY) !== '0' } catch { return true }
|
||||
})
|
||||
const toggleRedaction = (): void => {
|
||||
setRedactionOn((prev) => {
|
||||
const next = !prev
|
||||
try { localStorage.setItem(REDACTION_KEY, next ? '1' : '0') } catch { /* storage unavailable */ }
|
||||
return next
|
||||
})
|
||||
}
|
||||
const { op, prior, redactionHit } = useMemo(() => {
|
||||
if (opRaw === null || !redactionOn) return { op: opRaw, prior: priorRaw, redactionHit: false }
|
||||
const path = target.kind === 'op' ? target.path : ''
|
||||
// One redactText pass per field: the outcome carries both the masked
|
||||
// text and whether anything was hit.
|
||||
const mask = (text: string | undefined): { masked: string | undefined; hit: boolean } => {
|
||||
if (text === undefined) return { masked: undefined, hit: false }
|
||||
const outcome = redactText(path, text)
|
||||
return { masked: outcome.text, hit: outcome.hit }
|
||||
}
|
||||
const read = mask(opRaw.read)
|
||||
const content = mask(opRaw.content)
|
||||
const editOld = mask(opRaw.edit?.oldString)
|
||||
const editNew = mask(opRaw.edit?.newString)
|
||||
const errorText = mask(opRaw.errorText)
|
||||
const priorMasked = mask(priorRaw)
|
||||
const hit = read.hit || content.hit || editOld.hit || editNew.hit || errorText.hit || priorMasked.hit
|
||||
if (!hit) return { op: opRaw, prior: priorRaw, redactionHit: false }
|
||||
const redacted: FileOp = {
|
||||
...opRaw,
|
||||
...(read.masked !== undefined ? { read: read.masked } : {}),
|
||||
...(content.masked !== undefined ? { content: content.masked } : {}),
|
||||
...(opRaw.edit !== undefined
|
||||
? { edit: { oldString: editOld.masked ?? opRaw.edit.oldString, newString: editNew.masked ?? opRaw.edit.newString } }
|
||||
: {}),
|
||||
...(errorText.masked !== undefined ? { errorText: errorText.masked } : {}),
|
||||
}
|
||||
return { op: redacted, prior: priorMasked.masked, redactionHit: true }
|
||||
}, [opRaw, priorRaw, target, redactionOn])
|
||||
const opLang = useMemo(() => (target.kind === 'op' ? langOfPath(target.path) : undefined), [target])
|
||||
const opRows = useMemo(() => (op === null ? [] : diffOf(op, prior)), [op, prior])
|
||||
const opSegments = useMemo(() => buildDiffSegments(opRows), [opRows])
|
||||
@@ -158,6 +348,56 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
|
||||
[op],
|
||||
)
|
||||
|
||||
// ── Markdown reading mode: .md op targets (read/write/edit, non-error)
|
||||
// toggle between the raw/diff view and the rendered document — the same
|
||||
// shared MarkdownText pass the editor preview uses, with local image
|
||||
// destinations rewritten through the /sidebar/file media route. ──────
|
||||
const mdOp = target.kind === 'op' && !target.op.isError && /\.(md|markdown|mdx)$/i.test(target.path)
|
||||
const [reading, setReading] = useState(false)
|
||||
const readingSrc = useMemo(() => {
|
||||
if (!mdOp || op === null) return ''
|
||||
if (op.kind === 'read') return parseReadContent(op.read ?? '')
|
||||
if (op.kind === 'write') return op.content ?? ''
|
||||
if (op.kind === 'edit' && op.edit !== undefined) {
|
||||
if (prior !== undefined && prior.includes(op.edit.oldString)) {
|
||||
return prior.replace(op.edit.oldString, op.edit.newString)
|
||||
}
|
||||
return op.edit.newString
|
||||
}
|
||||
return ''
|
||||
}, [mdOp, op, prior])
|
||||
const readingText = useMemo(
|
||||
() => (mdOp && reading && readingSrc !== '' && target.kind === 'op'
|
||||
? rewriteLocalImageUrls(readingSrc, scope, target.path, window.location.origin)
|
||||
: ''),
|
||||
[mdOp, reading, readingSrc, scope, target],
|
||||
)
|
||||
|
||||
// ── HTML render mode: .html/.htm op targets (the editor html viewer's
|
||||
// ext set) load the SAVED file through the same /sidebar/html route the
|
||||
// editor's html viewer uses — relative assets (./style.css, img/x.png)
|
||||
// resolve inside the route, and a segmented read still renders the
|
||||
// whole document (the route serves the file, not the op snapshot). The
|
||||
// frame is always sandboxed (the attribute plus the route's CSP sandbox
|
||||
// header); the editor tab owns the warned no-sandbox escape hatch. ──
|
||||
const htmlOp = target.kind === 'op' && !target.op.isError && /\.(html?)$/i.test(target.path)
|
||||
const [rendering, setRendering] = useState(false)
|
||||
const htmlRenderSrc = useMemo(() => {
|
||||
if (!htmlOp || target.kind !== 'op') return ''
|
||||
return htmlUrl(scope, resolveSidebarPath(scope.cwd, target.path))
|
||||
}, [htmlOp, scope, target])
|
||||
|
||||
// ── PDF render mode: .pdf op targets (read / write / edit, non-error) reuse
|
||||
// the editor's PdfView verbatim — media-route bytes wrapped into an
|
||||
// explicitly-typed Blob so the browser's native PDF viewer opens (a
|
||||
// direct iframe src can fall back to a download). ──────────────────────
|
||||
const pdfOp = target.kind === 'op' && !target.op.isError && /\.pdf$/i.test(target.path)
|
||||
const [renderingPdf, setRenderingPdf] = useState(false)
|
||||
const pdfRenderPath = useMemo(() => {
|
||||
if (!pdfOp || target.kind !== 'op') return ''
|
||||
return resolveSidebarPath(scope.cwd, target.path)
|
||||
}, [pdfOp, scope, target])
|
||||
|
||||
// Header stats for git targets come off the parsed patch text.
|
||||
const gitStats = useMemo(() => {
|
||||
if (target.kind !== 'git' || diffText === null || diffText === '') return null
|
||||
@@ -264,6 +504,38 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{redactionHit && (
|
||||
<span className={css.redactBanner} role="status">{t('changesRedactBanner')}</span>
|
||||
)}
|
||||
{target.kind === 'op' && (
|
||||
<PaneToggle
|
||||
on={redactionOn}
|
||||
label={redactionOn ? t('changesRedactOnLabel') : t('changesRedactOffLabel')}
|
||||
title={redactionOn ? t('changesRedactOff') : t('changesRedactOn')}
|
||||
onClick={toggleRedaction}
|
||||
/>
|
||||
)}
|
||||
{mdOp && (
|
||||
<PaneToggle
|
||||
on={reading}
|
||||
label={t(reading ? 'changesMdRaw' : 'changesMdReading')}
|
||||
onClick={() => { setReading(value => !value) }}
|
||||
/>
|
||||
)}
|
||||
{htmlOp && (
|
||||
<PaneToggle
|
||||
on={rendering}
|
||||
label={t(rendering ? 'changesHtmlRaw' : 'changesHtmlRender')}
|
||||
onClick={() => { setRendering(value => !value) }}
|
||||
/>
|
||||
)}
|
||||
{pdfOp && (
|
||||
<PaneToggle
|
||||
on={renderingPdf}
|
||||
label={t(renderingPdf ? 'changesPdfRaw' : 'changesPdfRender')}
|
||||
onClick={() => { setRenderingPdf(value => !value) }}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
@@ -274,7 +546,17 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
|
||||
<IconCloseOutline16 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{target.kind === 'op' && op !== null && op.isError
|
||||
{target.kind === 'op' && htmlOp && rendering && htmlRenderSrc !== ''
|
||||
? <HtmlRenderPreview src={htmlRenderSrc} title={target.path} />
|
||||
: target.kind === 'op' && pdfOp && renderingPdf && pdfRenderPath !== ''
|
||||
? (
|
||||
<div className={css.htmlPane}>
|
||||
<PdfView scope={scope} path={pdfRenderPath} title={target.path} />
|
||||
</div>
|
||||
)
|
||||
: target.kind === 'op' && mdOp && reading && readingText !== ''
|
||||
? <MdReadingView text={readingText} />
|
||||
: target.kind === 'op' && op !== null && op.isError
|
||||
? (
|
||||
<div className={css.paneBody}>
|
||||
<div className={css.readError} role="alert">
|
||||
@@ -306,6 +588,7 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
|
||||
{diffText !== null && diffText !== '' && (
|
||||
<DiffFiles
|
||||
diff={diffText}
|
||||
resolveFold={foldLoader}
|
||||
untrackedPath={untracked !== undefined && target.ref.kind === 'worktree' ? target.ref.path : undefined}
|
||||
untrackedContent={untracked}
|
||||
/>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { GitLogEntry, GitStatusEntry, GitStatusResult, GitWorktree, SessionScope } from '../api.ts'
|
||||
import { api } from '../api.ts'
|
||||
import { usePolling } from '../use-polling.ts'
|
||||
import { baseName, isWithinWorkspace, relativeTo } from '../paths.ts'
|
||||
import { resolveSidebarPath } from '../produced-files.ts'
|
||||
import { relativeTime, t } from '../locales.ts'
|
||||
@@ -168,6 +169,9 @@ export function GitLens(props: GitLensProps) {
|
||||
} finally {
|
||||
if (options.loading && options.generation === refreshGeneration.current) setLoading(false)
|
||||
}
|
||||
// Granular scope fields: the scope object's identity churns, only its
|
||||
// sessionId / cwd fields gate the git target.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scope.sessionId, scope.cwd, repoRoot])
|
||||
|
||||
const refresh = useCallback(async (silent = false): Promise<void> => {
|
||||
@@ -231,6 +235,9 @@ export function GitLens(props: GitLensProps) {
|
||||
} finally {
|
||||
refreshInFlight.current = false
|
||||
}
|
||||
// Granular scope fields: the scope object's identity churns, only its
|
||||
// sessionId / cwd fields gate the refresh target.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scope.sessionId, scope.cwd, refreshTarget])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -274,11 +281,11 @@ export function GitLens(props: GitLensProps) {
|
||||
const generation = refreshGeneration.current += 1
|
||||
void refreshTarget(chosenPathRef.current ?? '', { loading: true, generation })
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
const timer = window.setInterval(() => { void refresh(true) }, 2_000)
|
||||
return () => { window.clearInterval(timer) }
|
||||
}, [visible, refresh])
|
||||
/** The silent poll tick (the status-only fast path between worktree
|
||||
* re-lists, see refresh) — fixed 2s cadence while visible, no initial
|
||||
* burst (mount and scope changes already refresh above). */
|
||||
const pollTick = useCallback((): Promise<void> => refresh(true), [refresh])
|
||||
usePolling(visible, pollTick, { intervalMs: 2_000 })
|
||||
|
||||
/** Append the next history page (lazy: only when the user asks for more). */
|
||||
const loadMoreLog = async (): Promise<void> => {
|
||||
@@ -664,6 +671,7 @@ export function GitLens(props: GitLensProps) {
|
||||
if (id === 'absolute') copy(resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, target.entry.path))
|
||||
}}
|
||||
portal
|
||||
compact
|
||||
align="start"
|
||||
getAnchorRect={() => (fileMenu === null ? null : new DOMRect(fileMenu.x, fileMenu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
@@ -721,6 +729,7 @@ export function GitLens(props: GitLensProps) {
|
||||
}
|
||||
}}
|
||||
portal
|
||||
compact
|
||||
align="start"
|
||||
getAnchorRect={() => (historyMenu === null ? null : new DOMRect(historyMenu.x, historyMenu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
|
||||
@@ -342,6 +342,14 @@
|
||||
|
||||
/* ── Shared preview pane ───────────────────────────────────────────────── */
|
||||
|
||||
.redactBanner { flex: none; font: var(--dsw-font-xxxs-strong-11); padding: 2px 8px; border-radius: 7px; color: var(--dsw-alias-state-warn-primary); background: var(--dsw-alias-interactive-bg-hover); white-space: nowrap; }
|
||||
.mdToggle { flex: none; border: 1px solid var(--dsw-alias-border-l1); border-radius: 7px; background: transparent; color: var(--dsw-alias-label-secondary); font: var(--dsw-font-xxxs-strong-11); padding: 2px 8px; cursor: pointer; }
|
||||
.mdToggle:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
|
||||
.mdToggle[data-on='true'] { border-color: var(--dsw-alias-state-business-primary); color: var(--dsw-alias-state-business-primary); }
|
||||
.mdBody { padding: 12px 16px 20px; font: var(--dsw-font-xs-regular-13); line-height: 1.6; min-height: 0; overflow-y: auto; }
|
||||
.htmlPane { flex: 1; min-height: 0; display: flex; }
|
||||
.htmlFrame { flex: 1; width: 100%; border: none; background: var(--dsw-alias-bg-base); }
|
||||
|
||||
.diffPane { border-top: 1px solid var(--dsw-alias-border-l1); display: flex; flex-direction: column; flex: none; min-height: 0; }
|
||||
.dragHandle { flex: none; height: 8px; cursor: ns-resize; position: relative; background: var(--dsw-alias-bg-base); }
|
||||
.dragHandle::after { content: ''; position: absolute; left: calc(50% - 18px); top: 3px; width: 36px; height: 2px; border-radius: 1px; background: var(--dsw-alias-border-l2); }
|
||||
|
||||
@@ -201,6 +201,33 @@ export function knownContentBefore(ops: readonly FileOp[], path: string, before:
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The read-tool response's `<content>` body split into lines with the
|
||||
* "(Showing lines …)" note dropped — the shared envelope core both read
|
||||
* parsers consume: {@link parseReadLines} recovers line numbers from the
|
||||
* "<n>: " prefixes, {@link parseReadContent} keeps the raw text.
|
||||
*/
|
||||
function contentLines(raw: string): string[] {
|
||||
const contentMatch = raw.match(/<content>([\s\S]*?)<\/content>/)
|
||||
const body = contentMatch ? contentMatch[1]! : raw
|
||||
return body.split('\n').filter((line) => !/^\s*\(Showing lines .*\)\s*$/.test(line))
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the DSH read-tool response envelope down to the file's raw content,
|
||||
* PRESERVING blank lines (unlike {@link parseReadLines}, which drops them):
|
||||
* drops the <content> wrapper, the "(Showing lines ...)" note, and the
|
||||
* per-line "<n>: " prefixes. The markdown reading mode needs the blank lines
|
||||
* because markdown block structure depends on them.
|
||||
*/
|
||||
export function parseReadContent(raw: string): string {
|
||||
return contentLines(raw)
|
||||
.map((line) => line.replace(/^\s*\d+:\s/, ''))
|
||||
.join('\n')
|
||||
.replace(/^[\n]+/, '')
|
||||
.replace(/\n+$/, '')
|
||||
}
|
||||
|
||||
/** One parsed read line: the file's own line number and its content text. */
|
||||
export interface ReadLine { readonly line: number; readonly text: string }
|
||||
|
||||
@@ -210,12 +237,9 @@ export interface ReadLine { readonly line: number; readonly text: string }
|
||||
* "<n>: " prefix as the line number, falling back to sequential counting.
|
||||
*/
|
||||
export function parseReadLines(raw: string): ReadLine[] {
|
||||
const contentMatch = raw.match(/<content>([\s\S]*?)<\/content>/)
|
||||
const body = contentMatch ? contentMatch[1]! : raw
|
||||
const result: ReadLine[] = []
|
||||
let fallback = 1
|
||||
for (const line of body.split('\n')) {
|
||||
if (/^\s*\(Showing lines .*\)\s*$/.test(line)) continue
|
||||
for (const line of contentLines(raw)) {
|
||||
if (line.length === 0) continue
|
||||
const match = line.match(/^\s*(\d+):\s?(.*)$/)
|
||||
if (match !== null) {
|
||||
|
||||
@@ -23,8 +23,9 @@
|
||||
* arbitrary file names, so the plugin's own host route serves the chunks),
|
||||
* 2. read the factory from the global registry,
|
||||
* 3. call it with a require that resolves the platform externals through
|
||||
* `__DSH_MODULES__.import(spec)` — the seed-word branch, the one part of
|
||||
* the module system that is stable across versions.
|
||||
* the injected module system's `import(spec)` (the `ctx.modules` service)
|
||||
* — the seed-word branch, the one part of the module system that is
|
||||
* stable across versions.
|
||||
*
|
||||
* Caching contract (three layers, each with a failure path):
|
||||
* - In-memory: one in-flight promise per chunk, memoized until
|
||||
@@ -90,8 +91,7 @@ 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.
|
||||
* {@link setChunkModuleSystem}.
|
||||
*/
|
||||
export interface ChunkModuleSystem {
|
||||
import(specifier: string): Promise<unknown>
|
||||
@@ -123,12 +123,11 @@ export function setChunkModuleSystem(system: ChunkModuleSystem | undefined): voi
|
||||
}
|
||||
|
||||
/** Resolve the shell-installed module system (injected, then the plugin
|
||||
* global shared with chunk-bundle copies, then the rc.7 page global). */
|
||||
* global shared with chunk-bundle copies). */
|
||||
function moduleSystem(): ChunkModuleSystem | undefined {
|
||||
const g = globalThis as Record<string, unknown>
|
||||
return injectedModuleSystem
|
||||
?? g[MODULE_SYSTEM_GLOBAL] as ChunkModuleSystem | undefined
|
||||
?? (g as { __DSH_MODULES__?: ChunkModuleSystem }).__DSH_MODULES__
|
||||
}
|
||||
|
||||
/** The plugin-owned chunk factory registry the chunk scripts populate. */
|
||||
@@ -244,8 +243,7 @@ export async function loadChunk(name: ChunkName): Promise<ChunkExports> {
|
||||
if (revalidation !== null) await revalidation
|
||||
const cached = cache.get(name)
|
||||
if (cached !== undefined) return cached
|
||||
let task: Promise<ChunkExports>
|
||||
task = (async (): Promise<ChunkExports> => {
|
||||
const task: Promise<ChunkExports> = (async (): Promise<ChunkExports> => {
|
||||
const test = testLoaders.get(name)
|
||||
if (test !== undefined) return test()
|
||||
const modules = moduleSystem()
|
||||
|
||||
+44
-42
@@ -2,15 +2,17 @@
|
||||
* CodeMirror 6 theme pieces for the sidebar editor. The editor surface
|
||||
* (background, caret, gutter) rides the DSH theme tokens so it blends with
|
||||
* the panel in both schemes; only the syntax token colors need concrete
|
||||
* values, and those come from the same designed palettes the app's code
|
||||
* surfaces use — the one-dark family for dark, the one-light family for
|
||||
* light. The scheme flip reconfigures these via a compartment (see
|
||||
* TextEditor), so the document, undo history and scroll survive re-theming.
|
||||
* values, and those come from the same designed syntax families the app's
|
||||
* code surfaces use — one-dark for dark, one-light for light
|
||||
* (one-dark-palette.ts, shared with the terminal's ANSI palette). The
|
||||
* scheme flip reconfigures these via a compartment (see TextEditor), so
|
||||
* the document, undo history and scroll survive re-theming.
|
||||
*/
|
||||
import { Compartment } from '@codemirror/state'
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
import { tags, type Tag } from '@lezer/highlight'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { ONE_DARK, ONE_LIGHT } from './one-dark-palette.ts'
|
||||
|
||||
/** Token-driven surface shared by both schemes (pure CSS values). */
|
||||
export const cmSurfaceTheme = EditorView.theme({
|
||||
@@ -53,52 +55,52 @@ interface HighlightRule {
|
||||
fontStyle?: string
|
||||
}
|
||||
|
||||
/** one-dark syntax palette (mirrors @codemirror/theme-one-dark). */
|
||||
/** one-dark syntax rules (mirrors @codemirror/theme-one-dark; hues from one-dark-palette.ts). */
|
||||
const HIGHLIGHTS_DARK: HighlightRule[] = [
|
||||
{ tag: tags.comment, color: '#5c6370', fontStyle: 'italic' },
|
||||
{ tag: tags.keyword, color: '#c678dd' },
|
||||
{ tag: tags.string, color: '#98c379' },
|
||||
{ tag: tags.number, color: '#d19a66' },
|
||||
{ tag: tags.bool, color: '#d19a66' },
|
||||
{ tag: tags.atom, color: '#d19a66' },
|
||||
{ tag: tags.typeName, color: '#e5c07b' },
|
||||
{ tag: tags.className, color: '#e5c07b' },
|
||||
{ tag: tags.propertyName, color: '#e06c75' },
|
||||
{ tag: tags.function(tags.variableName), color: '#61afef' },
|
||||
{ tag: tags.variableName, color: '#e06c75' },
|
||||
{ tag: tags.operator, color: '#56b6c2' },
|
||||
{ tag: tags.tagName, color: '#e06c75' },
|
||||
{ tag: tags.attributeName, color: '#d19a66' },
|
||||
{ tag: tags.heading, color: '#e06c75', fontStyle: 'bold' },
|
||||
{ tag: tags.comment, color: ONE_DARK.faintGray, fontStyle: 'italic' },
|
||||
{ tag: tags.keyword, color: ONE_DARK.magenta },
|
||||
{ tag: tags.string, color: ONE_DARK.green },
|
||||
{ tag: tags.number, color: ONE_DARK.orange },
|
||||
{ tag: tags.bool, color: ONE_DARK.orange },
|
||||
{ tag: tags.atom, color: ONE_DARK.orange },
|
||||
{ tag: tags.typeName, color: ONE_DARK.yellow },
|
||||
{ tag: tags.className, color: ONE_DARK.yellow },
|
||||
{ tag: tags.propertyName, color: ONE_DARK.red },
|
||||
{ tag: tags.function(tags.variableName), color: ONE_DARK.blue },
|
||||
{ tag: tags.variableName, color: ONE_DARK.red },
|
||||
{ tag: tags.operator, color: ONE_DARK.cyan },
|
||||
{ tag: tags.tagName, color: ONE_DARK.red },
|
||||
{ tag: tags.attributeName, color: ONE_DARK.orange },
|
||||
{ tag: tags.heading, color: ONE_DARK.red, fontStyle: 'bold' },
|
||||
{ tag: tags.emphasis, fontStyle: 'italic' },
|
||||
{ tag: tags.strong, fontStyle: 'bold' },
|
||||
{ tag: tags.link, color: '#61afef', fontStyle: 'underline' },
|
||||
{ tag: tags.meta, color: '#e5c07b' },
|
||||
{ tag: tags.invalid, color: '#ffffff', fontStyle: 'bold' },
|
||||
{ tag: tags.link, color: ONE_DARK.blue, fontStyle: 'underline' },
|
||||
{ tag: tags.meta, color: ONE_DARK.yellow },
|
||||
{ tag: tags.invalid, color: ONE_DARK.white, fontStyle: 'bold' },
|
||||
]
|
||||
|
||||
/** one-light syntax palette (the light counterpart of one-dark). */
|
||||
/** one-light syntax rules (the light counterpart; hues from one-dark-palette.ts). */
|
||||
const HIGHLIGHTS_LIGHT: HighlightRule[] = [
|
||||
{ tag: tags.comment, color: '#a0a1a7', fontStyle: 'italic' },
|
||||
{ tag: tags.keyword, color: '#a626a4' },
|
||||
{ tag: tags.string, color: '#50a14f' },
|
||||
{ tag: tags.number, color: '#986801' },
|
||||
{ tag: tags.bool, color: '#0184bc' },
|
||||
{ tag: tags.atom, color: '#0184bc' },
|
||||
{ tag: tags.typeName, color: '#c18401' },
|
||||
{ tag: tags.className, color: '#c18401' },
|
||||
{ tag: tags.propertyName, color: '#e45649' },
|
||||
{ tag: tags.function(tags.variableName), color: '#c18401' },
|
||||
{ tag: tags.variableName, color: '#e45649' },
|
||||
{ tag: tags.operator, color: '#383a42' },
|
||||
{ tag: tags.tagName, color: '#e45649' },
|
||||
{ tag: tags.attributeName, color: '#986801' },
|
||||
{ tag: tags.heading, color: '#e45649', fontStyle: 'bold' },
|
||||
{ tag: tags.comment, color: ONE_LIGHT.gray, fontStyle: 'italic' },
|
||||
{ tag: tags.keyword, color: ONE_LIGHT.magenta },
|
||||
{ tag: tags.string, color: ONE_LIGHT.green },
|
||||
{ tag: tags.number, color: ONE_LIGHT.orange },
|
||||
{ tag: tags.bool, color: ONE_LIGHT.blue },
|
||||
{ tag: tags.atom, color: ONE_LIGHT.blue },
|
||||
{ tag: tags.typeName, color: ONE_LIGHT.yellow },
|
||||
{ tag: tags.className, color: ONE_LIGHT.yellow },
|
||||
{ tag: tags.propertyName, color: ONE_LIGHT.red },
|
||||
{ tag: tags.function(tags.variableName), color: ONE_LIGHT.yellow },
|
||||
{ tag: tags.variableName, color: ONE_LIGHT.red },
|
||||
{ tag: tags.operator, color: ONE_LIGHT.black },
|
||||
{ tag: tags.tagName, color: ONE_LIGHT.red },
|
||||
{ tag: tags.attributeName, color: ONE_LIGHT.orange },
|
||||
{ tag: tags.heading, color: ONE_LIGHT.red, fontStyle: 'bold' },
|
||||
{ tag: tags.emphasis, fontStyle: 'italic' },
|
||||
{ tag: tags.strong, fontStyle: 'bold' },
|
||||
{ tag: tags.link, color: '#4078f2', fontStyle: 'underline' },
|
||||
{ tag: tags.meta, color: '#c18401' },
|
||||
{ tag: tags.invalid, color: '#ffffff', fontStyle: 'bold' },
|
||||
{ tag: tags.link, color: ONE_LIGHT.link, fontStyle: 'underline' },
|
||||
{ tag: tags.meta, color: ONE_LIGHT.yellow },
|
||||
{ tag: tags.invalid, color: ONE_LIGHT.white, fontStyle: 'bold' },
|
||||
]
|
||||
|
||||
/** The scheme-dependent extension pair (surface tint + syntax highlight). */
|
||||
|
||||
@@ -203,6 +203,7 @@ export function appendToDraft(ctx: Context, sessionId: string, text: string): bo
|
||||
*/
|
||||
export function fileMention(relativePath: string): { mention: string; label: string } | undefined {
|
||||
const path = relativePath.replace(/[\\/]+$/, '')
|
||||
// eslint-disable-next-line no-control-regex -- rejecting control characters is the point of this guard
|
||||
if (/[\u0000-\u001f\u007f-\u009f"]/u.test(path)) return undefined
|
||||
const mention = /\s/u.test(path) ? `@"${path}"` : `@${path}`
|
||||
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
||||
|
||||
@@ -70,3 +70,24 @@ function parseTitlebarInset(raw: string | null): number {
|
||||
export function resetDesktopEnvForTests(): void {
|
||||
cached = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The element the sidebar's panel host belongs in.
|
||||
*
|
||||
* The desktop shell stamps the upstream `#root` with
|
||||
* `data-dsh-desktop-content-viewport` and gives it `isolation: isolate` — a
|
||||
* stacking context around the whole app INCLUDING the shell's own overlay
|
||||
* layer (`.dshDesktopOverlay`, z-index 1000), which is where a plugin's modal
|
||||
* renders (the community market's, for one). A host parented to
|
||||
* `document.body` sits OUTSIDE that context, so the panel layer's documented
|
||||
* 25 outranks everything inside it and covered host modals; inside the
|
||||
* viewport the same 25 keeps its meaning — above the app frame (z-index auto)
|
||||
* and below the overlay layer (1000).
|
||||
*
|
||||
* Every other deployment (plain web, an unframed shell) keeps the body mount.
|
||||
* @param doc - the document to resolve against.
|
||||
* @returns the content viewport when the shell framed one, else the body.
|
||||
*/
|
||||
export function panelHostParent(doc: Document = document): HTMLElement {
|
||||
return doc.querySelector<HTMLElement>('#root[data-dsh-desktop-content-viewport]') ?? doc.body
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
* Untracked files produce no `git diff` output; the caller passes their
|
||||
* content to render as a full-file addition instead.
|
||||
*/
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { t } from '../locales.ts'
|
||||
import { diffStats, displayPath, parseUnifiedDiff, unifiedSegments, untrackedFile, type DiffFile } from './rows.ts'
|
||||
import { diffStats, displayPath, parseUnifiedDiff, unifiedSegments, untrackedFile, type DiffFile, type DiffRow, type FoldSegment } from './rows.ts'
|
||||
import { langOfPath } from './highlight.ts'
|
||||
import { DiffRows } from './DiffRows.tsx'
|
||||
import css from './diff.module.css'
|
||||
@@ -51,9 +51,14 @@ export interface DiffFilesProps {
|
||||
/** Untracked-file content: when present, renders as a full-file addition instead of parsing. */
|
||||
untrackedPath?: string
|
||||
untrackedContent?: string
|
||||
/** Fetch a git gap fold's hidden rows on demand (both sides' contents by
|
||||
* the fold's line ranges); forwarded to every file's DiffRows. Absent
|
||||
* folds without `rows` stay non-expandable (session-op diffs and
|
||||
* untracked additions always carry theirs). */
|
||||
resolveFold?: (file: DiffFile, segment: FoldSegment) => Promise<readonly DiffRow[]>
|
||||
}
|
||||
|
||||
export function DiffFiles({ diff, untrackedPath, untrackedContent }: DiffFilesProps) {
|
||||
export function DiffFiles({ diff, untrackedPath, untrackedContent, resolveFold }: DiffFilesProps) {
|
||||
const parsed = useMemo(() => {
|
||||
if (untrackedPath !== undefined) {
|
||||
return { files: [untrackedFile(untrackedPath, untrackedContent ?? '')] }
|
||||
@@ -63,6 +68,26 @@ export function DiffFiles({ diff, untrackedPath, untrackedContent }: DiffFilesPr
|
||||
const [expandedFiles, setExpandedFiles] = useState<Set<number>>(() => defaultExpandedFiles(parsed.files))
|
||||
useEffect(() => { setExpandedFiles(defaultExpandedFiles(parsed.files)) }, [parsed])
|
||||
|
||||
// One in-flight/resolved promise per file+fold key, so a fold re-clicked
|
||||
// after a remount (file header collapsed and re-expanded) resolves without
|
||||
// a second fetch and concurrent clicks join the same request. The cache
|
||||
// dies with `parsed` (a new diff text); rejected promises are dropped so a
|
||||
// later retry can go through.
|
||||
const foldCache = useRef(new Map<string, Promise<readonly DiffRow[]>>())
|
||||
useEffect(() => { foldCache.current = new Map() }, [parsed])
|
||||
const loadFold = (file: DiffFile, segment: FoldSegment): Promise<readonly DiffRow[]> => {
|
||||
if (resolveFold === undefined) return Promise.resolve([])
|
||||
const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath)
|
||||
const key = `${path}|${String(segment.oldStart)}|${String(segment.newStart)}`
|
||||
let promise = foldCache.current.get(key)
|
||||
if (promise === undefined) {
|
||||
promise = resolveFold(file, segment)
|
||||
foldCache.current.set(key, promise)
|
||||
promise.catch(() => { foldCache.current.delete(key) })
|
||||
}
|
||||
return promise
|
||||
}
|
||||
|
||||
// Segments and header stats computed once per file.
|
||||
const files = useMemo(
|
||||
() => parsed.files.map((file) => {
|
||||
@@ -107,7 +132,7 @@ export function DiffFiles({ diff, untrackedPath, untrackedContent }: DiffFilesPr
|
||||
)}
|
||||
</button>
|
||||
{expandable && fileExpanded && (
|
||||
<DiffRows segments={segments} lang={langOfPath(to)} />
|
||||
<DiffRows segments={segments} lang={langOfPath(to)} resolveFold={resolveFold !== undefined ? (segment) => loadFold(file, segment) : undefined} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
* line gutters, rewrite (mod) tinting with intra-line character highlights,
|
||||
* lightweight syntax coloring, and long-line folding. Presentational — the
|
||||
* segments arrive precomputed (session ops via buildDiffSegments, git diffs
|
||||
* via unifiedSegments) so both producers render identically.
|
||||
* via unifiedSegments) so both producers render identically; the one
|
||||
* exception is git gap folds, whose rows git never emitted and a caller
|
||||
* supplies on demand through resolveFold (first click fetches, then they
|
||||
* toggle like any fold).
|
||||
*/
|
||||
import { useEffect, useMemo, useState, type ReactElement, type ReactNode } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from 'react'
|
||||
import { t } from '../locales.ts'
|
||||
import { coalesceInline, diffInline, MIN_FOLD, type DiffRow, type DiffSegment, type InlineDiff } from './rows.ts'
|
||||
import { coalesceInline, diffInline, MIN_FOLD, type DiffRow, type DiffSegment, type FoldSegment, type InlineDiff } from './rows.ts'
|
||||
import { hasBlockComment, isColored, scanLine, type CodeToken, type TokenType } from './highlight.ts'
|
||||
import css from './diff.module.css'
|
||||
|
||||
@@ -105,21 +108,36 @@ export interface DiffRowsProps {
|
||||
segments: readonly DiffSegment[]
|
||||
/** Syntax language id (langOfPath); undefined renders plain text. */
|
||||
lang?: string
|
||||
/** Fetch a git gap fold's hidden rows on demand (both sides' contents
|
||||
* sliced by the fold's line ranges). Absent folds without `rows` — git's
|
||||
* unemitted gaps without a resolver — stay non-expandable markers. */
|
||||
resolveFold?: (segment: FoldSegment) => Promise<readonly DiffRow[]>
|
||||
}
|
||||
|
||||
/** One file's diff rows: fold chips between hunks, highlighted code rows. */
|
||||
export function DiffRows({ segments, lang }: DiffRowsProps) {
|
||||
export function DiffRows({ segments, lang, resolveFold }: DiffRowsProps) {
|
||||
// Long diff lines fold to one ellipsized row; the set holds expanded row keys.
|
||||
const [expandedLines, setExpandedLines] = useState<ReadonlySet<string>>(new Set())
|
||||
// Hunk-fold segments expanded by index; default collapsed.
|
||||
const [expandedFolds, setExpandedFolds] = useState<ReadonlySet<number>>(new Set())
|
||||
// Row-count cap expanded: a huge file renders head rows plus this button.
|
||||
const [expandedAll, setExpandedAll] = useState(false)
|
||||
// On-demand fold expansion (git gaps): resolved rows per segment index,
|
||||
// the loading/failed markers, and an epoch that invalidates in-flight
|
||||
// resolves when the segments identity changes.
|
||||
const [foldData, setFoldData] = useState<ReadonlyMap<number, readonly DiffRow[]>>(new Map())
|
||||
const [foldLoading, setFoldLoading] = useState<ReadonlySet<number>>(new Set())
|
||||
const [foldFailed, setFoldFailed] = useState<ReadonlySet<number>>(new Set())
|
||||
const foldEpoch = useRef(0)
|
||||
// Reset all folding when the segments identity changes (new target).
|
||||
useEffect(() => {
|
||||
setExpandedLines(new Set())
|
||||
setExpandedFolds(new Set())
|
||||
setExpandedAll(false)
|
||||
setFoldData(new Map())
|
||||
setFoldLoading(new Set())
|
||||
setFoldFailed(new Set())
|
||||
foldEpoch.current += 1
|
||||
}, [segments])
|
||||
|
||||
const inlineMap = useMemo(() => buildInlineMap(segments), [segments])
|
||||
@@ -190,7 +208,19 @@ export function DiffRows({ segments, lang }: DiffRowsProps) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const expandable = segment.rows !== undefined && segment.count >= MIN_FOLD
|
||||
if (foldFailed.has(segIndex)) {
|
||||
// The on-demand fetch failed: a quiet, non-clickable marker — the
|
||||
// expansion promise is gone, so no interaction is promised either.
|
||||
return (
|
||||
<div key={`fold-${String(segIndex)}`} className={css.foldRow}>
|
||||
<span className={css.foldMarker} title={t('changesContext')}>
|
||||
{t('changesFoldUnavailable')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const resolvedRows = foldData.get(segIndex)
|
||||
const expandable = (segment.rows !== undefined || resolvedRows !== undefined || resolveFold !== undefined) && segment.count >= MIN_FOLD
|
||||
if (!expandable) {
|
||||
// A tiny fold (or a git gap with no rows to reveal): a quiet marker.
|
||||
return (
|
||||
@@ -201,27 +231,49 @@ export function DiffRows({ segments, lang }: DiffRowsProps) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const loading = foldLoading.has(segIndex)
|
||||
const isExpanded = expandedFolds.has(segIndex)
|
||||
const revealed = segment.rows ?? resolvedRows
|
||||
return (
|
||||
<div
|
||||
key={`fold-${String(segIndex)}`}
|
||||
className={css.foldRow}
|
||||
data-expandable="true"
|
||||
data-expandable={loading ? undefined : 'true'}
|
||||
data-expanded={isExpanded ? 'true' : undefined}
|
||||
onClick={() => {
|
||||
setExpandedFolds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(segIndex)) next.delete(segIndex)
|
||||
else next.add(segIndex)
|
||||
return next
|
||||
})
|
||||
onClick={loading ? undefined : () => {
|
||||
if (revealed !== undefined) {
|
||||
setExpandedFolds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(segIndex)) next.delete(segIndex)
|
||||
else next.add(segIndex)
|
||||
return next
|
||||
})
|
||||
return
|
||||
}
|
||||
// A git gap fold's first click: fetch its rows, then expand.
|
||||
if (resolveFold === undefined) return
|
||||
const epoch = foldEpoch.current
|
||||
setFoldLoading(prev => new Set(prev).add(segIndex))
|
||||
resolveFold(segment).then(
|
||||
(rows) => {
|
||||
if (foldEpoch.current !== epoch) return
|
||||
setFoldData(prev => new Map(prev).set(segIndex, rows))
|
||||
setFoldLoading(prev => { const next = new Set(prev); next.delete(segIndex); return next })
|
||||
setExpandedFolds(prev => new Set(prev).add(segIndex))
|
||||
},
|
||||
() => {
|
||||
if (foldEpoch.current !== epoch) return
|
||||
setFoldLoading(prev => { const next = new Set(prev); next.delete(segIndex); return next })
|
||||
setFoldFailed(prev => new Set(prev).add(segIndex))
|
||||
},
|
||||
)
|
||||
}}
|
||||
>
|
||||
{isExpanded
|
||||
? segment.rows!.map((row, index) => renderDiffRow(row, `${segIndex}-${String(index)}`))
|
||||
{isExpanded && revealed !== undefined
|
||||
? revealed.map((row, index) => renderDiffRow(row, `${segIndex}-${String(index)}`))
|
||||
: (
|
||||
<span className={css.foldMarker} title={t('changesContext')}>
|
||||
{t('changesFold', { count: segment.count })}
|
||||
{loading ? t('changesFoldLoading') : t('changesFold', { count: segment.count })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -132,28 +132,77 @@ const MD_LANG: LangConfig = {
|
||||
wordBody: WORD,
|
||||
}
|
||||
|
||||
/** JS keyword list, shared by js/jsx/mjs/cjs. */
|
||||
const JS_WORDS = `async await break case catch class const continue debugger default delete do else
|
||||
export extends false finally for from function get if implements import in instanceof interface
|
||||
let new null of return set static super switch this throw true try typeof undefined var void
|
||||
while with yield`
|
||||
|
||||
/** TS keyword list, shared by ts/tsx/mts/cts. */
|
||||
const TS_WORDS = `abstract any as asserts async await boolean break case catch class const constructor
|
||||
continue debugger declare default delete do else enum export extends false finally for from
|
||||
function get if implements import in infer instanceof interface is keyof let module namespace
|
||||
never new null number object of override private protected public readonly return satisfies set
|
||||
static string super switch symbol this throw true try type typeof undefined union unknown var
|
||||
void while with yield`
|
||||
|
||||
/** CSS family: block comments delimited by slash-star, hyphenated property words. */
|
||||
const CSS_LANG: LangConfig = {
|
||||
lineComments: [],
|
||||
blockComment: ['/*', '*/'],
|
||||
strings: ['"', "'"],
|
||||
keywords: kw(`media import charset keyframes font-face supports page namespace layer scope container
|
||||
property value at-rule when and not only from to important
|
||||
background background-color background-image background-position background-size
|
||||
border border-radius border-color border-width border-style bottom box-shadow box-sizing
|
||||
color content cursor clip clip-path clear display direction
|
||||
flex flex-direction flex-wrap flex-flow flex-grow flex-shrink flex-basis
|
||||
font font-family font-size font-style font-weight float fill
|
||||
grid grid-area grid-template grid-template-columns grid-template-rows grid-gap gap
|
||||
height left letter-spacing line-height list-style margin margin-top margin-right
|
||||
margin-bottom margin-left max-height max-width min-height min-width opacity order
|
||||
outline overflow padding padding-top padding-right padding-bottom padding-left
|
||||
position pointer-events right rotate scale translate transform transform-origin
|
||||
text-align text-decoration text-transform top transition transition-property
|
||||
user-select vertical-align visibility white-space width word-break word-spacing z-index
|
||||
align-items align-content align-self justify-content justify-items justify-self
|
||||
aspect-ratio inset object-fit object-position resize scroll-behavior filter backdrop-filter
|
||||
animation animation-name animation-duration animation-timing-function animation-delay
|
||||
will-change
|
||||
html body p a div span li ul ol table tr td th form input button label select textarea img
|
||||
section header footer main nav article aside h1 h2 h3 h4 h5 h6 i b em strong small pre code
|
||||
inherit initial unset auto none fixed absolute relative sticky static flex block inline
|
||||
inline-block inline-flex grid hidden visible bold normal root var calc env`),
|
||||
constants: new Set(),
|
||||
macro: false,
|
||||
wordStart: /[A-Za-z-]/u,
|
||||
wordBody: /[A-Za-z0-9-]/u,
|
||||
}
|
||||
|
||||
/** Markup family (HTML/XML/SVG/Vue): markup comments plus common tags. */
|
||||
const MARKUP_LANG: LangConfig = {
|
||||
lineComments: [],
|
||||
blockComment: ['<!--', '-->'],
|
||||
strings: ['"', "'"],
|
||||
keywords: kw(`html head body title meta link script style template slot
|
||||
div span p a img ul ol li table thead tbody tr th td form input button label select
|
||||
option textarea header footer main nav section article aside figure figcaption
|
||||
h1 h2 h3 h4 h5 h6 strong em b i u s small br hr pre code blockquote
|
||||
svg path circle rect line polyline polygon g defs use text symbol
|
||||
xml doctype class id href src type value name content charset async defer
|
||||
vue component props setup script-style export import v-if v-for v-bind v-on`),
|
||||
constants: new Set(),
|
||||
macro: false,
|
||||
wordStart: LETTER,
|
||||
wordBody: WORD,
|
||||
}
|
||||
|
||||
/** Extension → language id, mirroring the read tool's hint table. */
|
||||
const LANGS: Readonly<Record<string, LangConfig>> = {
|
||||
ts: C_FAMILY(`abstract any as asserts async await boolean break case catch class const constructor
|
||||
continue debugger declare default delete do else enum export extends false finally for from
|
||||
function get if implements import in infer instanceof interface is keyof let module namespace
|
||||
never new null number object of override private protected public readonly return satisfies set
|
||||
static string super switch symbol this throw true try type typeof undefined union unknown var
|
||||
void while with yield`),
|
||||
tsx: C_FAMILY(`abstract any as asserts async await boolean break case catch class const constructor
|
||||
continue declare default delete do else enum export extends false finally for from function get
|
||||
if implements import in infer instanceof interface is keyof let module namespace never new null
|
||||
number object of override private protected public readonly return satisfies set static string
|
||||
super switch symbol this throw true try type typeof undefined union unknown var void while with
|
||||
yield`),
|
||||
js: C_FAMILY(`async await break case catch class const continue debugger default delete do else
|
||||
export extends false finally for from function get if implements import in instanceof interface
|
||||
let new null of return set static super switch this throw true try typeof undefined var void
|
||||
while with yield`),
|
||||
jsx: C_FAMILY(`async await break case catch class const continue debugger default delete do else
|
||||
export extends false finally for from function get if implements import in instanceof interface
|
||||
let new null of return set static super switch this throw true try typeof undefined var void
|
||||
while with yield`),
|
||||
ts: C_FAMILY(TS_WORDS),
|
||||
tsx: C_FAMILY(TS_WORDS),
|
||||
js: C_FAMILY(JS_WORDS),
|
||||
jsx: C_FAMILY(JS_WORDS),
|
||||
json: CONFIG_LANG,
|
||||
py: HASH_FAMILY(`and as assert async await break class continue def del elif else except finally
|
||||
for from global if import in is lambda nonlocal not or pass raise return try while with yield
|
||||
@@ -225,11 +274,22 @@ const LANGS: Readonly<Record<string, LangConfig>> = {
|
||||
md: MD_LANG,
|
||||
markdown: MD_LANG,
|
||||
mdx: MD_LANG,
|
||||
html: CONFIG_LANG,
|
||||
htm: CONFIG_LANG,
|
||||
css: HASH_FAMILY(''),
|
||||
scss: HASH_FAMILY(''),
|
||||
less: HASH_FAMILY(''),
|
||||
mjs: C_FAMILY(JS_WORDS),
|
||||
cjs: C_FAMILY(JS_WORDS),
|
||||
mts: C_FAMILY(TS_WORDS),
|
||||
cts: C_FAMILY(TS_WORDS),
|
||||
jsonc: CONFIG_LANG,
|
||||
json5: CONFIG_LANG,
|
||||
html: MARKUP_LANG,
|
||||
htm: MARKUP_LANG,
|
||||
xml: MARKUP_LANG,
|
||||
svg: MARKUP_LANG,
|
||||
vue: MARKUP_LANG,
|
||||
css: CSS_LANG,
|
||||
scss: CSS_LANG,
|
||||
less: CSS_LANG,
|
||||
graphql: HASH_FAMILY('query mutation fragment on directive enum input interface scalar schema type implements'),
|
||||
gql: HASH_FAMILY('query mutation fragment on directive enum input interface scalar schema type implements'),
|
||||
lua: HASH_FAMILY(`and break do else elseif end false for function goto if in local nil not or
|
||||
repeat return then true until while`),
|
||||
}
|
||||
|
||||
@@ -451,6 +451,41 @@ export function untrackedFile(path: string, content: string): DiffFile {
|
||||
return { oldPath: '/dev/null', newPath: `b/${path}`, binary: false, hunks: [{ oldStart: 0, newStart: 1, header: '', lines }] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize a git gap fold's hidden rows from the two sides' full file
|
||||
* contents, by the fold's known line ranges: the old side drives context
|
||||
* rows (each mapped onto the new side through the fold's offset — a gap is
|
||||
* an unchanged run, so the sides align), and new-side lines the old range
|
||||
* never reaches become pure additions. Line numbers clip to the actual
|
||||
* content (a no-newline file's ranges can overrun by one); `\r` endings
|
||||
* survive verbatim, like git's own context lines.
|
||||
*/
|
||||
export function foldRowsFromContents(fold: FoldSegment, oldContent: string, newContent: string): DiffRow[] {
|
||||
const oldLines = oldContent.length === 0 ? [] : oldContent.split('\n')
|
||||
const newLines = newContent.length === 0 ? [] : newContent.split('\n')
|
||||
const offset = fold.newStart - fold.oldStart
|
||||
const rows: DiffRow[] = []
|
||||
const oldFrom = Math.max(fold.oldStart, 1)
|
||||
const oldTo = Math.min(fold.oldEnd, oldLines.length)
|
||||
for (let oldLine = oldFrom; oldLine <= oldTo; oldLine += 1) {
|
||||
const text = oldLines[oldLine - 1]!
|
||||
const newLine = oldLine + offset
|
||||
rows.push(
|
||||
newLine >= fold.newStart && newLine <= fold.newEnd && newLine <= newLines.length
|
||||
? { kind: 'context', oldLine, newLine, text }
|
||||
: { kind: 'context', oldLine, text },
|
||||
)
|
||||
}
|
||||
// New-side lines beyond what the old range reached (a pure-addition gap):
|
||||
// rows carrying only the new-side number, exactly like added lines.
|
||||
const newFrom = Math.max(fold.newStart, 1)
|
||||
const newTo = Math.min(fold.newEnd, newLines.length)
|
||||
for (let newLine = Math.max(newFrom, oldTo + offset + 1); newLine <= newTo; newLine += 1) {
|
||||
rows.push({ kind: 'add', newLine, text: newLines[newLine - 1]! })
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/** Strip the `a/` / `b/` prefix git puts on diff paths (not on /dev/null). */
|
||||
export function displayPath(path: string): string {
|
||||
if (path === '/dev/null') return path
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Built-in file and folder glyphs for the file tree (feature: file icons).
|
||||
*
|
||||
* The built-in set is DSH's OWN file-type artwork: `FileTypeIcon` from
|
||||
* `@deepseek-ai/dsh-client-ui-primitives` classifies a path and renders the
|
||||
* same glyphs the host's own explorer shows — the 48 full-color code and
|
||||
* configuration categories, plus the category-colored sheet glyphs for
|
||||
* markdown, images, PDFs, office documents, video, folders and the generic
|
||||
* fallback. Colored artwork is content, not chrome, and the host draws it
|
||||
* with its own palette, so this module carries no color literals and stays
|
||||
* inside the skin contract (the guide's §12).
|
||||
*
|
||||
* Nothing here needs a lazy chunk: the artwork belongs to a platform module
|
||||
* the client already holds in its frozen module table, so the core bundle
|
||||
* gains a function call rather than 500-odd icon rules.
|
||||
*
|
||||
* External plugins that want their own glyphs (per extension, per exact file
|
||||
* name, per directory name) register `FileIconDescriptor`s through
|
||||
* `ctx.betterSidebar.registerFileIcon`; their registrations win over these.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { FileTypeIcon } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
/**
|
||||
* A file row: the host's own classifier and artwork for any path. Unknown
|
||||
* extensions land on the generic document glyph, exactly like the host's
|
||||
* explorer.
|
||||
* @param path - the row's path (any separator; the classifier reads the basename).
|
||||
* @param size - the square edge in px.
|
||||
* @returns the host's file-type glyph.
|
||||
*/
|
||||
export function builtinFileIcon(path: string, size: number): ReactNode {
|
||||
return <FileTypeIcon path={path} size={size} />
|
||||
}
|
||||
|
||||
/**
|
||||
* A directory row: the host's folder glyph.
|
||||
*
|
||||
* The host ships ONE folder drawing (`kind: 'folder'` resolves to its
|
||||
* monochrome `IconFolderClose16`, which rides `currentColor` and therefore
|
||||
* still follows the skin), and the classifier never returns a folder kind of
|
||||
* its own. The expansion state is already legible from the tree's own chevron
|
||||
* and row affordances, so this deliberately does not invent a second folder
|
||||
* drawing.
|
||||
* @param _open - whether the row is expanded (accepted for API compatibility).
|
||||
* @param size - the square edge in px.
|
||||
* @returns the host's folder glyph.
|
||||
*/
|
||||
export function builtinFolderIcon(_open: boolean, size: number): ReactNode {
|
||||
return <FileTypeIcon kind="folder" size={size} />
|
||||
}
|
||||
|
||||
/**
|
||||
* The last-resort glyph: the host's generic document, for the resolver's leaf
|
||||
* when no registration and no built-in match applies (and for surfaces that
|
||||
* render a file row without a service).
|
||||
* @param size - the square edge in px.
|
||||
* @returns the host's generic file glyph.
|
||||
*/
|
||||
export function fallbackFileIcon(size: number): ReactNode {
|
||||
return <FileTypeIcon kind="other" size={size} />
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Shared vocabulary of the sidebar's HTML preview surfaces (the editor's
|
||||
* html viewer and the changes tab's op render preview): the sandbox tokens
|
||||
* both preview iframes load with, plus the document-base rewrite the srcdoc
|
||||
* delivery path needs.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The sandbox tokens of an HTML preview iframe. NO allow-same-origin (the
|
||||
* preview must stay in an opaque origin — with the route's own origin it
|
||||
* could read session data) and NO allow-top-navigation (a previewed page
|
||||
* must not hijack the GUI). The editor viewer can disable the sandbox
|
||||
* per-feature in the side card settings (warned); the changes tab's op
|
||||
* render preview always stays sandboxed.
|
||||
*/
|
||||
export const HTML_IFRAME_SANDBOX = 'allow-scripts allow-popups allow-downloads allow-modals'
|
||||
|
||||
/** Whether a document already declares its own base URL. */
|
||||
const HAS_BASE = /<base[\s/>]/i
|
||||
|
||||
/** Index just past the element a `<base>` must precede to land in <head>. */
|
||||
function insertionPoint(html: string): number {
|
||||
const head = /<head\b[^>]*>/i.exec(html)
|
||||
if (head !== null) return head.index + head[0].length
|
||||
const htmlTag = /<html\b[^>]*>/i.exec(html)
|
||||
if (htmlTag !== null) return htmlTag.index + htmlTag[0].length
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory part of a preview route URL, with its trailing slash.
|
||||
* @param url - the `/sidebar/html/<sessionId>/<path…>` route URL.
|
||||
* @returns the URL up to and including the last `/`.
|
||||
*/
|
||||
export function previewBaseOf(url: string): string {
|
||||
const cut = url.lastIndexOf('/')
|
||||
return cut < 0 ? url : url.slice(0, cut + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Point a preview document's relative references back at its own route.
|
||||
*
|
||||
* A `srcdoc` document has no URL of its own — the browser resolves its
|
||||
* relative references against the EMBEDDING page's URL, so `./style.css` in a
|
||||
* previewed file would resolve to the GUI's origin and hit whatever route
|
||||
* happens to match. Injecting `<base href="<the file's route directory>">`
|
||||
* keeps relative resolution inside `/sidebar/html/…`, where the file's own
|
||||
* siblings live. A document that declares its own `<base>` is left alone: the
|
||||
* author said what the base is.
|
||||
*
|
||||
* @param html - the previewed document's markup.
|
||||
* @param url - the route URL the document was fetched from.
|
||||
* @returns the markup, with a base element injected when it had none.
|
||||
*/
|
||||
export function withPreviewBase(html: string, url: string): string {
|
||||
if (HAS_BASE.test(html)) return html
|
||||
const href = previewBaseOf(url).replace(/"/g, '"')
|
||||
const at = insertionPoint(html)
|
||||
return `${html.slice(0, at)}<base href="${href}">${html.slice(at)}`
|
||||
}
|
||||
+2
-26
@@ -1,24 +1,12 @@
|
||||
/**
|
||||
* Icons the sidebar needs beyond the primitives set: a terminal glyph (the
|
||||
* icon library has none), a diff glyph, and the two panel-toggle glyphs for
|
||||
* the top-right cluster. Per-tab icons live on the tab descriptors
|
||||
* icon library has none), a diff glyph, and the bottom-workbench toggle.
|
||||
* Per-tab icons live on the tab descriptors
|
||||
* (`descriptor.icon`), not in a type-keyed switch — the icon mapping was
|
||||
* registry-ized with the tab types.
|
||||
*/
|
||||
import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
/**
|
||||
* Right-panel toggle glyph (the "侧拉" button): a frame with a filled strip
|
||||
* along its RIGHT edge, in the app's outline style (1.5px stroke,
|
||||
* currentColor).
|
||||
*/
|
||||
export const IconPanelRightOutline16 = ({ 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.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<rect x="10.5" y="3.25" width="2.75" height="9.5" rx="1" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/**
|
||||
* Bottom-panel toggle glyph (the "底栏" button): a frame with a filled strip
|
||||
* along its BOTTOM edge, in the app's outline style.
|
||||
@@ -161,15 +149,3 @@ export const IconVscode16 = ({ size = 16, className }: IconProps) => (
|
||||
<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>
|
||||
)
|
||||
|
||||
/**
|
||||
* Free-window glyph in the app's outline style (1.5px stroke, currentColor):
|
||||
* a background frame with a detached rounded mini-window floating over its
|
||||
* top-right — the changes tab's "diff opens as a free window" setting.
|
||||
*/
|
||||
export const IconFloatWindowOutline16 = ({ 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="4.5" width="10" height="10" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<rect x="9.5" y="1.5" width="5" height="5" rx="1.5" stroke="currentColor" strokeWidth="1.5" fill="none" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -25,9 +25,12 @@
|
||||
* legacy signal engines emit without isComposing.
|
||||
*/
|
||||
|
||||
/** The pure decision: is this keyboard event part of an IME composition? */
|
||||
export function isImeComposition(event: { isComposing: boolean; keyCode: number }): boolean {
|
||||
return event.isComposing || event.keyCode === 229
|
||||
/** The pure decision: is this keyboard event part of an IME composition?
|
||||
* `isComposing` is optional on the input: React's synthetic KeyboardEvent
|
||||
* type does not declare it (the DOM event always carries it), and the
|
||||
* keyCode 229 fallback covers exactly those callers. */
|
||||
export function isImeComposition(event: { isComposing?: boolean; keyCode: number }): boolean {
|
||||
return event.isComposing === true || event.keyCode === 229
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+70
-49
@@ -16,14 +16,18 @@ import { createBetterSidebarService, matchUrlTarget } from './service.ts'
|
||||
import { revalidateChunksOnReactivate, setChunkModuleSystem } from './chunk-loader.ts'
|
||||
import { registerBuiltins } from './builtins/index.ts'
|
||||
import { registerApesTabs, APES_LOCALE_NS, apesZh, apesEn, attachApesLocale } from './apes/index.ts'
|
||||
import { registerApesNativeTabs } from './apes/native-tabs.tsx'
|
||||
import { Sidebar } from './Sidebar.tsx'
|
||||
import { RenderBoundary } from './RenderBoundary.tsx'
|
||||
import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx'
|
||||
import { registerTurnTailInterception } from './intercept.tsx'
|
||||
import { createNativeTabRecords } from './native/tab-adapter.tsx'
|
||||
import { registerNativeSurface } from './native/index.ts'
|
||||
import { registerBottomToggle } from './sidebar/bottom-toggle.tsx'
|
||||
import { createNativeSurface } from './native/surface.ts'
|
||||
import { registerLinkInterception } from './link-intercept.ts'
|
||||
import { registerImeGuard } from './ime-guard.ts'
|
||||
import { registerSettingsNavIcon } from './settings-nav-icon.ts'
|
||||
import { loadBootDecision } from './prefs.ts'
|
||||
import { panelHostParent } from './desktop-env.ts'
|
||||
import { SideCardSection } from './SideCardSection.tsx'
|
||||
import { api } from './api.ts'
|
||||
import { LOCALE_NS, attachLocale, attachBetterLocale, t, zh, en } from './locales.ts'
|
||||
@@ -60,8 +64,8 @@ export function apply(ctx: Context): void {
|
||||
// run on fiber disposal, so re-activation (HMR) re-registers cleanly.
|
||||
attachLocale(ctx.locale)
|
||||
// The apes module has its own locale namespace (isolated from the upstream
|
||||
// betterSidebar dicts per D1=B); attach it so apes t() tracks the DSH
|
||||
// active-locale preference, and register its zh/en dicts under `apes`.
|
||||
// betterSidebar dicts): attach it so apes t() tracks the DSH active-locale
|
||||
// preference, and register its zh/en dicts under `apes`.
|
||||
attachApesLocale(ctx.locale)
|
||||
ctx.effect(() => {
|
||||
const offZh = ctx.locale.register(LOCALE_NS, 'zh', zh)
|
||||
@@ -145,6 +149,27 @@ export function apply(ctx: Context): void {
|
||||
// are ready by the time the sidebar renders.
|
||||
const service = createBetterSidebarService(sidebarStore)
|
||||
ctx.provide('betterSidebar', service)
|
||||
// The native right-Sidebar surface: the plugin's content is registered as
|
||||
// DSH tab types (one per descriptor) and every open routes there, so the
|
||||
// right column belongs to the host and only the bottom workbench stays
|
||||
// plugin-owned. Both halves live for this fiber's lifetime.
|
||||
const nativeRecords = createNativeTabRecords()
|
||||
const nativeSurface = createNativeSurface(ctx, nativeRecords)
|
||||
service.setSurface(nativeSurface)
|
||||
ctx.effect(
|
||||
() => registerNativeSurface({ ctx, store: sidebarStore, service, records: nativeRecords }),
|
||||
'dsh-apes-sidebar: native right-Sidebar registrations',
|
||||
)
|
||||
// The bottom workbench's expand/collapse button in DSH's session header
|
||||
// (the header's corner seat belongs to the native sidebar's own control).
|
||||
ctx.effect(
|
||||
() => registerBottomToggle(ctx, sidebarStore),
|
||||
'dsh-apes-sidebar: bottom-workbench toggle',
|
||||
)
|
||||
ctx.effect(
|
||||
() => () => { nativeSurface.dispose(); service.setSurface(undefined) },
|
||||
'dsh-apes-sidebar: native right-Sidebar surface',
|
||||
)
|
||||
// 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
|
||||
@@ -156,8 +181,7 @@ export function apply(ctx: Context): void {
|
||||
terminalTitle = name
|
||||
const snapshot = service.getSnapshot()
|
||||
if (snapshot.state === undefined) return
|
||||
const tabs = allLeaves(snapshot.state.splits)
|
||||
.concat(allLeaves(snapshot.state.bottomSplits))
|
||||
const tabs = allLeaves(snapshot.state.bottomSplits)
|
||||
.flatMap(leaf => leaf.tabs)
|
||||
for (const tab of tabs) {
|
||||
if (tab.type === 'terminal' && !isAgentTabId(tab.id) && tab.title === fallbackTitle) {
|
||||
@@ -173,34 +197,32 @@ export function apply(ctx: Context): void {
|
||||
'dsh-apes-sidebar: register built-in tabs and viewers',
|
||||
)
|
||||
// The apes:* information-layer tabs (the fork's own contribution, isolated
|
||||
// under src/client/apes per D1=B). Registered through the same service as
|
||||
// the builtins so they get the full sidebar lifecycle; the disposer runs on
|
||||
// fiber disposal (HMR-safe). A throw here is caught by the outer try and
|
||||
// routed to fail() — a bad apes registration must never blank the sidebar.
|
||||
// under src/client/apes). Registered through the SAME service as the
|
||||
// builtins, so the fork's native adapter (`native/index.ts`) turns each one
|
||||
// into an official right-sidebar tab type + body automatically — that is the
|
||||
// entire reason this fork now runs ONE sidebar instead of two. The disposer
|
||||
// runs on fiber disposal (HMR-safe).
|
||||
ctx.effect(
|
||||
() => registerApesTabs(ctx, service),
|
||||
'dsh-apes-sidebar: register apes tabs',
|
||||
)
|
||||
// Phase A spike: also publish apes:profile as an OFFICIAL right-sidebar tab
|
||||
// type (ctx.sidebarRightTabs + the keyed `sidebar.right.pane.tab` seat), so
|
||||
// the tab renders inside DSH's own panel instead of only in the fork's
|
||||
// shell. Both surfaces coexist during the spike; the disposer reverses the
|
||||
// two-stage registration on fiber disposal (HMR-safe). A throw is caught
|
||||
// inside the bridge and never reaches this effect.
|
||||
ctx.effect(
|
||||
() => registerApesNativeTabs(ctx),
|
||||
'dsh-apes-sidebar: apes native right-sidebar tabs',
|
||||
)
|
||||
// A failure anywhere in the client lifecycle must never take the app down
|
||||
// silently: log with the plugin prefix and pin a visible diagnostic strip
|
||||
// to the page so a blank panel is never the only symptom.
|
||||
// to the page so a blank panel is never the only symptom. This strip is
|
||||
// the last-resort reporter (no CSS module is reachable from here), so its
|
||||
// colors go through skin token chains with the previous hexes as the
|
||||
// chain tails — worst case (no skin tokens on the page) it renders
|
||||
// byte-identical to the old hardcoded bar, and any `--dsw-alias-*` skin
|
||||
// re-themes it (guide §12: no hardcoded colors).
|
||||
const fail = (phase: string, error: unknown): void => {
|
||||
console.error(`[dsh-apes-sidebar] ${phase} error:`, error)
|
||||
try {
|
||||
const bar = document.createElement('div')
|
||||
bar.style.cssText = 'position:fixed;left:8px;bottom:8px;z-index:2147483000;max-width:70vw;padding:8px 12px;'
|
||||
+ 'font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;color:#f2a1a1;background:#1b1b22;'
|
||||
+ 'border:1px solid #f2a1a1;border-radius:8px;white-space:pre-wrap'
|
||||
+ 'font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;'
|
||||
+ 'color:var(--dsw-alias-state-error-primary,#f2a1a1);'
|
||||
+ 'background:var(--dsw-alias-bg-layer-3,var(--dsw-alias-bg-base,#1b1b22));'
|
||||
+ 'border:1px solid var(--dsw-alias-state-error-primary,#f2a1a1);border-radius:8px;white-space:pre-wrap'
|
||||
bar.textContent = `[dsh-apes-sidebar] ${phase} error: ${error instanceof Error ? error.message : String(error)}`
|
||||
document.body.appendChild(bar)
|
||||
} catch {
|
||||
@@ -208,10 +230,9 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
}
|
||||
try {
|
||||
// rc.8+ exposes the client module system as the `ctx.modules` service
|
||||
// (no window.__DSH_MODULES__ page global anymore); the chunk loader needs
|
||||
// it to resolve its externals, so inject it before anything can load a
|
||||
// lazy chunk. The loader falls back to the rc.7 global when absent.
|
||||
// rc.8+ exposes the client module system as the `ctx.modules` service;
|
||||
// the chunk loader needs it to resolve its externals, so inject it
|
||||
// before anything can load a lazy chunk.
|
||||
setChunkModuleSystem(ctx.modules)
|
||||
// Fresh chunk state for this activation: drop per-test fixtures and
|
||||
// revalidate loaded chunk scripts against the bundle route's ETags —
|
||||
@@ -239,17 +260,26 @@ export function apply(ctx: Context): void {
|
||||
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. */
|
||||
/** Put the host in the layer it belongs to — re-attaching it if the
|
||||
* page (a desktop shell wrapper, SPA navigation, …) removed it, and
|
||||
* moving it if the shell framed the content viewport after our mount
|
||||
* (see panelHostParent). */
|
||||
const anchorHost = (): void => {
|
||||
if (host === undefined) return
|
||||
const parent = panelHostParent()
|
||||
if (host.parentElement !== parent) parent.appendChild(host)
|
||||
}
|
||||
/** Watch for the host being removed, and for the shell stamping its
|
||||
* framed mode onto <body> (the same effect tags #root as the content
|
||||
* viewport, which is where the host must live). */
|
||||
const guardAnchor = (): void => {
|
||||
if (bodyObserver !== undefined) return
|
||||
bodyObserver = new MutationObserver(() => {
|
||||
if (host !== undefined && !document.body.contains(host)) {
|
||||
document.body.appendChild(host)
|
||||
}
|
||||
bodyObserver = new MutationObserver(anchorHost)
|
||||
bodyObserver.observe(document.body, {
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['data-dsh-desktop-mode'],
|
||||
})
|
||||
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
|
||||
@@ -303,7 +333,10 @@ export function apply(ctx: Context): void {
|
||||
try {
|
||||
host = document.createElement('div')
|
||||
host.setAttribute('data-dsh-apes-sidebar', '')
|
||||
document.body.appendChild(host)
|
||||
// The shell's framed content viewport when there is one (see
|
||||
// panelHostParent): it is a stacking context, so a body-level host
|
||||
// would paint above every overlay the shell hosts inside it.
|
||||
panelHostParent().appendChild(host)
|
||||
root = createRoot(host)
|
||||
root.render(createElement(RenderBoundary, { className: css.boundaryError }, createElement(Sidebar, { ctx, store: sidebarStore })))
|
||||
mounted = true
|
||||
@@ -324,7 +357,7 @@ export function apply(ctx: Context): void {
|
||||
// rides the same fetch, so one round trip covers both decisions).
|
||||
const decision = await Promise.race([
|
||||
loadBootDecision(api),
|
||||
new Promise<null>(resolve => { const timer = window.setTimeout(() => resolve(null), 2000) }),
|
||||
new Promise<null>(resolve => { window.setTimeout(() => resolve(null), 2000) }),
|
||||
])
|
||||
if (disposed) return
|
||||
if (decision !== null) {
|
||||
@@ -363,18 +396,6 @@ export function apply(ctx: Context): void {
|
||||
'dsh-apes-sidebar: turn-tail interception',
|
||||
)
|
||||
|
||||
ctx.effect(
|
||||
() => {
|
||||
try {
|
||||
return registerOpenPathInterception(ctx, sidebarStore)
|
||||
} catch (error) {
|
||||
fail('interception', error)
|
||||
return () => {}
|
||||
}
|
||||
},
|
||||
'dsh-apes-sidebar: open-path interception',
|
||||
)
|
||||
|
||||
ctx.effect(
|
||||
() => {
|
||||
try {
|
||||
|
||||
@@ -8,10 +8,9 @@
|
||||
*/
|
||||
import { IconCodeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { firstLeaf, revealPaths, togglePanel, type SidebarStore } from './state.ts'
|
||||
import { revealPaths, type SidebarStore } from './state.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { resolveSidebarPath, selectProducedFiles } from './produced-files.ts'
|
||||
import { wrapOpenWorkspacePath, type OpenWorkspacePathService } from './openpath-intercept.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** Open a file in the sidebar's editor (used by the intercepted row and the explorer). */
|
||||
@@ -25,18 +24,10 @@ export function openSidebarFile(ctx: Context, store: SidebarStore, sessionId: st
|
||||
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.
|
||||
* directories, highlight the rows, and focus the explorer tab. Unknown
|
||||
* files fall back to revealing the workspace root itself.
|
||||
*/
|
||||
export function revealInExplorer(
|
||||
ctx: Context,
|
||||
@@ -53,15 +44,6 @@ export function revealInExplorer(
|
||||
? 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
|
||||
@@ -130,6 +112,7 @@ export function SidebarProducedFiles(props: {
|
||||
export function registerTurnTailInterception(ctx: Context, store: SidebarStore): () => void {
|
||||
return ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
|
||||
name: 'conversation.chat.turnTail',
|
||||
id: 'dsh-apes-sidebar:turn-tail',
|
||||
// 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. Also
|
||||
@@ -137,9 +120,7 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
|
||||
select: (owner) => {
|
||||
if (store.getSuspended()) return null
|
||||
if (store.getPrefs().tabsEnabled['editor'] === false) return null
|
||||
const matched = selectProducedFiles(owner)
|
||||
if (matched !== null) lastProduced = matched
|
||||
return matched
|
||||
return selectProducedFiles(owner)
|
||||
},
|
||||
priority: -1,
|
||||
registrant: 'dsh-apes-sidebar',
|
||||
@@ -149,38 +130,3 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
|
||||
}),
|
||||
}, SidebarProducedFiles))
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the chat file-open interception: shadows
|
||||
* `remote.session.openWorkspacePath` — the single funnel every chat-side
|
||||
* file open goes through on alpha hosts (tool-row path links, the
|
||||
* produced-files row, prose mentions, inline-code paths) — so opens land in
|
||||
* the 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 remote call.
|
||||
*
|
||||
* The `remote.session` namespace service mounts asynchronously (the gateway
|
||||
* client creates it when the session-controller contribution arrives) and
|
||||
* is recreated on contribution remounts, so the wrapper installs through
|
||||
* `ctx.inject`: the callback runs once the service exists and re-runs after
|
||||
* every remount, re-applying the shadow on the fresh instance. Returns the
|
||||
* disposer (disposes the inject fiber, which restores the original method
|
||||
* descriptor — HMR-safe).
|
||||
*/
|
||||
export function registerOpenPathInterception(ctx: Context, store: SidebarStore): () => void {
|
||||
const fiber = ctx.inject(['remote.session'], (fctx) => {
|
||||
fctx.effect(() => {
|
||||
const service = fctx.get('remote.session') as OpenWorkspacePathService
|
||||
return wrapOpenWorkspacePath(service, {
|
||||
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) },
|
||||
})
|
||||
}, 'dsh-apes-sidebar: open-path interception wrap')
|
||||
})
|
||||
return () => { void fiber.dispose() }
|
||||
}
|
||||
|
||||
+16
-25
@@ -1,37 +1,28 @@
|
||||
/**
|
||||
* 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.
|
||||
* Size written to `--dsh-sidebar-height`. The conversation column (output +
|
||||
* composer) must keep at least {@link CONVERSATION_MIN} of the viewport after
|
||||
* the bottom workbench claims height; a closed workbench pushes 0. The right
|
||||
* column belongs to DSH's native Sidebar, so this shell pushes no width.
|
||||
*/
|
||||
import { PANEL_MIN } from './state.ts'
|
||||
import { CONVERSATION_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
|
||||
export interface BottomPushInput {
|
||||
/** Whether the bottom workbench is expanded. */
|
||||
open: boolean
|
||||
/** Committed (or mid-drag) bottom height in px. */
|
||||
height: number
|
||||
/** Viewport height the cap is measured against (visual viewport when known). */
|
||||
viewportHeight: 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)
|
||||
/** Compute the live layout-push height for the bottom workbench. */
|
||||
export function bottomPushHeight(input: BottomPushInput): number {
|
||||
if (!input.open) return 0
|
||||
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,
|
||||
}
|
||||
const maxHeight = Math.max(0, viewportHeight - Math.min(CONVERSATION_MIN, viewportHeight))
|
||||
return Math.min(finiteNonNegative(input.height), maxHeight)
|
||||
}
|
||||
|
||||
+75
-47
@@ -1,47 +1,25 @@
|
||||
/**
|
||||
* Layout push: an open panel occupies layout instead of floating over it.
|
||||
* The right panel reserves padding inside the AppFrame three-column grid,
|
||||
* so the app sidebar, conversation, and details column all remain to its
|
||||
* left while the frame's border-box stays viewport-wide. Harness measures
|
||||
* that border-box to choose desktop versus narrow presentation; preserving
|
||||
* it prevents a desktop window from entering narrow mode merely because the
|
||||
* plugin panel opened.
|
||||
* Layout push: the bottom workbench occupies layout instead of floating over
|
||||
* the conversation. The right column belongs to DSH's native Sidebar, so this
|
||||
* stylesheet pushes no width — only the workbench's height.
|
||||
*
|
||||
* AppFrame positions its details drag handle from the measured border-box.
|
||||
* Move that handle left by the same reserved width so it stays on the details
|
||||
* column edge. The ordinary sidebar handle remains at its native coordinate.
|
||||
*
|
||||
* The bottom panel squeezes only the conversation column. The anchor is the
|
||||
* The workbench squeezes only the conversation column. The anchor is the
|
||||
* [data-dsh-center-col] tag the Sidebar shell's locator writes on the
|
||||
* frame's measured center grid item (Sidebar.tsx locate — not a positional
|
||||
* path: the frame also contains an overlay layer and drag handles). A
|
||||
* stretched grid item shrinks by its bottom margin, so the conversation
|
||||
* output and composer lift without touching either sidebar.
|
||||
*
|
||||
* Sizes ride CSS variables updated by the Sidebar shell (0 while collapsed).
|
||||
* Expand/collapse uses the host theme duration; drags disable transitions so
|
||||
* every reserved edge tracks the pointer.
|
||||
* The size rides a CSS variable updated by the Sidebar shell (0 while
|
||||
* collapsed). Expand/collapse uses the host theme duration; drags disable
|
||||
* transitions so the reserved edge tracks the pointer.
|
||||
*/
|
||||
/* Current shells expose [data-dsh-frame]; rc.8-era shells expose the same
|
||||
AppFrame only as the root slot's direct child. */
|
||||
#root [data-dsh-frame],
|
||||
#root > [data-slot="root"] > div {
|
||||
box-sizing: border-box;
|
||||
padding-right: var(--dsh-sidebar-width, 0px);
|
||||
transition: padding-right var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
#root [data-dsh-frame] > [data-side="details"],
|
||||
#root > [data-slot="root"] > div > [data-side="details"] {
|
||||
transform: translateX(calc(0px - var(--dsh-sidebar-width, 0px)));
|
||||
transition:
|
||||
left var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
/* The AppFrame's center column. The Sidebar shell's locator (Sidebar.tsx
|
||||
locate/measureCenter) tags the measured column node with
|
||||
[data-dsh-center-col] — one attribute write per node lifetime. The rule
|
||||
locate/measureCenter, resolving via center-column.ts) tags the measured
|
||||
column node with [data-dsh-center-col] — one attribute write per node
|
||||
lifetime. The locator anchors on the conversation slot host
|
||||
(`main.conversation` on DSH 0.1.5-alpha.2+, `conversation` on alpha.1)
|
||||
and skips the transparent display:contents slot hosts above it. The rule
|
||||
used to anchor on a structural has()-selector over the conversation slot
|
||||
wrapper, whose match cache invalidates on every #root subtree mutation
|
||||
(the streaming chat mutates it constantly), re-evaluating the selector
|
||||
@@ -73,13 +51,7 @@ body[data-dsh-sidebar-collapsed] [data-slot="conversation.session.header"] > hea
|
||||
padding-right: 78px;
|
||||
}
|
||||
|
||||
body[data-dsh-sidebar-dragging] #root [data-dsh-frame],
|
||||
body[data-dsh-sidebar-dragging] #root > [data-slot="root"] > div,
|
||||
body[data-dsh-sidebar-dragging] #root [data-dsh-frame] > [data-side="details"],
|
||||
body[data-dsh-sidebar-dragging] #root > [data-slot="root"] > div > [data-side="details"],
|
||||
body[data-dsh-sidebar-dragging] #root [data-dsh-center-col],
|
||||
#root [data-dsh-frame][data-dragging] > [data-side="details"],
|
||||
#root > [data-slot="root"] > div[data-dragging] > [data-side="details"] {
|
||||
body[data-dsh-sidebar-dragging] #root [data-dsh-center-col] {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@@ -88,11 +60,11 @@ body[data-dsh-sidebar-dragging] #root [data-dsh-center-col],
|
||||
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 {
|
||||
[data-dsh-apes-sidebar-settings-nav] > svg:first-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-dsh-better-sidebar-settings-nav]::before {
|
||||
[data-dsh-apes-sidebar-settings-nav]::before {
|
||||
content: '';
|
||||
flex: none;
|
||||
width: 16px;
|
||||
@@ -103,11 +75,67 @@ body[data-dsh-sidebar-dragging] #root [data-dsh-center-col],
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#root [data-dsh-frame],
|
||||
#root > [data-slot="root"] > div,
|
||||
#root [data-dsh-frame] > [data-side="details"],
|
||||
#root > [data-slot="root"] > div > [data-side="details"],
|
||||
#root [data-dsh-center-col] {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Context-menu submenu flip (viewport clamping for the portaled menus).
|
||||
*
|
||||
* The ui-primitives Menu clamps its main portal list into the viewport but
|
||||
* positions nested submenus with static CSS only — anchored at the parent
|
||||
* row's bottom, growing UP and to the RIGHT — with no clamp whatsoever.
|
||||
* Both defaults are the worst case for our menus: a tall "open with" submenu
|
||||
* escapes past the viewport top when the row menu opens near the top, and
|
||||
* past the right edge in a narrow workbench pane (a pane can be far narrower
|
||||
* than the card 218 + gap + submenu 165 a submenu needs).
|
||||
*
|
||||
* menu-flip.ts publishes `data-dsh-sidebar-submenu` on <body> — with the
|
||||
* words "down" and/or "left" — for exactly the lifetime of one of OUR
|
||||
* submenu-bearing menus (FileTree row menu, TabBar tab menu). The tokens
|
||||
* flip the growth direction; the attribute's absence restores the
|
||||
* primitive's own geometry untouched, and host-owned menus are never
|
||||
* affected because they never open while the attribute exists (each side
|
||||
* closes on the other's outside pointerdown).
|
||||
*
|
||||
* No max-height/overflow scroll cap here on purpose: overflow clipping
|
||||
* would cut the submenu's ::before hover bridge (the corridor that keeps
|
||||
* itemWrap's mouseleave from closing the card mid-crossing). */
|
||||
|
||||
/* Visual tether, part 1 — the parent row stays lit while its submenu is
|
||||
* open. The primitive only styles :hover, so once the pointer crossed into
|
||||
* the side card the parent row's fill cleared and the floating card read as
|
||||
* unrelated. The Menu marks the open parent row with aria-expanded="true"
|
||||
* (host-rendered, stable), so the same hover token re-applies for the
|
||||
* submenu's whole lifetime. */
|
||||
body[data-dsh-sidebar-submenu] div[role="menu"] button[aria-expanded="true"] {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Visual tether, part 2 — the side card hugs its parent (10px → 4px gap),
|
||||
* with the ::before hover corridor narrowed to match. Ordered BEFORE the
|
||||
* flip overrides below so their left/right flips still win. */
|
||||
body[data-dsh-sidebar-submenu] div[role="menu"] div[role="menu"] {
|
||||
left: calc(100% + 4px);
|
||||
}
|
||||
|
||||
body[data-dsh-sidebar-submenu] div[role="menu"] div[role="menu"]::before {
|
||||
left: -4px;
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
body[data-dsh-sidebar-submenu~="down"] div[role="menu"] div[role="menu"] {
|
||||
top: -4px;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
body[data-dsh-sidebar-submenu~="left"] div[role="menu"] div[role="menu"] {
|
||||
left: auto;
|
||||
right: calc(100% + 4px);
|
||||
}
|
||||
|
||||
/* The hover bridge follows the card to the left side of the parent row. */
|
||||
body[data-dsh-sidebar-submenu~="left"] div[role="menu"] div[role="menu"]::before {
|
||||
left: auto;
|
||||
right: -4px;
|
||||
}
|
||||
|
||||
@@ -22,12 +22,12 @@ interface LazyChunkViewProps<P> {
|
||||
props: P
|
||||
}
|
||||
|
||||
function LazyChunkView<P>({ chunk, pick, props }: LazyChunkViewProps<P>): ReactNode {
|
||||
function LazyChunkView<P extends object>({ chunk, pick, props }: LazyChunkViewProps<P>): ReactNode {
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const [state, setState] = useState<
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; message: string }
|
||||
| { status: 'ready'; Comp: ComponentType<any> }
|
||||
| { status: 'ready'; Comp: ComponentType<P> }
|
||||
>({ status: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
+49
-16
@@ -18,13 +18,27 @@
|
||||
/** The ar dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
|
||||
export const ar: Record<string, string> = {
|
||||
files: 'الملفات',
|
||||
guideDescFiles: 'استعرض شجرة ملفات مساحة العمل؛ انقر للمعاينة أو التحرير',
|
||||
guideDescGit: 'تغييرات Git وتعديلات ملفات هذه الجلسة في تبويب واحد',
|
||||
guideDescSubagent: 'بنية الوكلاء الفرعيين والمهام الخلفية مباشرةً',
|
||||
guideDescSidechat: 'خيط متابعة مستقل يرث سياق هذه الجلسة',
|
||||
guideDescTerminal: 'صدفة حقيقية (xterm + pty) مع إعادة الاتصال',
|
||||
guideDescBrowser: 'متصفح مدمج يمكنه فتح روابط المحادثة',
|
||||
changesSessionEmpty: 'لا توجد عمليات ملفات في هذه الجلسة بعد',
|
||||
changesRead: 'قراءة',
|
||||
changesMdReading: 'قراءة',
|
||||
changesHtmlRender: "عرض",
|
||||
changesHtmlRaw: "المصدر",
|
||||
changesPdfRender: "عرض",
|
||||
changesPdfRaw: "المصدر",
|
||||
changesMdRaw: 'الخام',
|
||||
changesWrite: 'كتابة',
|
||||
changesEdit: 'تحرير',
|
||||
changesRunning: 'قيد التنفيذ',
|
||||
changesError: 'خطأ',
|
||||
changesFold: '{count} أسطر… انقر للتوسيع',
|
||||
changesFoldLoading: 'جارٍ التوسيع…',
|
||||
changesFoldUnavailable: 'السياق غير متوفر',
|
||||
changesContext: 'سياق',
|
||||
changesPriorUnknown: 'المحتوى السابق خارج النافذة؛ يُعرض كما لو أُضيف بالكامل',
|
||||
explorer: 'المستكشف',
|
||||
@@ -74,7 +88,6 @@ export const ar: Record<string, string> = {
|
||||
closeOtherTabs: 'إغلاق التبويبات الأخرى',
|
||||
closeLeftTabs: 'إغلاق التبويبات على اليسار',
|
||||
closeRightTabs: 'إغلاق التبويبات على اليمين',
|
||||
moveToFreeWindow: 'النقل إلى نافذة حرة',
|
||||
floatDropHint: 'حرّك للفتح في نافذة حرة',
|
||||
dockToSidebar: 'العودة إلى الشريط الجانبي',
|
||||
pinTerminal: 'تثبيت الطرفية',
|
||||
@@ -89,16 +102,17 @@ export const ar: Record<string, string> = {
|
||||
pinnedTerminalScopeGlobal: 'مثبتة عام',
|
||||
pinnedRailLabel: 'الطرفيات المثبتة',
|
||||
closePinnedTerminal: 'إغلاق الطرفية',
|
||||
collapse: 'طي الشريط الجانبي',
|
||||
expand: 'توسيع الشريط الجانبي',
|
||||
collapseBottomPanel: 'طي اللوحة السفلية',
|
||||
expandBottomPanel: 'توسيع اللوحة السفلية',
|
||||
terminalError: 'فشل اتصال الطرفية',
|
||||
terminalConnectFailed: 'فشل اتصال الطرفية عدة مرات',
|
||||
terminalRetry: 'إعادة المحاولة',
|
||||
terminalWaitBanner: 'الوكيل ينتظر {needle}',
|
||||
terminalSkipWait: 'تخطي الانتظار',
|
||||
terminalDepsFailed: 'فشل تحميل تبعية الطرفية node-pty',
|
||||
terminalDepsHint: 'شغّل الأمر التالي في طرفية أو cmd على جهاز DSH للإصلاح، ثم أعد المحاولة (يبقى node-pty متزامناً مع إصدار نواة DSH):',
|
||||
terminalDepsProfile: ' (الملف الشخصي المكتشف: {profile})',
|
||||
terminalShellNotFound: 'لم يتم العثور على الصدفة المُعدَّة: {name} — تحقق من مسار الصدفة في الإعدادات → Side card → Terminal',
|
||||
preview: 'معاينة',
|
||||
toc: 'الفهرس',
|
||||
edit: 'تحرير',
|
||||
@@ -145,12 +159,6 @@ export const ar: Record<string, string> = {
|
||||
changesOpenDiffTab: 'فتح في تبويب diff منفصل',
|
||||
changesClosePreview: 'إغلاق المعاينة',
|
||||
changesResizePreview: 'تغيير حجم المعاينة',
|
||||
changesDiffOpenTitle: 'يفتح الفرق كـ',
|
||||
changesDiffOpenDesc: 'أين يضع إجراء «التوسيع إلى تبويب diff» الفرق',
|
||||
changesDiffOpenFloat: 'نافذة عائمة',
|
||||
changesDiffOpenFloatDesc: 'نافذة عائمة في وسط الشاشة — سحب وتحجيم وتثبيت فوق',
|
||||
changesDiffOpenPane: 'لوحة',
|
||||
changesDiffOpenPaneDesc: 'مثبتة أسفل اللوحة المصدر (تقسيم diff بنمط VSCode)',
|
||||
changesLoadError: 'سجلات ملفات الجلسة غير متاحة الآن',
|
||||
staged: 'مُدرج',
|
||||
unstaged: 'غير مُدرج',
|
||||
@@ -199,6 +207,13 @@ export const ar: Record<string, string> = {
|
||||
addToConversation: 'إضافة إلى المحادثة',
|
||||
copyRelative: 'نسخ المسار النسبي',
|
||||
copyAbsolute: 'نسخ المسار المطلق',
|
||||
rename: 'إعادة تسمية',
|
||||
renameInvalid: 'لا يمكن أن يكون الاسم فارغًا أو يحتوي على فواصل مسار.',
|
||||
delete: 'حذف',
|
||||
deleteTitle: 'حذف «{name}»؟',
|
||||
deleteDescFile: 'سيتم حذف هذا الملف نهائيًا. لا يمكن التراجع عن هذا الإجراء.',
|
||||
deleteDescDir: 'سيتم حذف هذا الدليل وكل محتوياته نهائيًا. لا يمكن التراجع عن هذا الإجراء.',
|
||||
dismiss: 'إغلاق',
|
||||
download: 'تنزيل',
|
||||
uploadFiles: 'رفع ملفات',
|
||||
uploadFolder: 'رفع مجلد',
|
||||
@@ -217,13 +232,6 @@ export const ar: Record<string, string> = {
|
||||
settingsIntro: 'إدارة ما تعرضه البطاقة الجانبية وكيف تتصرف',
|
||||
settingsPopupDesc: 'تكوين خيارات ذات صلة بـ {feature}',
|
||||
settingsDone: 'تم',
|
||||
settingsOpenTitle: 'فتح افتراضياً للمحادثات الجديدة',
|
||||
settingsOpenDesc: 'توسيع البطاقة الجانبية تلقائياً للمحادثات الجديدة تماماً؛ المحادثات الموجودة تحتفظ بتخطيطاتها',
|
||||
settingsWidthTitle: 'الحصة الافتراضية للعرض',
|
||||
settingsWidthDesc: 'الحصة الافتراضية للبطاقة الجانبية من عرض النافذة للمحادثات الجديدة (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'فتح ملفات المحادثة في الشريط الجانبي',
|
||||
settingsOpenPathDesc: 'فتح روابط الملفات في المحادثة (صفوف الأدوات، الملفات الناتجة، الإشارات) في محرّر الشريط الجانبي بدلاً من التطبيق الافتراضي للنظام',
|
||||
settingsOpenToolsTitle: 'حقن أداة فتح الشريط الجانبي للنموذج',
|
||||
settingsOpenToolsDesc: 'عند التفعيل، يمكن للنموذج فتح الملفات والمجلدات وصفحات HTTP(S) في الشريط الجانبي عبر أداة sidebar_open (معطّل افتراضياً)',
|
||||
settingsTitleBarTitle: 'وضع توافق الموضع',
|
||||
@@ -422,4 +430,29 @@ export const ar: Record<string, string> = {
|
||||
pluginDocsPanelDesc: '«وثائق عامة» في شريط DSH الجانبي: ملاحظات Markdown عامة، قابلة للقراءة من أي مساحة عمل — قائمة ملفات، مخطط تفصيلي، فتح في Chrome / VS Code، وأزرار نسخ؛ دليل الوثائق قابل للتكوين (الافتراضي ~/.dsh/docs)',
|
||||
pluginEgoBrowserDesc: 'متصفح الوكيل لـ DeepSeek Harness: 32 أداة ego_* تقود متصفح Chromium حقيقي، مع تبويب «متصفح ego» أصلي في الشريط الجانبي يعرض مباشرة كل صفحة يزورها الوكيل — يمكنك النقر والسحب والكتابة لتولي التحكم. يسجّل التبويب تلقائيًا عند وجود better-sidebar، وإلا يظهر كفقاعة عائمة',
|
||||
pluginBilingualReaderDesc: 'اقرأ ملفات PDF في الشريط الجانبي لـ DSH: عرض PDF أصلي، حدد نصًا لترجمته بواسطة النموذج اللغوي مع السياق، معزولًا تمامًا عن المحادثة الرئيسية',
|
||||
changesRedactOn: "تنقيع مُفعّل: تظهر الأسرار كـ [REDACTED]؛ انقر للتعطيل (على مسؤوليتك)",
|
||||
changesRedactOff: "تنقيع مُعطّل: سيُعرض المحتوى كما هو؛ انقر للتفعيل",
|
||||
changesRedactOnLabel: "تنقيع",
|
||||
changesRedactOffLabel: "بدون تنقيع",
|
||||
changesRedactBanner: "مُنقّى",
|
||||
pluginSentinelName: 'dsh-sentinel نظام الاستيقاظ',
|
||||
pluginEgoBrowserName: 'ego-browser متصفح الوكيل',
|
||||
pluginBetterOverleafName: 'dsh-better-overleaf تبويب Overleaf',
|
||||
pluginDocsPanelName: 'dsh-docs-panel مستندات عامة',
|
||||
pluginFlowglassName: 'dsh-flowglass Flowglass',
|
||||
pluginGitForgeName: 'dsh-git-forge بيانات اعتماد Git',
|
||||
pluginGitRemotesName: 'dsh-git-remotes أجهزة Git البعيدة',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench منصة GitHub',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa تحديد وسؤال',
|
||||
pluginSidenoteName: 'dsh-sidenote محادثة جانبية',
|
||||
pluginServerDeckName: 'dsh-server-deck لوحة الخوادم',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel نفق SSH',
|
||||
pluginTurnReviewName: 'dsh-turn-review مراجعة الجولة',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader قارئ ثنائي اللغة',
|
||||
pluginOfficeName: 'معاينة Office',
|
||||
pluginMdExportName: 'تصدير Markdown',
|
||||
pluginCodeNavName: 'مستكشف معاينة الكود',
|
||||
pluginVideoPreviewName: 'معاينة الفيديو',
|
||||
presetDshDesktopDesc: 'وضع Electron المتقدم (بلا إطار): يحجز macOS شريطًا علويًا بمقدار 20px، ويحجز Windows بمقدار 32px لشريط العنوان عند غياب WCO',
|
||||
}
|
||||
|
||||
+49
-16
@@ -3,13 +3,27 @@
|
||||
*/
|
||||
export const de: Record<string, string> = {
|
||||
files: 'Dateien',
|
||||
guideDescFiles: 'Dateibaum des Arbeitsbereichs durchsuchen; Klick öffnet Vorschau oder Editor',
|
||||
guideDescGit: 'Git-Änderungen und Dateiänderungen dieser Sitzung in einem Tab',
|
||||
guideDescSubagent: 'Subagent-Topologie und Hintergrundaufgaben, live',
|
||||
guideDescSidechat: 'Ein unabhängiger Folge-Thread mit dem Kontext dieser Sitzung',
|
||||
guideDescTerminal: 'Eine echte Shell (xterm + pty) mit Wiederverbindung',
|
||||
guideDescBrowser: 'Eingebetteter Browser, der Chat-Links übernehmen kann',
|
||||
changesSessionEmpty: 'Noch keine Dateioperationen in dieser Sitzung',
|
||||
changesRead: 'Lesen',
|
||||
changesMdReading: 'Lesen',
|
||||
changesHtmlRender: "Rendern",
|
||||
changesHtmlRaw: "Quelltext",
|
||||
changesPdfRender: "Rendern",
|
||||
changesPdfRaw: "Quelltext",
|
||||
changesMdRaw: 'Rohfassung',
|
||||
changesWrite: 'Schreiben',
|
||||
changesEdit: 'Bearbeiten',
|
||||
changesRunning: 'läuft',
|
||||
changesError: 'Fehler',
|
||||
changesFold: '{count} Zeilen… zum Aufklappen klicken',
|
||||
changesFoldLoading: 'Wird aufgeklappt…',
|
||||
changesFoldUnavailable: 'Kontext nicht verfügbar',
|
||||
changesContext: 'Kontext',
|
||||
changesPriorUnknown: 'Inhalt vor der Änderung liegt außerhalb des Fensters; als vollständig neu angezeigt',
|
||||
explorer: 'Explorer',
|
||||
@@ -59,7 +73,6 @@ export const de: Record<string, string> = {
|
||||
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',
|
||||
@@ -74,16 +87,17 @@ export const de: Record<string, string> = {
|
||||
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',
|
||||
terminalWaitBanner: 'Agent wartet auf {needle}',
|
||||
terminalSkipWait: 'Warten überspringen',
|
||||
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})',
|
||||
terminalShellNotFound: 'Konfigurierte Shell nicht gefunden: {name} — Shell-Pfad unter Einstellungen → Side card → Terminal prüfen',
|
||||
preview: 'Vorschau',
|
||||
toc: 'Inhaltsverzeichnis',
|
||||
edit: 'Bearbeiten',
|
||||
@@ -130,12 +144,6 @@ export const de: Record<string, string> = {
|
||||
changesOpenDiffTab: 'In eigenem Tab öffnen',
|
||||
changesClosePreview: 'Vorschau schließen',
|
||||
changesResizePreview: 'Vorschauhöhe anpassen',
|
||||
changesDiffOpenTitle: 'Diff öffnet als',
|
||||
changesDiffOpenDesc: 'Wo die Aktion „In eigenem Tab öffnen“ den Diff platziert',
|
||||
changesDiffOpenFloat: 'Freies Fenster',
|
||||
changesDiffOpenFloatDesc: 'Ein schwebendes Fenster mittig im Viewport — verschieben, skalieren, oben halten',
|
||||
changesDiffOpenPane: 'Bereich',
|
||||
changesDiffOpenPaneDesc: 'Unter dem Quellbereich angedockt (VSCode-artiger Diff-Split)',
|
||||
changesLoadError: 'Sitzungsdatei-Aufzeichnungen sind derzeit nicht verfügbar',
|
||||
staged: 'Gestaged',
|
||||
unstaged: 'Nicht gestaged',
|
||||
@@ -184,6 +192,13 @@ export const de: Record<string, string> = {
|
||||
addToConversation: 'Zur Unterhaltung hinzufügen',
|
||||
copyRelative: 'Relativen Pfad kopieren',
|
||||
copyAbsolute: 'Absoluten Pfad kopieren',
|
||||
rename: 'Umbenennen',
|
||||
renameInvalid: 'Der Name darf nicht leer sein und keine Pfadtrenner enthalten.',
|
||||
delete: 'Löschen',
|
||||
deleteTitle: '„{name}“ löschen?',
|
||||
deleteDescFile: 'Dadurch wird die Datei dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.',
|
||||
deleteDescDir: 'Dadurch werden das Verzeichnis und dessen gesamter Inhalt dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.',
|
||||
dismiss: 'Schließen',
|
||||
download: 'Herunterladen',
|
||||
uploadFiles: 'Dateien hochladen',
|
||||
uploadFolder: 'Ordner hochladen',
|
||||
@@ -202,13 +217,6 @@ export const de: Record<string, string> = {
|
||||
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',
|
||||
@@ -407,4 +415,29 @@ export const de: Record<string, string> = {
|
||||
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',
|
||||
pluginBilingualReaderDesc: 'PDFs im DSH-Seitenbereich lesen: native PDF-Anzeige, Text auswählen und mit dem LLM übersetzen — mit Kontext, vollständig vom Hauptgespräch isoliert',
|
||||
changesRedactOn: "Schwärzung aktiv: Geheimnisse als [REDACTED]; Klicken zum Deaktivieren (auf eigenes Risiko)",
|
||||
changesRedactOff: "Schwärzung aus: Inhalte werden unverändert angezeigt; Klicken zum Aktivieren",
|
||||
changesRedactOnLabel: "Schwärzung",
|
||||
changesRedactOffLabel: "Ohne Schwärzung",
|
||||
changesRedactBanner: "Geschwärzt",
|
||||
pluginSentinelName: 'dsh-sentinel Wecksystem',
|
||||
pluginEgoBrowserName: 'ego-browser Agent-Browser',
|
||||
pluginBetterOverleafName: 'dsh-better-overleaf Overleaf-Tab',
|
||||
pluginDocsPanelName: 'dsh-docs-panel Globale Dokumente',
|
||||
pluginFlowglassName: 'dsh-flowglass Flowglass',
|
||||
pluginGitForgeName: 'dsh-git-forge Git-Zugangsdaten',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Git-Remotes',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench GitHub-Workbench',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa Auswählen & Fragen',
|
||||
pluginSidenoteName: 'dsh-sidenote Seiten-Chat',
|
||||
pluginServerDeckName: 'dsh-server-deck Server-Deck',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel SSH-Tunnel',
|
||||
pluginTurnReviewName: 'dsh-turn-review Runden-Review',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader Zweisprachiger Reader',
|
||||
pluginOfficeName: 'Office-Vorschau',
|
||||
pluginMdExportName: 'Markdown-Export',
|
||||
pluginCodeNavName: 'Code-Vorschau-Navigation',
|
||||
pluginVideoPreviewName: 'Video-Vorschau',
|
||||
presetDshDesktopDesc: 'Elektronischer Erweiterte-Modus (rahmenlos): macOS reserviert oben 20px; Windows reserviert ohne WCO 32px für die Titelleiste',
|
||||
}
|
||||
|
||||
+49
-16
@@ -10,13 +10,27 @@
|
||||
/** fr dictionary for the `betterSidebar` namespace. */
|
||||
export const fr: Record<string, string> = {
|
||||
files: 'Fichiers',
|
||||
guideDescFiles: 'Parcourir l\'arborescence du workspace ; un clic ouvre l\'aperçu ou l\'édition',
|
||||
guideDescGit: 'Modifications Git et éditions de cette session dans un seul onglet',
|
||||
guideDescSubagent: 'Topologie des sous-agents et tâches d\'arrière-plan, en direct',
|
||||
guideDescSidechat: 'Un fil de suivi indépendant héritant du contexte de cette session',
|
||||
guideDescTerminal: 'Un vrai shell (xterm + pty) avec reconnexion',
|
||||
guideDescBrowser: 'Navigateur intégré, qui peut reprendre les liens du chat',
|
||||
changesSessionEmpty: 'Aucune opération de fichier dans cette session',
|
||||
changesRead: 'Lecture',
|
||||
changesMdReading: 'Lecture',
|
||||
changesHtmlRender: "Rendu",
|
||||
changesHtmlRaw: "Source",
|
||||
changesPdfRender: "Rendu",
|
||||
changesPdfRaw: "Source",
|
||||
changesMdRaw: 'Brut',
|
||||
changesWrite: 'Écriture',
|
||||
changesEdit: 'Modification',
|
||||
changesRunning: 'en cours',
|
||||
changesError: 'erreur',
|
||||
changesFold: '{count} lignes… cliquer pour déplier',
|
||||
changesFoldLoading: 'Dépliage…',
|
||||
changesFoldUnavailable: 'Contexte indisponible',
|
||||
changesContext: 'contexte',
|
||||
changesPriorUnknown: 'Le contenu antérieur est hors de la fenêtre ; affiché comme entièrement ajouté',
|
||||
explorer: 'Explorateur',
|
||||
@@ -66,7 +80,6 @@ export const fr: Record<string, string> = {
|
||||
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',
|
||||
@@ -81,16 +94,17 @@ export const fr: Record<string, string> = {
|
||||
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',
|
||||
terminalWaitBanner: 'L’agent attend {needle}',
|
||||
terminalSkipWait: 'Ignorer l’attente',
|
||||
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})',
|
||||
terminalShellNotFound: 'Shell configuré introuvable : {name} — vérifiez le chemin du shell dans Réglages → Side card → Terminal',
|
||||
preview: 'Aperçu',
|
||||
toc: 'Sommaire',
|
||||
edit: 'Modifier',
|
||||
@@ -137,12 +151,6 @@ export const fr: Record<string, string> = {
|
||||
changesOpenDiffTab: 'Ouvrir dans un onglet diff',
|
||||
changesClosePreview: 'Fermer l\'aperçu',
|
||||
changesResizePreview: 'Redimensionner l\'aperçu',
|
||||
changesDiffOpenTitle: 'Le diff s\'ouvre en',
|
||||
changesDiffOpenDesc: 'Où l\'action « Ouvrir dans un onglet diff » place le diff',
|
||||
changesDiffOpenFloat: 'Fenêtre flottante',
|
||||
changesDiffOpenFloatDesc: 'Une fenêtre flottante centrée sur la fenêtre — déplacer, redimensionner, garder au premier plan',
|
||||
changesDiffOpenPane: 'Panneau',
|
||||
changesDiffOpenPaneDesc: 'Ancré sous le panneau source (division de diff style VSCode)',
|
||||
changesLoadError: 'Les enregistrements de fichiers de la session sont indisponibles',
|
||||
staged: 'En scène',
|
||||
unstaged: 'Hors scène',
|
||||
@@ -191,6 +199,13 @@ export const fr: Record<string, string> = {
|
||||
addToConversation: 'Ajouter à la discussion',
|
||||
copyRelative: 'Copier le chemin relatif',
|
||||
copyAbsolute: 'Copier le chemin absolu',
|
||||
rename: 'Renommer',
|
||||
renameInvalid: 'Le nom ne peut pas être vide ni contenir de séparateurs de chemin.',
|
||||
delete: 'Supprimer',
|
||||
deleteTitle: 'Supprimer « {name} » ?',
|
||||
deleteDescFile: 'Ce fichier sera définitivement supprimé. Cette action est irréversible.',
|
||||
deleteDescDir: 'Ce répertoire et tout son contenu seront définitivement supprimés. Cette action est irréversible.',
|
||||
dismiss: 'Fermer',
|
||||
download: 'Télécharger',
|
||||
uploadFiles: 'Importer des fichiers',
|
||||
uploadFolder: 'Importer un dossier',
|
||||
@@ -209,13 +224,6 @@ export const fr: Record<string, string> = {
|
||||
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',
|
||||
@@ -414,4 +422,29 @@ export const fr: Record<string, string> = {
|
||||
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',
|
||||
pluginBilingualReaderDesc: 'Lire des PDF dans la barre latérale DSH : affichage PDF natif, sélectionnez du texte pour le traduire avec le LLM, avec contexte et totalement isolé de la conversation principale',
|
||||
changesRedactOn: "Masquage actif : les secrets s'affichent en [REDACTED] ; cliquez pour désactiver (à vos risques)",
|
||||
changesRedactOff: "Masquage désactivé : le contenu s'affiche tel quel ; cliquez pour activer",
|
||||
changesRedactOnLabel: "Masquage",
|
||||
changesRedactOffLabel: "Sans masquage",
|
||||
changesRedactBanner: "Masqué",
|
||||
pluginSentinelName: 'dsh-sentinel Système de réveil',
|
||||
pluginEgoBrowserName: 'ego-browser Navigateur d’agents',
|
||||
pluginBetterOverleafName: 'dsh-better-overleaf Onglet Overleaf',
|
||||
pluginDocsPanelName: 'dsh-docs-panel Docs globales',
|
||||
pluginFlowglassName: 'dsh-flowglass Flowglass',
|
||||
pluginGitForgeName: 'dsh-git-forge Identifiants Git',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Dépôts distants Git',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench Atelier GitHub',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa Sélectionner & demander',
|
||||
pluginSidenoteName: 'dsh-sidenote Chat latéral',
|
||||
pluginServerDeckName: 'dsh-server-deck Tableau de bord serveurs',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel Tunnel SSH',
|
||||
pluginTurnReviewName: 'dsh-turn-review Revue du tour',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader Lecteur bilingue',
|
||||
pluginOfficeName: 'Aperçu Office',
|
||||
pluginMdExportName: 'Exportation Markdown',
|
||||
pluginCodeNavName: 'Navigateur d’aperçu de code',
|
||||
pluginVideoPreviewName: 'Aperçu vidéo',
|
||||
presetDshDesktopDesc: 'Mode avancé Electron (sans bordure) : macOS réserve 20px en haut ; Windows réserve 32px pour la barre de titre sans WCO',
|
||||
}
|
||||
|
||||
+49
-16
@@ -17,13 +17,27 @@
|
||||
/** The hi dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
|
||||
export const hi: Record<string, string> = {
|
||||
files: 'फ़ाइलें',
|
||||
guideDescFiles: 'वर्कस्पेस फ़ाइल ट्री देखें; क्लिक करने पर प्रीव्यू या संपादन खुलता है',
|
||||
guideDescGit: 'Git बदलाव और इस सत्र के फ़ाइल बदलाव एक ही टैब में',
|
||||
guideDescSubagent: 'सबएजेंट टोपोलॉजी और बैकग्राउंड कार्य, लाइव',
|
||||
guideDescSidechat: 'इस सत्र का संदर्भ विरासत में लेने वाला स्वतंत्र फ़ॉलो-अप थ्रेड',
|
||||
guideDescTerminal: 'असली शेल (xterm + pty), रीकनेक्ट सहित',
|
||||
guideDescBrowser: 'अंतर्निहित ब्राउज़र, चैट के लिंक भी खोल सकता है',
|
||||
changesSessionEmpty: 'इस सत्र में अभी कोई फ़ाइल ऑपरेशन नहीं',
|
||||
changesRead: 'पढ़ना',
|
||||
changesMdReading: 'पठन',
|
||||
changesHtmlRender: "रेंडर",
|
||||
changesHtmlRaw: "मूल",
|
||||
changesPdfRender: "रेंडर",
|
||||
changesPdfRaw: "मूल",
|
||||
changesMdRaw: 'मूल पाठ',
|
||||
changesWrite: 'लिखना',
|
||||
changesEdit: 'संपादन',
|
||||
changesRunning: 'चल रहा है',
|
||||
changesError: 'त्रुटि',
|
||||
changesFold: '{count} पंक्तियाँ… विस्तार के लिए क्लिक करें',
|
||||
changesFoldLoading: 'विस्तार हो रहा है…',
|
||||
changesFoldUnavailable: 'संदर्भ अनुपलब्ध',
|
||||
changesContext: 'संदर्भ',
|
||||
changesPriorUnknown: 'बदलाव से पहले की सामग्री विंडो में नहीं है; पूरी नई जोड़ी गई दिखाई जाती है',
|
||||
explorer: 'एक्सप्लोरर',
|
||||
@@ -73,7 +87,6 @@ export const hi: Record<string, string> = {
|
||||
closeOtherTabs: 'अन्य टैब बंद करें',
|
||||
closeLeftTabs: 'बाएँ टैब बंद करें',
|
||||
closeRightTabs: 'दाएँ टैब बंद करें',
|
||||
moveToFreeWindow: 'फ़्री विंडो में ले जाएँ',
|
||||
floatDropHint: 'फ़्री विंडो में खोलने के लिए छोड़ें',
|
||||
dockToSidebar: 'साइडबार पर वापस जाएँ',
|
||||
pinTerminal: 'टर्मिनल पिन करें',
|
||||
@@ -88,16 +101,17 @@ export const hi: Record<string, string> = {
|
||||
pinnedTerminalScopeGlobal: 'वैश्विक रूप से पिन किया गया',
|
||||
pinnedRailLabel: 'पिन किए गए टर्मिनल',
|
||||
closePinnedTerminal: 'टर्मिनल बंद करें',
|
||||
collapse: 'साइडबार संक्षिप्त करें',
|
||||
expand: 'साइडबार विस्तृत करें',
|
||||
collapseBottomPanel: 'निचला पैनल संक्षिप्त करें',
|
||||
expandBottomPanel: 'निचला पैनल विस्तृत करें',
|
||||
terminalError: 'टर्मिनल कनेक्शन विफल',
|
||||
terminalConnectFailed: 'टर्मिनल बार-बार कनेक्ट विफल',
|
||||
terminalRetry: 'पुनः प्रयास',
|
||||
terminalWaitBanner: 'एजेंट {needle} की प्रतीक्षा कर रहा है',
|
||||
terminalSkipWait: 'प्रतीक्षा छोड़ें',
|
||||
terminalDepsFailed: 'टर्मिनल निर्भरता node-pty लोड विफल',
|
||||
terminalDepsHint: 'DSH मशीन पर एक टर्मिनल या cmd में नीचे दिया गया कमांड चलाएँ, फिर पुनः प्रयास करें (node-pty DSH कोर संस्करण के साथ सिंक रहता है):',
|
||||
terminalDepsProfile: ' (पहचाना गया प्रोफ़ाइल: {profile})',
|
||||
terminalShellNotFound: 'कॉन्फ़िगर किया गया shell नहीं मिला: {name} — Settings → Side card → Terminal में shell पथ जाँचें',
|
||||
preview: 'पूर्वावलोकन',
|
||||
toc: 'विषय-सूची',
|
||||
edit: 'संपादित करें',
|
||||
@@ -144,12 +158,6 @@ export const hi: Record<string, string> = {
|
||||
changesOpenDiffTab: 'अलग diff टैब में खोलें',
|
||||
changesClosePreview: 'पूर्वावलोकन बंद करें',
|
||||
changesResizePreview: 'पूर्वावलोकन का आकार बदलें',
|
||||
changesDiffOpenTitle: 'diff खुलता है',
|
||||
changesDiffOpenDesc: '«अलग diff टैब में खोलें» क्रिया diff को कहाँ रखती है',
|
||||
changesDiffOpenFloat: 'मुक्त विंडो',
|
||||
changesDiffOpenFloatDesc: 'व्यूपोर्ट के केंद्र में एक तैरती विंडो — खींचें, आकार बदलें, ऊपर रखें',
|
||||
changesDiffOpenPane: 'पैनल',
|
||||
changesDiffOpenPaneDesc: 'सोर्स पैनल के नीचे डॉक किया (VSCode-शैली diff स्प्लिट)',
|
||||
changesLoadError: 'सेशन फ़ाइल रिकॉर्ड अभी अनुपलब्ध हैं',
|
||||
staged: 'स्टेज किया',
|
||||
unstaged: 'अनस्टेज',
|
||||
@@ -198,6 +206,13 @@ export const hi: Record<string, string> = {
|
||||
addToConversation: 'वार्तालाप में जोड़ें',
|
||||
copyRelative: 'सापेक्ष पथ कॉपी',
|
||||
copyAbsolute: 'निरपेक्ष पथ कॉपी',
|
||||
rename: 'नाम बदलें',
|
||||
renameInvalid: 'नाम खाली नहीं हो सकता और उसमें पथ विभाजक नहीं हो सकते।',
|
||||
delete: 'हटाएँ',
|
||||
deleteTitle: '“{name}” हटाएँ?',
|
||||
deleteDescFile: 'यह फ़ाइल स्थायी रूप से हटा दी जाएगी। इसे पूर्ववत नहीं किया जा सकता।',
|
||||
deleteDescDir: 'यह निर्देशिका और उसकी सारी सामग्री स्थायी रूप से हटा दी जाएगी। इसे पूर्ववत नहीं किया जा सकता।',
|
||||
dismiss: 'बंद करें',
|
||||
download: 'डाउनलोड',
|
||||
uploadFiles: 'फ़ाइलें अपलोड करें',
|
||||
uploadFolder: 'फ़ोल्डर अपलोड करें',
|
||||
@@ -216,13 +231,6 @@ export const hi: Record<string, string> = {
|
||||
settingsIntro: 'साइड कार्ड क्या दिखाता है और कैसे व्यवहार करता है प्रबंधित करें',
|
||||
settingsPopupDesc: '{feature} के लिए संबंधित विकल्प कॉन्फ़िगर करें',
|
||||
settingsDone: 'पूर्ण',
|
||||
settingsOpenTitle: 'नए वार्तालाप के लिए डिफ़ॉल्ट रूप से खोलें',
|
||||
settingsOpenDesc: 'बिल्कुल नए वार्तालाप के लिए साइड कार्ड स्वतः विस्तृत करें; मौजूदा वार्तालाप अपना लेआउट रखते हैं',
|
||||
settingsWidthTitle: 'डिफ़ॉल्ट चौड़ाई हिस्सा',
|
||||
settingsWidthDesc: 'नए वार्तालाप के लिए साइड कार्ड की विंडो चौड़ाई का डिफ़ॉल्ट हिस्सा (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'चैट फ़ाइलें साइडबार में खोलें',
|
||||
settingsOpenPathDesc: 'चैट में फ़ाइल लिंक (टूल पंक्ति, उत्पादित फ़ाइलें, उल्लेख) क्लिक करने पर सिस्टम डिफ़ॉल्ट ऐप के बजाय साइडबार एडिटर में खोलें',
|
||||
settingsOpenToolsTitle: 'मॉडल के लिए साइडबार ओपन टूल इंजेक्ट करें',
|
||||
settingsOpenToolsDesc: 'चालू होने पर, मॉडल sidebar_open टूल से साइडबार में फ़ाइलें, फ़ोल्डर और HTTP(S) पेज खोल सकता है (डिफ़ॉल्ट रूप से बंद)',
|
||||
settingsTitleBarTitle: 'स्थिति संगतता मोड',
|
||||
@@ -421,4 +429,29 @@ export const hi: Record<string, string> = {
|
||||
pluginDocsPanelDesc: 'DSH साइडबार में ग्लोबल डॉक्स: किसी भी वर्कस्पेस से अपने Markdown नोट्स पढ़ें — फ़ाइल सूची, रूपरेखा, Chrome / VS Code में खोलें, और कॉपी बटन; docs डायरेक्टरी कॉन्फ़िगर करने योग्य (डिफ़ॉल्ट ~/.dsh/docs)',
|
||||
pluginEgoBrowserDesc: 'DeepSeek Harness के लिए एजेंट ब्राउज़र: 32 ego_* टूल असली Chromium चलाते हैं; साइडबार में नेटिव «ego browser» टैब एजेंट के हर विज़िट किए गए पेज को लाइव दिखाता है — आप क्लिक, ड्रैग और टाइप करके नियंत्रण ले सकते हैं। better-sidebar मौजूद होने पर टैब स्वतः रजिस्टर होता है, अन्यथा फ्लोटिंग बबल के रूप में दिखता है',
|
||||
pluginBilingualReaderDesc: 'DSH साइडबार में PDF पढ़ें: मूल PDF प्रदर्शन, टेक्स्ट चुनकर LLM से अनुवाद करें, संदर्भ के साथ और मुख्य संवाद से पूरी तरह अलग',
|
||||
changesRedactOn: "रिडैक्शन चालू: संवेदनशील मान [REDACTED] दिखेंगे; बंद करने के लिए क्लिक करें (अपने जोखिम पर)",
|
||||
changesRedactOff: "रिडैक्शन बंद: सामग्री जैसी है वैसी दिखेगी; चालू करने के लिए क्लिक करें",
|
||||
changesRedactOnLabel: "रिडैक्शन",
|
||||
changesRedactOffLabel: "बिना रिडैक्शन",
|
||||
changesRedactBanner: "रिडैक्टेड",
|
||||
pluginSentinelName: 'dsh-sentinel वेक-अप सिस्टम',
|
||||
pluginEgoBrowserName: 'ego-browser एजेंट ब्राउज़र',
|
||||
pluginBetterOverleafName: 'dsh-better-overleaf Overleaf टैब',
|
||||
pluginDocsPanelName: 'dsh-docs-panel ग्लोबल डॉक्स',
|
||||
pluginFlowglassName: 'dsh-flowglass Flowglass',
|
||||
pluginGitForgeName: 'dsh-git-forge Git क्रेडेंशियल',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Git रिमोट',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench GitHub वर्कबेंच',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa चुनें और पूछें',
|
||||
pluginSidenoteName: 'dsh-sidenote साइड चैट',
|
||||
pluginServerDeckName: 'dsh-server-deck सर्वर डेक',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel SSH टनल',
|
||||
pluginTurnReviewName: 'dsh-turn-review टर्न समीक्षा',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader द्विभाषी रीडर',
|
||||
pluginOfficeName: 'Office प्रीव्यू',
|
||||
pluginMdExportName: 'Markdown एक्सपोर्ट',
|
||||
pluginCodeNavName: 'कोड प्रीव्यू नेविगेटर',
|
||||
pluginVideoPreviewName: 'वीडियो प्रीव्यू',
|
||||
presetDshDesktopDesc: 'Electron एडवांस्ड (फ्रेमलेस) मोड: macOS ऊपर 20px सुरक्षित रखता है; Windows WCO अनुपलब्ध होने पर टाइटल बार के लिए 32px रखता है',
|
||||
}
|
||||
|
||||
+49
-16
@@ -15,13 +15,27 @@
|
||||
|
||||
export const id: Record<string, string> = {
|
||||
files: 'Berkas',
|
||||
guideDescFiles: 'Telusuri pohon berkas workspace; klik untuk pratinjau atau edit',
|
||||
guideDescGit: 'Perubahan Git dan perubahan berkas sesi ini dalam satu tab',
|
||||
guideDescSubagent: 'Topologi subagent dan tugas latar belakang, langsung',
|
||||
guideDescSidechat: 'Utas lanjutan independen yang mewarisi konteks sesi ini',
|
||||
guideDescTerminal: 'Shell sungguhan (xterm + pty), bisa menyambung ulang',
|
||||
guideDescBrowser: 'Peramban tertanam, bisa mengambil alih tautan chat',
|
||||
changesSessionEmpty: 'Belum ada operasi file di sesi ini',
|
||||
changesRead: 'Baca',
|
||||
changesMdReading: 'Baca',
|
||||
changesHtmlRender: "Render",
|
||||
changesHtmlRaw: "Sumber",
|
||||
changesPdfRender: "Render",
|
||||
changesPdfRaw: "Sumber",
|
||||
changesMdRaw: 'Mentah',
|
||||
changesWrite: 'Tulis',
|
||||
changesEdit: 'Edit',
|
||||
changesRunning: 'berjalan',
|
||||
changesError: 'error',
|
||||
changesFold: '{count} baris… klik untuk meluaskan',
|
||||
changesFoldLoading: 'Meluaskan…',
|
||||
changesFoldUnavailable: 'Konteks tidak tersedia',
|
||||
changesContext: 'konteks',
|
||||
changesPriorUnknown: 'Konten sebelum perubahan berada di luar jendela; ditampilkan sebagai sepenuhnya baru',
|
||||
explorer: 'Explorer',
|
||||
@@ -71,7 +85,6 @@ export const id: Record<string, string> = {
|
||||
closeOtherTabs: 'Tutup Tab Lain',
|
||||
closeLeftTabs: 'Tutup Tab di Kiri',
|
||||
closeRightTabs: 'Tutup Tab di Kanan',
|
||||
moveToFreeWindow: 'Pindahkan ke jendela bebas',
|
||||
floatDropHint: 'Lepaskan untuk membuka di jendela bebas',
|
||||
dockToSidebar: 'Kembali ke bilah samping',
|
||||
pinTerminal: 'Sematkan Terminal',
|
||||
@@ -86,16 +99,17 @@ export const id: Record<string, string> = {
|
||||
pinnedTerminalScopeGlobal: 'Disematkan global',
|
||||
pinnedRailLabel: 'Terminal Tersemat',
|
||||
closePinnedTerminal: 'Tutup Terminal',
|
||||
collapse: 'Lipat sidebar',
|
||||
expand: 'Bentangkan sidebar',
|
||||
collapseBottomPanel: 'Lipat panel bawah',
|
||||
expandBottomPanel: 'Bentangkan panel bawah',
|
||||
terminalError: 'Koneksi terminal gagal',
|
||||
terminalConnectFailed: 'Terminal gagal terhubung berulang kali',
|
||||
terminalRetry: 'Coba lagi',
|
||||
terminalWaitBanner: 'Agen menunggu {needle}',
|
||||
terminalSkipWait: 'Lewati penungguan',
|
||||
terminalDepsFailed: 'Dependensi terminal node-pty gagal dimuat',
|
||||
terminalDepsHint: 'Jalankan perintah di bawah ini di terminal atau cmd pada mesin DSH untuk memperbaikinya, lalu coba lagi (node-pty tetap sinkron dengan versi inti DSH):',
|
||||
terminalDepsProfile: ' (profil terdeteksi: {profile})',
|
||||
terminalShellNotFound: 'Shell yang dikonfigurasi tidak ditemukan: {name} — periksa path shell di Pengaturan → Side card → Terminal',
|
||||
preview: 'Pratinjau',
|
||||
toc: 'Daftar Isi',
|
||||
edit: 'Edit',
|
||||
@@ -142,12 +156,6 @@ export const id: Record<string, string> = {
|
||||
changesOpenDiffTab: 'Buka di tab diff terpisah',
|
||||
changesClosePreview: 'Tutup pratinjau',
|
||||
changesResizePreview: 'Ubah ukuran pratinjau',
|
||||
changesDiffOpenTitle: 'Diff terbuka sebagai',
|
||||
changesDiffOpenDesc: 'Di mana aksi "buka di tab diff terpisah" menempatkan diff',
|
||||
changesDiffOpenFloat: 'Jendela bebas',
|
||||
changesDiffOpenFloatDesc: 'Jendela mengambang di tengah viewport — geser, ubah ukuran, selalu di atas',
|
||||
changesDiffOpenPane: 'Panel',
|
||||
changesDiffOpenPaneDesc: 'Ditambat di bawah panel sumber (split diff gaya VSCode)',
|
||||
changesLoadError: 'Catatan berkas sesi sedang tidak tersedia',
|
||||
staged: 'Staged',
|
||||
unstaged: 'Unstaged',
|
||||
@@ -196,6 +204,13 @@ export const id: Record<string, string> = {
|
||||
addToConversation: 'Tambahkan ke obrolan',
|
||||
copyRelative: 'Salin path relatif',
|
||||
copyAbsolute: 'Salin path absolut',
|
||||
rename: 'Ganti nama',
|
||||
renameInvalid: 'Nama tidak boleh kosong atau mengandung pemisah jalur.',
|
||||
delete: 'Hapus',
|
||||
deleteTitle: 'Hapus "{name}"?',
|
||||
deleteDescFile: 'Ini menghapus file secara permanen. Tidak dapat dibatalkan.',
|
||||
deleteDescDir: 'Ini menghapus direktori dan seluruh isinya secara permanen. Tidak dapat dibatalkan.',
|
||||
dismiss: 'Tutup',
|
||||
download: 'Unduh',
|
||||
uploadFiles: 'Unggah berkas',
|
||||
uploadFolder: 'Unggah folder',
|
||||
@@ -214,13 +229,6 @@ export const id: Record<string, string> = {
|
||||
settingsIntro: 'Kelola apa yang ditampilkan kartu samping dan perilakunya',
|
||||
settingsPopupDesc: 'Konfigurasikan opsi terkait untuk {feature}',
|
||||
settingsDone: 'Selesai',
|
||||
settingsOpenTitle: 'Buka secara default untuk obrolan baru',
|
||||
settingsOpenDesc: 'Bentangkan kartu samping secara otomatis untuk obrolan baru; obrolan yang sudah ada mempertahankan tata letaknya',
|
||||
settingsWidthTitle: 'Porsi lebar default',
|
||||
settingsWidthDesc: 'Porsi lebar jendela default kartu samping untuk obrolan baru (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'Buka berkas obrolan di sidebar',
|
||||
settingsOpenPathDesc: 'Buka tautan berkas di obrolan (baris alat, berkas yang dihasilkan, mention) di editor sidebar alih-alih aplikasi default sistem',
|
||||
settingsOpenToolsTitle: 'Suntik alat buka sidebar untuk model',
|
||||
settingsOpenToolsDesc: 'Saat diaktifkan, model dapat membuka file, folder, dan halaman HTTP(S) di sidebar melalui alat sidebar_open (nonaktif secara default)',
|
||||
settingsTitleBarTitle: 'Mode kompatibilitas posisi',
|
||||
@@ -419,4 +427,29 @@ export const id: Record<string, string> = {
|
||||
pluginDocsPanelDesc: 'Dokumen global di sidebar DSH: baca catatan Markdown Anda sendiri dari ruang kerja apa pun — daftar berkas, kerangka, buka di Chrome / VS Code, dan tombol salin; direktori docs dapat dikonfigurasi (default ~/.dsh/docs)',
|
||||
pluginEgoBrowserDesc: 'Browser agen untuk DeepSeek Harness: 32 alat ego_* menggerakkan Chromium sungguhan; tab «ego browser» native di bilah samping menampilkan langsung setiap halaman yang dikunjungi agen — Anda bisa mengklik, menyeret, dan mengetik untuk mengambil alih. Tab terdaftar otomatis jika better-sidebar terpasang; jika tidak, jatuh ke gelembung mengambang',
|
||||
pluginBilingualReaderDesc: 'Baca PDF di sidebar DSH: tampilan PDF asli, pilih teks untuk diterjemahkan dengan LLM, dengan konteks dan sepenuhnya terisolasi dari percakapan utama',
|
||||
changesRedactOn: "Redaksi aktif: rahasia tampil sebagai [REDACTED]; klik untuk mematikan (risiko Anda sendiri)",
|
||||
changesRedactOff: "Redaksi mati: konten tampil apa adanya; klik untuk mengaktifkan",
|
||||
changesRedactOnLabel: "Redaksi",
|
||||
changesRedactOffLabel: "Tanpa redaksi",
|
||||
changesRedactBanner: "Teredaksi",
|
||||
pluginSentinelName: 'dsh-sentinel Sistem bangun',
|
||||
pluginEgoBrowserName: 'ego-browser Peramban agen',
|
||||
pluginBetterOverleafName: 'dsh-better-overleaf Tab Overleaf',
|
||||
pluginDocsPanelName: 'dsh-docs-panel Dokumen global',
|
||||
pluginFlowglassName: 'dsh-flowglass Flowglass',
|
||||
pluginGitForgeName: 'dsh-git-forge Kredensial Git',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Remote Git',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench Meja kerja GitHub',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa Pilih & tanya',
|
||||
pluginSidenoteName: 'dsh-sidenote Obrolan samping',
|
||||
pluginServerDeckName: 'dsh-server-deck Dek server',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel Terowongan SSH',
|
||||
pluginTurnReviewName: 'dsh-turn-review Tinjauan giliran',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader Pembaca dwibahasa',
|
||||
pluginOfficeName: 'Pratinjau Office',
|
||||
pluginMdExportName: 'Ekspor Markdown',
|
||||
pluginCodeNavName: 'Navigator pratinjau kode',
|
||||
pluginVideoPreviewName: 'Pratinjau video',
|
||||
presetDshDesktopDesc: 'Mode Electron lanjutan (tanpa bingkai): macOS mencadangkan 20px di atas; Windows mencadangkan 32px untuk bilah judul saat WCO tidak tersedia',
|
||||
}
|
||||
|
||||
+49
-16
@@ -8,13 +8,27 @@
|
||||
|
||||
export const it: Record<string, string> = {
|
||||
files: 'File',
|
||||
guideDescFiles: 'Sfoglia l\'albero del workspace; un clic apre anteprima o editor',
|
||||
guideDescGit: 'Modifiche Git e modifiche ai file di questa sessione in un solo tab',
|
||||
guideDescSubagent: 'Topologia dei subagent e attività in background, in tempo reale',
|
||||
guideDescSidechat: 'Un thread di approfondimento indipendente che eredita il contesto della sessione',
|
||||
guideDescTerminal: 'Una vera shell (xterm + pty) con riconnessione',
|
||||
guideDescBrowser: 'Browser integrato, può aprire i link della chat',
|
||||
changesSessionEmpty: 'Ancora nessuna operazione sui file in questa sessione',
|
||||
changesRead: 'Lettura',
|
||||
changesMdReading: 'Lettura',
|
||||
changesHtmlRender: "Render",
|
||||
changesHtmlRaw: "Sorgente",
|
||||
changesPdfRender: "Render",
|
||||
changesPdfRaw: "Sorgente",
|
||||
changesMdRaw: 'Grezzo',
|
||||
changesWrite: 'Scrittura',
|
||||
changesEdit: 'Modifica',
|
||||
changesRunning: 'in esecuzione',
|
||||
changesError: 'errore',
|
||||
changesFold: '{count} righe… clicca per espandere',
|
||||
changesFoldLoading: 'Espansione…',
|
||||
changesFoldUnavailable: 'Contesto non disponibile',
|
||||
changesContext: 'contesto',
|
||||
changesPriorUnknown: 'Il contenuto precedente è fuori dalla finestra; mostrato come interamente aggiunto',
|
||||
explorer: 'Esplora risorse',
|
||||
@@ -64,7 +78,6 @@ export const it: Record<string, string> = {
|
||||
closeOtherTabs: 'Chiudi le altre schede',
|
||||
closeLeftTabs: 'Chiudi le schede a sinistra',
|
||||
closeRightTabs: 'Chiudi le schede a destra',
|
||||
moveToFreeWindow: 'Sposta in finestra libera',
|
||||
floatDropHint: 'Rilascia per aprire in una finestra libera',
|
||||
dockToSidebar: 'Torna alla barra laterale',
|
||||
pinTerminal: 'Blocca Terminale',
|
||||
@@ -79,16 +92,17 @@ export const it: Record<string, string> = {
|
||||
pinnedTerminalScopeGlobal: 'Bloccato globalmente',
|
||||
pinnedRailLabel: 'Terminali Bloccati',
|
||||
closePinnedTerminal: 'Chiudi Terminale',
|
||||
collapse: 'Comprimi barra laterale',
|
||||
expand: 'Espandi barra laterale',
|
||||
collapseBottomPanel: 'Comprimi pannello inferiore',
|
||||
expandBottomPanel: 'Espandi pannello inferiore',
|
||||
terminalError: 'Connessione del terminale non riuscita',
|
||||
terminalConnectFailed: 'Il terminale non è riuscito a connettersi più volte',
|
||||
terminalRetry: 'Riprova',
|
||||
terminalWaitBanner: 'L’agente è in attesa di {needle}',
|
||||
terminalSkipWait: 'Salta attesa',
|
||||
terminalDepsFailed: 'Caricamento della dipendenza del terminale node-pty non riuscito',
|
||||
terminalDepsHint: 'Esegua il comando seguente in un terminale o cmd sul computer DSH per ripristinarlo, poi riprovi (node-pty resta sincronizzato con la versione del core DSH):',
|
||||
terminalDepsProfile: ' (rilevato profilo: {profile})',
|
||||
terminalShellNotFound: 'Shell configurata non trovata: {name} — verifica il percorso della shell in Impostazioni → Side card → Terminal',
|
||||
preview: 'Anteprima',
|
||||
toc: 'Indice',
|
||||
edit: 'Modifica',
|
||||
@@ -135,12 +149,6 @@ export const it: Record<string, string> = {
|
||||
changesOpenDiffTab: 'Apri in una scheda diff',
|
||||
changesClosePreview: 'Ridimensiona l\'anteprima',
|
||||
changesResizePreview: 'Ridimensiona anteprima',
|
||||
changesDiffOpenTitle: 'Il diff si apre come',
|
||||
changesDiffOpenDesc: 'Dove l\'azione "apri in una scheda diff" colloca il diff',
|
||||
changesDiffOpenFloat: 'Finestra libera',
|
||||
changesDiffOpenFloatDesc: 'Una finestra flottante centrata sul viewport — trascina, ridimensiona, sempre in primo piano',
|
||||
changesDiffOpenPane: 'Pannello',
|
||||
changesDiffOpenPaneDesc: 'Ancorato sotto il pannello sorgente (divisione diff in stile VSCode)',
|
||||
changesLoadError: 'I record dei file della sessione non sono disponibili',
|
||||
staged: 'In stage',
|
||||
unstaged: 'Non in stage',
|
||||
@@ -189,6 +197,13 @@ export const it: Record<string, string> = {
|
||||
addToConversation: 'Aggiungi alla conversazione',
|
||||
copyRelative: 'Copia percorso relativo',
|
||||
copyAbsolute: 'Copia percorso assoluto',
|
||||
rename: 'Rinomina',
|
||||
renameInvalid: 'Il nome non può essere vuoto o contenere separatori di percorso.',
|
||||
delete: 'Elimina',
|
||||
deleteTitle: 'Eliminare "{name}"?',
|
||||
deleteDescFile: 'Il file verrà eliminato definitivamente. Questa azione non può essere annullata.',
|
||||
deleteDescDir: 'La directory e tutto il suo contenuto verranno eliminati definitivamente. Questa azione non può essere annullata.',
|
||||
dismiss: 'Chiudi',
|
||||
download: 'Scarica',
|
||||
uploadFiles: 'Carica file',
|
||||
uploadFolder: 'Carica cartella',
|
||||
@@ -207,13 +222,6 @@ export const it: Record<string, string> = {
|
||||
settingsIntro: 'Gestisca cosa mostra la scheda laterale e come si comporta',
|
||||
settingsPopupDesc: 'Configuri le opzioni relative a {feature}',
|
||||
settingsDone: 'Fatto',
|
||||
settingsOpenTitle: 'Apri per impostazione predefinita per le nuove conversazioni',
|
||||
settingsOpenDesc: 'Espande automaticamente la scheda laterale per le conversazioni nuove; le conversazioni esistenti mantengono il proprio layout',
|
||||
settingsWidthTitle: 'Quota di larghezza predefinita',
|
||||
settingsWidthDesc: 'La quota predefinita della scheda laterale sulla larghezza della finestra per le nuove conversazioni (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'Apri i file della chat nella barra laterale',
|
||||
settingsOpenPathDesc: 'Apre i collegamenti ai file nella chat (righe di strumenti, file prodotti, menzioni) nell’editor della barra laterale invece dell’app predefinita di sistema',
|
||||
settingsOpenToolsTitle: 'Iniettare lo strumento di apertura della barra laterale per il modello',
|
||||
settingsOpenToolsDesc: 'Se attivato, il modello può aprire file, cartelle e pagine HTTP(S) nella barra laterale tramite lo strumento sidebar_open (disattivato per impostazione predefinita)',
|
||||
settingsTitleBarTitle: 'Modalità di compatibilità della posizione',
|
||||
@@ -412,4 +420,29 @@ export const it: Record<string, string> = {
|
||||
pluginDocsPanelDesc: 'Documenti globali nella barra laterale di DSH: legga le sue note Markdown da qualsiasi spazio di lavoro — una lista di file, una struttura, apertura in Chrome / VS Code e pulsanti di copia; la directory dei documenti è configurabile (predefinita ~/.dsh/docs)',
|
||||
pluginEgoBrowserDesc: 'Il browser agente per DeepSeek Harness: 32 strumenti ego_* pilotano un vero Chromium; una scheda nativa «ego browser» nella barra laterale mostra in tempo reale ogni pagina visitata dall’agente — puoi cliccare, trascinare e digitare per prendere il controllo. Registra la scheda automaticamente se better-sidebar è presente, altrimenti una bolla flottante',
|
||||
pluginBilingualReaderDesc: 'Leggi PDF nella barra laterale DSH: visualizzazione PDF nativa, seleziona il testo per tradurlo con l’LLM, con contesto e completamente isolato dalla conversazione principale',
|
||||
changesRedactOn: "Mascheramento attivo: i segreti appaiono come [REDACTED]; clicca per disattivare (a tuo rischio)",
|
||||
changesRedactOff: "Mascheramento disattivo: il contenuto appare invariato; clicca per attivare",
|
||||
changesRedactOnLabel: "Mascheramento",
|
||||
changesRedactOffLabel: "Senza mascheramento",
|
||||
changesRedactBanner: "Mascherato",
|
||||
pluginSentinelName: 'dsh-sentinel Sistema di riattivazione',
|
||||
pluginEgoBrowserName: 'ego-browser Browser dell’agente',
|
||||
pluginBetterOverleafName: 'dsh-better-overleaf Scheda Overleaf',
|
||||
pluginDocsPanelName: 'dsh-docs-panel Documenti globali',
|
||||
pluginFlowglassName: 'dsh-flowglass Flowglass',
|
||||
pluginGitForgeName: 'dsh-git-forge Credenziali Git',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Remote Git',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench Banco di lavoro GitHub',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa Seleziona e chiedi',
|
||||
pluginSidenoteName: 'dsh-sidenote Chat laterale',
|
||||
pluginServerDeckName: 'dsh-server-deck Ponte server',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel Tunnel SSH',
|
||||
pluginTurnReviewName: 'dsh-turn-review Revisione del turno',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader Lettore bilingue',
|
||||
pluginOfficeName: 'Anteprima Office',
|
||||
pluginMdExportName: 'Esportazione Markdown',
|
||||
pluginCodeNavName: 'Navigatore anteprima codice',
|
||||
pluginVideoPreviewName: 'Anteprima video',
|
||||
presetDshDesktopDesc: 'Modalità avanzata Electron (senza bordi): macOS riserva 20px in alto; Windows riserva 32px per la barra del titolo quando WCO non è disponibile',
|
||||
}
|
||||
|
||||
+49
-16
@@ -17,13 +17,27 @@
|
||||
/** The ja dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
|
||||
export const ja: Record<string, string> = {
|
||||
files: 'ファイル',
|
||||
guideDescFiles: 'ワークスペースのファイルツリーを閲覧し、クリックでプレビュー/編集',
|
||||
guideDescGit: 'Git の変更と今回のセッションのファイル変更を 1 つのタブに',
|
||||
guideDescSubagent: 'サブエージェントの構成とバックグラウンドタスクをリアルタイム表示',
|
||||
guideDescSidechat: '現在のセッションの文脈を引き継ぐ独立した追加スレッド',
|
||||
guideDescTerminal: '本物のシェル(xterm + pty)、再接続対応',
|
||||
guideDescBrowser: '埋め込みブラウザ。チャット内のリンクも引き受け可能',
|
||||
changesSessionEmpty: 'このセッションにはファイル操作がまだありません',
|
||||
changesRead: '読み取り',
|
||||
changesMdReading: '閲覧',
|
||||
changesHtmlRender: "レンダリング",
|
||||
changesHtmlRaw: "原文",
|
||||
changesPdfRender: "レンダリング",
|
||||
changesPdfRaw: "原文",
|
||||
changesMdRaw: '原文',
|
||||
changesWrite: '書き込み',
|
||||
changesEdit: '編集',
|
||||
changesRunning: '実行中',
|
||||
changesError: 'エラー',
|
||||
changesFold: '{count} 行…クリックで展開',
|
||||
changesFoldLoading: '展開中…',
|
||||
changesFoldUnavailable: 'コンテキスト未読み込み',
|
||||
changesContext: 'コンテキスト',
|
||||
changesPriorUnknown: '変更前の内容がウィンドウ内になく、すべて新規追加として表示されます',
|
||||
explorer: 'エクスプローラー',
|
||||
@@ -73,7 +87,6 @@ export const ja: Record<string, string> = {
|
||||
closeOtherTabs: '他のタブを閉じる',
|
||||
closeLeftTabs: '左のタブを閉じる',
|
||||
closeRightTabs: '右のタブを閉じる',
|
||||
moveToFreeWindow: 'フリーウィンドウへ移動',
|
||||
floatDropHint: '離すとフリーウィンドウで開きます',
|
||||
dockToSidebar: 'サイドバーへ戻す',
|
||||
pinTerminal: 'ターミナルを固定',
|
||||
@@ -88,16 +101,17 @@ export const ja: Record<string, string> = {
|
||||
pinnedTerminalScopeGlobal: 'グローバルに固定',
|
||||
pinnedRailLabel: '固定されたターミナル',
|
||||
closePinnedTerminal: 'ターミナルを閉じる',
|
||||
collapse: 'サイドバーを折りたたむ',
|
||||
expand: 'サイドバーを展開',
|
||||
collapseBottomPanel: '下パネルを折りたたむ',
|
||||
expandBottomPanel: '下パネルを展開',
|
||||
terminalError: 'ターミナル接続に失敗',
|
||||
terminalConnectFailed: 'ターミナル接続が繰り返し失敗しました',
|
||||
terminalRetry: '再試行',
|
||||
terminalWaitBanner: 'エージェントが {needle} を待機中',
|
||||
terminalSkipWait: '待機をスキップ',
|
||||
terminalDepsFailed: 'ターミナル依存関係 node-pty の読み込みに失敗',
|
||||
terminalDepsHint: 'DSH 環境のターミナルまたは cmd で以下のコマンドを実行して修復し、再試行してください(node-pty は DSH コアと同じバージョンを維持):',
|
||||
terminalDepsProfile: '(検出された profile:{profile})',
|
||||
terminalShellNotFound: '設定されたシェルが見つかりません:{name}(設定 → サイドカード → ターミナル のシェルパスを確認してください)',
|
||||
preview: 'プレビュー',
|
||||
toc: '目次',
|
||||
edit: '編集',
|
||||
@@ -144,12 +158,6 @@ export const ja: Record<string, string> = {
|
||||
changesOpenDiffTab: '独立タブで開く',
|
||||
changesClosePreview: 'プレビューを閉じる',
|
||||
changesResizePreview: 'プレビューの高さを変更',
|
||||
changesDiffOpenTitle: 'diff の展開先',
|
||||
changesDiffOpenDesc: '「独立タブで開く」操作が diff をどこに配置するか',
|
||||
changesDiffOpenFloat: 'フリーウィンドウ',
|
||||
changesDiffOpenFloatDesc: 'ビューポート中央に浮かぶウィンドウ — ドラッグ・リサイズ・最前面固定',
|
||||
changesDiffOpenPane: 'ペイン',
|
||||
changesDiffOpenPaneDesc: 'ソースペインの下にドッキング(VSCode 式 diff 分割)',
|
||||
changesLoadError: 'セッションのファイル記録は現在利用できません',
|
||||
staged: 'ステージ済み',
|
||||
unstaged: '未ステージ',
|
||||
@@ -198,6 +206,13 @@ export const ja: Record<string, string> = {
|
||||
addToConversation: '会話に追加',
|
||||
copyRelative: '相対パスをコピー',
|
||||
copyAbsolute: '絶対パスをコピー',
|
||||
rename: '名前の変更',
|
||||
renameInvalid: '名前は空にできず、パス区切り文字を含められません。',
|
||||
delete: '削除',
|
||||
deleteTitle: '「{name}」を削除しますか?',
|
||||
deleteDescFile: 'このファイルを完全に削除します。元に戻せません。',
|
||||
deleteDescDir: 'このディレクトリと内容物をすべて完全に削除します。元に戻せません。',
|
||||
dismiss: '閉じる',
|
||||
download: 'ダウンロード',
|
||||
uploadFiles: 'ファイルをアップロード',
|
||||
uploadFolder: 'フォルダーをアップロード',
|
||||
@@ -216,13 +231,6 @@ export const ja: Record<string, string> = {
|
||||
settingsIntro: 'サイドカードの表示内容とデフォルト挙動を管理',
|
||||
settingsPopupDesc: '「{feature}」の関連オプションを設定',
|
||||
settingsDone: '完了',
|
||||
settingsOpenTitle: '新規会話でデフォルト展開',
|
||||
settingsOpenDesc: '新規会話時にサイドカードを自動展開。既存の会話はそれぞれのレイアウトを維持',
|
||||
settingsWidthTitle: 'デフォルト幅の比率',
|
||||
settingsWidthDesc: '新規会話時のサイドカードがウィンドウ幅に占める割合 (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'チャット内ファイルをサイドバーで開く',
|
||||
settingsOpenPathDesc: 'チャット内のファイルリンク(ツール行、産物リスト、ファイル言及)クリック時に、システム既定アプリではなくサイドバーエディターで開く',
|
||||
settingsOpenToolsTitle: 'モデルにサイドバー開くツールを注入',
|
||||
settingsOpenToolsDesc: 'オンにすると、モデルは sidebar_open ツールでサイドバーにファイル・フォルダー・HTTP(S) ページを開ける(デフォルトオフ)',
|
||||
settingsTitleBarTitle: '位置互換モード',
|
||||
@@ -421,4 +429,29 @@ export const ja: Record<string, string> = {
|
||||
pluginDocsPanelDesc: 'DSH サイドバー内の「グローバルドキュメント」:任意のワークスペースから読めるグローバル Markdown ノート。リストで選んで閲覧、ホバーでアウトラインジャンプ、Chrome / VS Code で外部オープン、コードコピー。ディレクトリは設定可(デフォルト ~/.dsh/docs)',
|
||||
pluginEgoBrowserDesc: 'DeepSeek Harness 用エージェントブラウザ:32 個の ego_* ツールが本物の Chromium を操作し、サイドバーのネイティブ「ego ブラウザ」Tab がエージェントの訪れる全ページをライブ表示。クリック・ドラッグ・入力で操作を引き継げます。better-sidebar があれば Tab を自動登録、なければフローティングバブルにフォールバック',
|
||||
pluginBilingualReaderDesc: 'DSH サイドバーで論文 PDF を読む:ネイティブ PDF 表示、テキストを選択して LLM で翻訳。コンテキストを使い、メイン会話から完全に分離',
|
||||
changesRedactOn: "マスキング有効:機密値は [REDACTED] で表示。クリックで無効化(自己責任)",
|
||||
changesRedactOff: "マスキング無効:内容がそのまま表示されます。クリックで有効化",
|
||||
changesRedactOnLabel: "マスキング中",
|
||||
changesRedactOffLabel: "マスキング解除",
|
||||
changesRedactBanner: "マスク済み",
|
||||
pluginSentinelName: 'dsh-sentinel ウェイクアップシステム',
|
||||
pluginEgoBrowserName: 'ego-browser エージェントブラウザー',
|
||||
pluginBetterOverleafName: 'dsh-better-overleaf Overleaf タブ',
|
||||
pluginDocsPanelName: 'dsh-docs-panel グローバルドキュメント',
|
||||
pluginFlowglassName: 'dsh-flowglass Flowglass',
|
||||
pluginGitForgeName: 'dsh-git-forge Git 認証情報',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Git リモート',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench GitHub ワークベンチ',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa 選択して質問',
|
||||
pluginSidenoteName: 'dsh-sidenote サイドチャット',
|
||||
pluginServerDeckName: 'dsh-server-deck サーバーデッキ',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel SSH トンネル',
|
||||
pluginTurnReviewName: 'dsh-turn-review ターンレビュー',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader バイリンガルリーダー',
|
||||
pluginOfficeName: 'Office プレビュー',
|
||||
pluginMdExportName: 'Markdown エクスポート',
|
||||
pluginCodeNavName: 'コードプレビューナビゲーター',
|
||||
pluginVideoPreviewName: '動画プレビュー',
|
||||
presetDshDesktopDesc: 'Electron 高度モード(枠なし):macOS は上部 20px、Windows は WCO 未提供時に 32px のタイトルバー分を確保',
|
||||
}
|
||||
|
||||
+49
-16
@@ -9,13 +9,27 @@
|
||||
|
||||
export const ko: Record<string, string> = {
|
||||
files: '파일',
|
||||
guideDescFiles: '워크스페이스 파일 트리를 보고 클릭하면 미리보기/편집',
|
||||
guideDescGit: 'Git 변경 사항과 이번 세션의 파일 변경을 한 탭에서',
|
||||
guideDescSubagent: '하위 에이전트 구조와 백그라운드 작업을 실시간으로',
|
||||
guideDescSidechat: '현재 세션의 컨텍스트를 이어받는 독립 후속 스레드',
|
||||
guideDescTerminal: '실제 셸(xterm + pty), 재연결 지원',
|
||||
guideDescBrowser: '내장 브라우저, 채팅 링크도 대신 열 수 있음',
|
||||
changesSessionEmpty: '이 세션에 아직 파일 작업이 없습니다',
|
||||
changesRead: '읽기',
|
||||
changesMdReading: '읽기',
|
||||
changesHtmlRender: "렌더링",
|
||||
changesHtmlRaw: "원문",
|
||||
changesPdfRender: "렌더링",
|
||||
changesPdfRaw: "원문",
|
||||
changesMdRaw: '원문',
|
||||
changesWrite: '쓰기',
|
||||
changesEdit: '편집',
|
||||
changesRunning: '실행 중',
|
||||
changesError: '오류',
|
||||
changesFold: '{count}줄… 클릭하여 펼치기',
|
||||
changesFoldLoading: '펼치는 중…',
|
||||
changesFoldUnavailable: '컨텍스트를 불러올 수 없음',
|
||||
changesContext: '컨텍스트',
|
||||
changesPriorUnknown: '변경 전 내용이 창에 없어 모두 새로 추가된 것으로 표시됩니다',
|
||||
explorer: '탐색기',
|
||||
@@ -65,7 +79,6 @@ export const ko: Record<string, string> = {
|
||||
closeOtherTabs: '다른 탭 닫기',
|
||||
closeLeftTabs: '왼쪽 탭 닫기',
|
||||
closeRightTabs: '오른쪽 탭 닫기',
|
||||
moveToFreeWindow: '자유 창으로 이동',
|
||||
floatDropHint: '놓으면 자유 창에서 열립니다',
|
||||
dockToSidebar: '사이드바로 돌아가기',
|
||||
pinTerminal: '터미널 고정',
|
||||
@@ -80,16 +93,17 @@ export const ko: Record<string, string> = {
|
||||
pinnedTerminalScopeGlobal: '전역에 고정됨',
|
||||
pinnedRailLabel: '고정된 터미널',
|
||||
closePinnedTerminal: '터미널 닫기',
|
||||
collapse: '사이드바 접기',
|
||||
expand: '사이드바 펼치기',
|
||||
collapseBottomPanel: '하단 패널 접기',
|
||||
expandBottomPanel: '하단 패널 펼치기',
|
||||
terminalError: '터미널 연결 실패',
|
||||
terminalConnectFailed: '터미널 연결이 여러 번 실패했습니다',
|
||||
terminalRetry: '다시 시도',
|
||||
terminalWaitBanner: '에이전트가 {needle} 대기 중',
|
||||
terminalSkipWait: '대기 건너뛰기',
|
||||
terminalDepsFailed: '터미널 의존성 node-pty 로드 실패',
|
||||
terminalDepsHint: 'DSH가 설치된 환경의 터미널 또는 cmd에서 아래 명령을 실행하여 복구한 후 다시 시도를 클릭하세요(node-pty는 DSH 코어와 동일한 버전을 유지합니다):',
|
||||
terminalDepsProfile: ' (profile 감지됨: {profile})',
|
||||
terminalShellNotFound: '설정된 셸을 찾을 수 없습니다: {name} — 설정 → 사이드 카드 → 터미널에서 셸 경로를 확인하세요',
|
||||
preview: '미리보기',
|
||||
toc: '목차',
|
||||
edit: '편집',
|
||||
@@ -136,12 +150,6 @@ export const ko: Record<string, string> = {
|
||||
changesOpenDiffTab: '별도 탭에서 열기',
|
||||
changesClosePreview: '미리보기 닫기',
|
||||
changesResizePreview: '미리보기 높이 조정',
|
||||
changesDiffOpenTitle: 'diff 열기 위치',
|
||||
changesDiffOpenDesc: '「별도 탭에서 열기」 작업이 diff를 배치하는 곳',
|
||||
changesDiffOpenFloat: '자유 창',
|
||||
changesDiffOpenFloatDesc: '뷰포트 중앙의 떠 있는 창 — 드래그, 크기 조절, 항상 위',
|
||||
changesDiffOpenPane: '패널',
|
||||
changesDiffOpenPaneDesc: '소스 패널 아래에 도킹(VSCode 스타일 diff 분할)',
|
||||
changesLoadError: '세션 파일 기록을 지금 사용할 수 없습니다',
|
||||
staged: '스테이징됨',
|
||||
unstaged: '스테이징 안 됨',
|
||||
@@ -190,6 +198,13 @@ export const ko: Record<string, string> = {
|
||||
addToConversation: '대화에 추가',
|
||||
copyRelative: '상대 경로 복사',
|
||||
copyAbsolute: '절대 경로 복사',
|
||||
rename: '이름 바꾸기',
|
||||
renameInvalid: '이름은 비워 둘 수 없으며 경로 구분 기호를 포함할 수 없습니다.',
|
||||
delete: '삭제',
|
||||
deleteTitle: '"{name}"을(를) 삭제할까요?',
|
||||
deleteDescFile: '이 파일이 영구적으로 삭제됩니다. 되돌릴 수 없습니다.',
|
||||
deleteDescDir: '이 디렉터리와 모든 내용이 영구적으로 삭제됩니다. 되돌릴 수 없습니다.',
|
||||
dismiss: '닫기',
|
||||
download: '다운로드',
|
||||
uploadFiles: '파일 업로드',
|
||||
uploadFolder: '폴더 업로드',
|
||||
@@ -208,13 +223,6 @@ export const ko: Record<string, string> = {
|
||||
settingsIntro: '사이드 카드의 표시 내용과 기본 동작을 관리합니다',
|
||||
settingsPopupDesc: '"{feature}" 관련 옵션을 구성합니다',
|
||||
settingsDone: '완료',
|
||||
settingsOpenTitle: '새 대화 기본 열림',
|
||||
settingsOpenDesc: '새 대화를 만들 때 사이드 카드를 자동으로 펼칩니다. 기존 대화는 각자의 레이아웃을 유지합니다',
|
||||
settingsWidthTitle: '기본 너비 비율',
|
||||
settingsWidthDesc: '새 대화에서 사이드 카드가 창 너비에서 차지하는 비율(%) (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: '채팅 영역 파일을 사이드바에서 열기',
|
||||
settingsOpenPathDesc: '채팅에서 파일 링크(도구 행, 산출물 목록, 파일 언급)를 클릭하면 사이드바 편집기에서 열고, 시스템 기본 앱은 호출하지 않습니다',
|
||||
settingsOpenToolsTitle: '모델에 사이드바 열기 도구 주입',
|
||||
settingsOpenToolsDesc: '켜면 모델이 sidebar_open 도구로 사이드바에서 파일·폴더·HTTP(S) 페이지를 열 수 있습니다(기본 꺼짐)',
|
||||
settingsTitleBarTitle: '위치 호환 모드',
|
||||
@@ -413,4 +421,29 @@ export const ko: Record<string, string> = {
|
||||
pluginDocsPanelDesc: 'DSH 사이드바의 "전역 문서": 전역 Markdown 메모로, 어떤 작업 공간에서든 언제든 읽을 수 있습니다. 목록을 클릭해 읽기, 마우스를 올리면 개요로 이동, Chrome / VS Code에서 외부 열기, 코드 복사. 디렉터리를 설정할 수 있습니다(기본 ~/.dsh/docs)',
|
||||
pluginEgoBrowserDesc: 'DeepSeek Harness용 에이전트 브라우저: 32개의 ego_* 도구가 실제 Chromium을 구동하며, 사이드바의 네이티브 «ego 브라우저» 탭에서 에이전트가 방문하는 모든 페이지를 실시간으로 볼 수 있습니다 — 클릭, 드래그, 타이핑으로 직접 조작할 수 있습니다. better-sidebar가 있으면 탭을 자동 등록하고, 없으면 플로팅 버블로 대체합니다',
|
||||
pluginBilingualReaderDesc: 'DSH 사이드바에서 PDF 읽기: 네이티브 PDF 표시, 텍스트를 선택해 LLM으로 번역, 컨텍스트 사용 및 메인 대화와 완전 격리',
|
||||
changesRedactOn: "마스킹 켜짐: 민감 값이 [REDACTED]로 표시됩니다. 클릭하면 꺼집니다(책임 본인)",
|
||||
changesRedactOff: "마스킹 꺼짐: 내용이 그대로 표시됩니다. 클릭하면 켜집니다",
|
||||
changesRedactOnLabel: "마스킹 중",
|
||||
changesRedactOffLabel: "마스킹 해제",
|
||||
changesRedactBanner: "마스킹됨",
|
||||
pluginSentinelName: 'dsh-sentinel 웨이크업 시스템',
|
||||
pluginEgoBrowserName: 'ego-browser 에이전트 브라우저',
|
||||
pluginBetterOverleafName: 'dsh-better-overleaf Overleaf 탭',
|
||||
pluginDocsPanelName: 'dsh-docs-panel 전역 문서',
|
||||
pluginFlowglassName: 'dsh-flowglass Flowglass',
|
||||
pluginGitForgeName: 'dsh-git-forge Git 자격 증명',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Git 리모트',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench GitHub 워크벤치',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa 선택 후 질문',
|
||||
pluginSidenoteName: 'dsh-sidenote 사이드 채팅',
|
||||
pluginServerDeckName: 'dsh-server-deck 서버 데크',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel SSH 터널',
|
||||
pluginTurnReviewName: 'dsh-turn-review 턴 리뷰',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader 이중언어 리더',
|
||||
pluginOfficeName: 'Office 미리보기',
|
||||
pluginMdExportName: 'Markdown 내보내기',
|
||||
pluginCodeNavName: '코드 미리보기 내비게이터',
|
||||
pluginVideoPreviewName: '동영상 미리보기',
|
||||
presetDshDesktopDesc: 'Electron 고급 모드(테두리 없음): macOS는 상단에 20px, Windows는 WCO가 없을 때 타이틀 바에 32px를 확보',
|
||||
}
|
||||
|
||||
+49
-16
@@ -15,13 +15,27 @@
|
||||
*/
|
||||
export const nl: Record<string, string> = {
|
||||
files: 'Bestanden',
|
||||
guideDescFiles: 'Blader door de bestandsboom van de workspace; klik opent voorbeeld of editor',
|
||||
guideDescGit: 'Git-wijzigingen en bestandswijzigingen van deze sessie in één tabblad',
|
||||
guideDescSubagent: 'Subagent-topologie en achtergrondtaken, live',
|
||||
guideDescSidechat: 'Een onafhankelijke vervolgthread met de context van deze sessie',
|
||||
guideDescTerminal: 'Een echte shell (xterm + pty) met herverbinding',
|
||||
guideDescBrowser: 'Ingebouwde browser die chatlinks kan overnemen',
|
||||
changesSessionEmpty: 'Nog geen bestandsbewerkingen in deze sessie',
|
||||
changesRead: 'Lezen',
|
||||
changesMdReading: 'Lezen',
|
||||
changesHtmlRender: "Renderen",
|
||||
changesHtmlRaw: "Bron",
|
||||
changesPdfRender: "Renderen",
|
||||
changesPdfRaw: "Bron",
|
||||
changesMdRaw: 'Brontekst',
|
||||
changesWrite: 'Schrijven',
|
||||
changesEdit: 'Bewerken',
|
||||
changesRunning: 'actief',
|
||||
changesError: 'fout',
|
||||
changesFold: '{count} regels… klik om uit te klappen',
|
||||
changesFoldLoading: 'Uitklappen…',
|
||||
changesFoldUnavailable: 'Context niet beschikbaar',
|
||||
changesContext: 'context',
|
||||
changesPriorUnknown: 'Eerdere inhoud valt buiten het venster; getoond als volledig nieuw',
|
||||
explorer: 'Verkenner',
|
||||
@@ -71,7 +85,6 @@ export const nl: Record<string, string> = {
|
||||
closeOtherTabs: 'Andere tabbladen sluiten',
|
||||
closeLeftTabs: 'Tabbladen links sluiten',
|
||||
closeRightTabs: 'Tabbladen rechts sluiten',
|
||||
moveToFreeWindow: 'Naar vrij venster verplaatsen',
|
||||
floatDropHint: 'Loslaten om in een vrij venster te openen',
|
||||
dockToSidebar: 'Terug naar de zijbalk',
|
||||
pinTerminal: 'Terminal vastmaken',
|
||||
@@ -86,16 +99,17 @@ export const nl: Record<string, string> = {
|
||||
pinnedTerminalScopeGlobal: 'Wereldwijd vastgemaakt',
|
||||
pinnedRailLabel: 'Vastgemaakte terminals',
|
||||
closePinnedTerminal: 'Terminal sluiten',
|
||||
collapse: 'Zijbalk inklappen',
|
||||
expand: 'Zijbalk uitklappen',
|
||||
collapseBottomPanel: 'Onderpaneel inklappen',
|
||||
expandBottomPanel: 'Onderpaneel uitklappen',
|
||||
terminalError: 'Terminalverbinding mislukt',
|
||||
terminalConnectFailed: 'Terminalverbinding herhaaldelijk mislukt',
|
||||
terminalRetry: 'Opnieuw proberen',
|
||||
terminalWaitBanner: 'Agent wacht op {needle}',
|
||||
terminalSkipWait: 'Wachten overslaan',
|
||||
terminalDepsFailed: 'Terminalafhankelijkheid node-pty kon niet worden geladen',
|
||||
terminalDepsHint: 'Voer het onderstaande commando uit in een terminal of cmd op de DSH-machine om het te herstellen en probeer het opnieuw (node-pty blijft in sync met de DSH-coreversie):',
|
||||
terminalDepsProfile: ' (gedetecteerde profile: {profile})',
|
||||
terminalShellNotFound: 'Geconfigureerde shell niet gevonden: {name} — controleer het shell-pad onder Instellingen → Side card → Terminal',
|
||||
preview: 'Voorbeeld',
|
||||
toc: 'Inhoudsopgave',
|
||||
edit: 'Bewerken',
|
||||
@@ -142,12 +156,6 @@ export const nl: Record<string, string> = {
|
||||
changesOpenDiffTab: 'In eigen diff-tabblad openen',
|
||||
changesClosePreview: 'Voorbeeld sluiten',
|
||||
changesResizePreview: 'Voorbeeld vergroten/verkleinen',
|
||||
changesDiffOpenTitle: 'Diff opent als',
|
||||
changesDiffOpenDesc: 'Waar de actie „in eigen diff-tabblad openen” de diff plaatst',
|
||||
changesDiffOpenFloat: 'Vrij venster',
|
||||
changesDiffOpenFloatDesc: 'Een zwevend venster in het midden — slepen, schalen, bovenop houden',
|
||||
changesDiffOpenPane: 'Paneel',
|
||||
changesDiffOpenPaneDesc: 'Vastgemaakt onder het bronpaneel (VSCode-achtige diff-split)',
|
||||
changesLoadError: 'Sessiebestandsregistraties zijn nu niet beschikbaar',
|
||||
staged: 'Gestaged',
|
||||
unstaged: 'Niet gestaged',
|
||||
@@ -196,6 +204,13 @@ export const nl: Record<string, string> = {
|
||||
addToConversation: 'Aan conversatie toevoegen',
|
||||
copyRelative: 'Relatief pad kopiëren',
|
||||
copyAbsolute: 'Absoluut pad kopiëren',
|
||||
rename: 'Naam wijzigen',
|
||||
renameInvalid: 'De naam mag niet leeg zijn of padscheidingstekens bevatten.',
|
||||
delete: 'Verwijderen',
|
||||
deleteTitle: '"{name}" verwijderen?',
|
||||
deleteDescFile: 'Dit verwijdert het bestand permanent. Dit kan niet ongedaan worden gemaakt.',
|
||||
deleteDescDir: 'Dit verwijdert de map en alles erin permanent. Dit kan niet ongedaan worden gemaakt.',
|
||||
dismiss: 'Sluiten',
|
||||
download: 'Downloaden',
|
||||
uploadFiles: 'Bestanden uploaden',
|
||||
uploadFolder: 'Map uploaden',
|
||||
@@ -214,13 +229,6 @@ export const nl: Record<string, string> = {
|
||||
settingsIntro: 'Beheer wat de zijkaart toont en hoe deze zich gedraagt',
|
||||
settingsPopupDesc: 'Gerelateerde opties voor {feature} configureren',
|
||||
settingsDone: 'Klaar',
|
||||
settingsOpenTitle: 'Standaard openen voor nieuwe conversaties',
|
||||
settingsOpenDesc: 'De zijkaart automatisch uitklappen voor gloednieuwe conversaties; bestaande conversaties behouden hun eigen layout',
|
||||
settingsWidthTitle: 'Standaard breedte-aandeel',
|
||||
settingsWidthDesc: 'Het standaard breedte-aandeel van de zijkaart voor nieuwe conversaties (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'Chatbestanden in de zijbalk openen',
|
||||
settingsOpenPathDesc: 'Bestandslinks in de chat (toolrijen, geproduceerde bestanden, vermeldingen) openen in de zijbalk-editor in plaats van de standaard systeemapp',
|
||||
settingsOpenToolsTitle: 'Zijbalk-openen-tool voor het model injecteren',
|
||||
settingsOpenToolsDesc: 'Indien ingeschakeld kan het model bestanden, mappen en HTTP(S)-pagina\'s in de zijbalk openen via de sidebar_open-tool (standaard uit)',
|
||||
settingsTitleBarTitle: 'Positiecompatibiliteitsmodus',
|
||||
@@ -419,4 +427,29 @@ export const nl: Record<string, string> = {
|
||||
pluginDocsPanelDesc: 'Globale docs in de DSH-zijbalk: lees uw eigen Markdown-notities vanuit elke werkruimte — een bestandslijst, een outline, openen in Chrome / VS Code, en kopieerknoppen; de docs-map is configureerbaar (standaard ~/.dsh/docs)',
|
||||
pluginEgoBrowserDesc: 'De agentbrowser voor DeepSeek Harness: 32 ego_*-tools besturen een echt Chromium; een native «ego-browser»-tab in de zijbalk toont live elke pagina die de agent bezoekt — u kunt klikken, slepen en typen om over te nemen. Registreert de tab automatisch als better-sidebar aanwezig is, anders een zwevende bel',
|
||||
pluginBilingualReaderDesc: 'Lees PDF’s in de DSH-zijbalk: native PDF-weergave, selecteer tekst om te vertalen met de LLM, met context en volledig geïsoleerd van het hoofdgesprek',
|
||||
changesRedactOn: "Redactie aan: geheimen worden als [REDACTED] getoond; klik om uit te schakelen (op eigen risico)",
|
||||
changesRedactOff: "Redactie uit: inhoud wordt ongewijzigd getoond; klik om in te schakelen",
|
||||
changesRedactOnLabel: "Redactie",
|
||||
changesRedactOffLabel: "Zonder redactie",
|
||||
changesRedactBanner: "Geredigeerd",
|
||||
pluginSentinelName: 'dsh-sentinel Weksysteem',
|
||||
pluginEgoBrowserName: 'ego-browser Agentbrowser',
|
||||
pluginBetterOverleafName: 'dsh-better-overleaf Overleaf-tab',
|
||||
pluginDocsPanelName: 'dsh-docs-panel Globale documenten',
|
||||
pluginFlowglassName: 'dsh-flowglass Flowglass',
|
||||
pluginGitForgeName: 'dsh-git-forge Git-inloggegevens',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Git-remotes',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench GitHub-workbench',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa Selecteren & vragen',
|
||||
pluginSidenoteName: 'dsh-sidenote Zijchat',
|
||||
pluginServerDeckName: 'dsh-server-deck Serverdeck',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel SSH-tunnel',
|
||||
pluginTurnReviewName: 'dsh-turn-review Beurtreview',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader Tweetalige lezer',
|
||||
pluginOfficeName: 'Office-voorvertoning',
|
||||
pluginMdExportName: 'Markdown-export',
|
||||
pluginCodeNavName: 'Codevoorvertoning-navigator',
|
||||
pluginVideoPreviewName: 'Videovoorvertoning',
|
||||
presetDshDesktopDesc: 'Geavanceerde Electronmodus (zonder rand): macOS reserveert 20px bovenaan; Windows reserveert 32px voor de titelbalk wanneer WCO niet beschikbaar is',
|
||||
}
|
||||
|
||||
+49
-16
@@ -19,13 +19,27 @@
|
||||
/** The pl dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
|
||||
export const pl: Record<string, string> = {
|
||||
files: 'Pliki',
|
||||
guideDescFiles: 'Przeglądaj drzewo plików obszaru roboczego; klik otwiera podgląd lub edycję',
|
||||
guideDescGit: 'Zmiany Git i zmiany plików z tej sesji w jednej karcie',
|
||||
guideDescSubagent: 'Topologia subagentów i zadania w tle na żywo',
|
||||
guideDescSidechat: 'Niezależny wątek uzupełniający dziedziczący kontekst tej sesji',
|
||||
guideDescTerminal: 'Prawdziwy shell (xterm + pty) z ponownym łączeniem',
|
||||
guideDescBrowser: 'Wbudowana przeglądarka, może przejmować linki z czatu',
|
||||
changesSessionEmpty: 'Brak operacji na plikach w tej sesji',
|
||||
changesRead: 'Odczyt',
|
||||
changesMdReading: 'Czytanie',
|
||||
changesHtmlRender: "Renderuj",
|
||||
changesHtmlRaw: "Źródło",
|
||||
changesPdfRender: "Renderuj",
|
||||
changesPdfRaw: "Źródło",
|
||||
changesMdRaw: 'Źródło',
|
||||
changesWrite: 'Zapis',
|
||||
changesEdit: 'Edycja',
|
||||
changesRunning: 'w toku',
|
||||
changesError: 'błąd',
|
||||
changesFold: '{count} wierszy… kliknij, aby rozwinąć',
|
||||
changesFoldLoading: 'Rozwijanie…',
|
||||
changesFoldUnavailable: 'Kontekst niedostępny',
|
||||
changesContext: 'kontekst',
|
||||
changesPriorUnknown: 'Zawartość sprzed zmiany jest poza oknem; pokazana jako w całości nowa',
|
||||
explorer: 'Eksplorator',
|
||||
@@ -75,7 +89,6 @@ export const pl: Record<string, string> = {
|
||||
closeOtherTabs: 'Zamknij inne karty',
|
||||
closeLeftTabs: 'Zamknij karty po lewej',
|
||||
closeRightTabs: 'Zamknij karty po prawej',
|
||||
moveToFreeWindow: 'Przenieś do wolnego okna',
|
||||
floatDropHint: 'Puść, aby otworzyć w wolnym oknie',
|
||||
dockToSidebar: 'Wróć do panelu bocznego',
|
||||
pinTerminal: 'Przypnij Terminal',
|
||||
@@ -90,16 +103,17 @@ export const pl: Record<string, string> = {
|
||||
pinnedTerminalScopeGlobal: 'Przypięty globalnie',
|
||||
pinnedRailLabel: 'Przypięte terminale',
|
||||
closePinnedTerminal: 'Zamknij terminal',
|
||||
collapse: 'Zwiń panel boczny',
|
||||
expand: 'Rozwiń panel boczny',
|
||||
collapseBottomPanel: 'Zwiń panel dolny',
|
||||
expandBottomPanel: 'Rozwiń panel dolny',
|
||||
terminalError: 'Połączenie z terminalem nie powiodło się',
|
||||
terminalConnectFailed: 'Terminal nie mógł połączyć się wielokrotnie',
|
||||
terminalRetry: 'Ponów',
|
||||
terminalWaitBanner: 'Agent czeka na {needle}',
|
||||
terminalSkipWait: 'Pomiń oczekiwanie',
|
||||
terminalDepsFailed: 'Zależność terminala node-pty nie mogła się załadować',
|
||||
terminalDepsHint: 'Uruchom poniższe polecenie w terminalu lub cmd na maszynie DSH, aby to naprawić, a następnie ponów (node-pty pozostaje w synchronizacji z wersją rdzenia DSH):',
|
||||
terminalDepsProfile: ' (wykryty profil: {profile})',
|
||||
terminalShellNotFound: 'Nie znaleziono skonfigurowanej powłoki: {name} — sprawdź ścieżkę powłoki w Ustawienia → Side card → Terminal',
|
||||
preview: 'Podgląd',
|
||||
toc: 'Spis treści',
|
||||
edit: 'Edytuj',
|
||||
@@ -146,12 +160,6 @@ export const pl: Record<string, string> = {
|
||||
changesOpenDiffTab: 'Otwórz w osobnej karcie',
|
||||
changesClosePreview: 'Zamknij podgląd',
|
||||
changesResizePreview: 'Zmień wysokość podglądu',
|
||||
changesDiffOpenTitle: 'Diff otwiera się jako',
|
||||
changesDiffOpenDesc: 'Gdzie akcja „otwórz w osobnej karcie” umieszcza diff',
|
||||
changesDiffOpenFloat: 'Odrębne okno',
|
||||
changesDiffOpenFloatDesc: 'Pływające okno wyśrodkowane na ekranie — przeciągaj, skaluj, trzymaj na wierzchu',
|
||||
changesDiffOpenPane: 'Panel',
|
||||
changesDiffOpenPaneDesc: 'Zadokowane pod panelem źródłowym (podział diff w stylu VSCode)',
|
||||
changesLoadError: 'Zapisy plików sesji są teraz niedostępne',
|
||||
staged: 'W indeksie',
|
||||
unstaged: 'Poza indeksem',
|
||||
@@ -200,6 +208,13 @@ export const pl: Record<string, string> = {
|
||||
addToConversation: 'Dodaj do rozmowy',
|
||||
copyRelative: 'Kopiuj ścieżkę względną',
|
||||
copyAbsolute: 'Kopiuj ścieżkę bezwzględną',
|
||||
rename: 'Zmień nazwę',
|
||||
renameInvalid: 'Nazwa nie może być pusta ani zawierać separatorów ścieżki.',
|
||||
delete: 'Usuń',
|
||||
deleteTitle: 'Usunąć „{name}”?',
|
||||
deleteDescFile: 'Spowoduje to trwałe usunięcie pliku. Tej operacji nie można cofnąć.',
|
||||
deleteDescDir: 'Spowoduje to trwałe usunięcie katalogu i całej jego zawartości. Tej operacji nie można cofnąć.',
|
||||
dismiss: 'Zamknij',
|
||||
download: 'Pobierz',
|
||||
uploadFiles: 'Wgraj pliki',
|
||||
uploadFolder: 'Wgraj folder',
|
||||
@@ -218,13 +233,6 @@ export const pl: Record<string, string> = {
|
||||
settingsIntro: 'Zarządzaj tym, co pokazuje karta boczna i jak się zachowuje',
|
||||
settingsPopupDesc: 'Skonfiguruj opcje powiązane z: {feature}',
|
||||
settingsDone: 'Gotowe',
|
||||
settingsOpenTitle: 'Otwórz domyślnie dla nowych rozmów',
|
||||
settingsOpenDesc: 'Rozwiń kartę boczną automatycznie dla zupełnie nowych rozmów; istniejące rozmowy zachowują własne układy',
|
||||
settingsWidthTitle: 'Domyślny udział szerokości',
|
||||
settingsWidthDesc: 'Domyślny udział szerokości okna dla karty bocznej w nowych rozmowach (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'Otwieraj pliki czatu w panelu bocznym',
|
||||
settingsOpenPathDesc: 'Otwieraj linki plików na czacie (wiersze narzędzi, wyprodukowane pliki, wzmianki) w edytorze panelu bocznego zamiast w domyślnej aplikacji systemu',
|
||||
settingsOpenToolsTitle: 'Wstrzyknij narzędzie otwierania panelu bocznego dla modelu',
|
||||
settingsOpenToolsDesc: 'Po włączeniu model może otwierać pliki, foldery i strony HTTP(S) w panelu bocznym za pomocą narzędzia sidebar_open (domyślnie wyłączone)',
|
||||
settingsTitleBarTitle: 'Tryb zgodności pozycji',
|
||||
@@ -423,4 +431,29 @@ export const pl: Record<string, string> = {
|
||||
pluginDocsPanelDesc: 'Globalne dokumenty w panelu bocznym DSH: globalne notatki Markdown, czytelne z dowolnego obszaru roboczego — lista z kliknięciem, kontur z przejściami, otwieranie w Chrome / VS Code i przyciski kopiowania kodu; katalog konfigurowalny (domyślnie ~/.dsh/docs)',
|
||||
pluginEgoBrowserDesc: 'Przeglądarka agenta dla DeepSeek Harness: 32 narzędzia ego_* sterują prawdziwym Chromium; natywna karta «ego browser» na pasku bocznym pokazuje na żywo każdą stronę odwiedzaną przez agenta — możesz klikać, przeciągać i pisać, aby przejąć kontrolę. Karta rejestruje się automatycznie, gdy better-sidebar jest zainstalowany; w przeciwnym razie pływający dymek',
|
||||
pluginBilingualReaderDesc: 'Czytaj PDF-y w panelu bocznym DSH: natywny podgląd PDF, zaznacz tekst, aby przetłumaczyć go LLM-em, z kontekstem i całkowicie izolowany od głównej rozmowy',
|
||||
changesRedactOn: "Redakcja włączona: tajne dane pokazywane jako [REDACTED]; kliknij, aby wyłączyć (na własne ryzyko)",
|
||||
changesRedactOff: "Redakcja wyłączona: treść pokazywana bez zmian; kliknij, aby włączyć",
|
||||
changesRedactOnLabel: "Redakcja",
|
||||
changesRedactOffLabel: "Bez redakcji",
|
||||
changesRedactBanner: "Zredagowano",
|
||||
pluginSentinelName: 'dsh-sentinel System wybudzania',
|
||||
pluginEgoBrowserName: 'ego-browser Przeglądarka agenta',
|
||||
pluginBetterOverleafName: 'dsh-better-overleaf Karta Overleaf',
|
||||
pluginDocsPanelName: 'dsh-docs-panel Dokumenty globalne',
|
||||
pluginFlowglassName: 'dsh-flowglass Flowglass',
|
||||
pluginGitForgeName: 'dsh-git-forge Dane uwierzytelniające Git',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Zdalne repozytoria Git',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench Warsztat GitHub',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa Zaznacz i zapytaj',
|
||||
pluginSidenoteName: 'dsh-sidenote Czat boczny',
|
||||
pluginServerDeckName: 'dsh-server-deck Panel serwerów',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel Tunel SSH',
|
||||
pluginTurnReviewName: 'dsh-turn-review Rewizja tury',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader Czytnik dwujęzyczny',
|
||||
pluginOfficeName: 'Podgląd Office',
|
||||
pluginMdExportName: 'Eksport Markdown',
|
||||
pluginCodeNavName: 'Nawigator podglądu kodu',
|
||||
pluginVideoPreviewName: 'Podgląd wideo',
|
||||
presetDshDesktopDesc: 'Tryb zaawansowany Electron (bez ramki): macOS rezerwuje 20px u góry; Windows bez WCO rezerwuje 32px na pasek tytułu',
|
||||
}
|
||||
|
||||
+49
-16
@@ -1,12 +1,26 @@
|
||||
export const pt: Record<string, string> = {
|
||||
files: 'Arquivos',
|
||||
guideDescFiles: 'Navegue pela árvore do workspace; um clique abre a pré-visualização ou a edição',
|
||||
guideDescGit: 'Alterações do Git e edições desta sessão em um único separador',
|
||||
guideDescSubagent: 'Topologia de subagentes e tarefas em segundo plano, ao vivo',
|
||||
guideDescSidechat: 'Uma conversa paralela independente que herda o contexto desta sessão',
|
||||
guideDescTerminal: 'Um shell real (xterm + pty) com reconexão',
|
||||
guideDescBrowser: 'Navegador incorporado, que pode abrir links do chat',
|
||||
changesSessionEmpty: 'Ainda sem operações de arquivo nesta sessão',
|
||||
changesRead: 'Leitura',
|
||||
changesMdReading: 'Leitura',
|
||||
changesHtmlRender: "Renderizar",
|
||||
changesHtmlRaw: "Fonte",
|
||||
changesPdfRender: "Renderizar",
|
||||
changesPdfRaw: "Fonte",
|
||||
changesMdRaw: 'Bruto',
|
||||
changesWrite: 'Escrita',
|
||||
changesEdit: 'Edição',
|
||||
changesRunning: 'em execução',
|
||||
changesError: 'erro',
|
||||
changesFold: '{count} linhas… clique para expandir',
|
||||
changesFoldLoading: 'Expandindo…',
|
||||
changesFoldUnavailable: 'Contexto indisponível',
|
||||
changesContext: 'contexto',
|
||||
changesPriorUnknown: 'O conteúdo anterior está fora da janela; mostrado como totalmente adicionado',
|
||||
explorer: 'Explorador',
|
||||
@@ -56,7 +70,6 @@ export const pt: Record<string, string> = {
|
||||
closeOtherTabs: 'Fechar outras abas',
|
||||
closeLeftTabs: 'Fechar abas à esquerda',
|
||||
closeRightTabs: 'Fechar abas à direita',
|
||||
moveToFreeWindow: 'Mover para janela flutuante',
|
||||
floatDropHint: 'Solte para abrir em uma janela flutuante',
|
||||
dockToSidebar: 'Voltar para a barra lateral',
|
||||
pinTerminal: 'Fixar Terminal',
|
||||
@@ -71,16 +84,17 @@ export const pt: Record<string, string> = {
|
||||
pinnedTerminalScopeGlobal: 'Fixado globalmente',
|
||||
pinnedRailLabel: 'Terminais Fixados',
|
||||
closePinnedTerminal: 'Fechar Terminal',
|
||||
collapse: 'Recolher barra lateral',
|
||||
expand: 'Expandir barra lateral',
|
||||
collapseBottomPanel: 'Recolher painel inferior',
|
||||
expandBottomPanel: 'Expandir painel inferior',
|
||||
terminalError: 'Falha na conexão do terminal',
|
||||
terminalConnectFailed: 'O terminal falhou ao conectar repetidamente',
|
||||
terminalRetry: 'Tentar novamente',
|
||||
terminalWaitBanner: 'O agente está aguardando {needle}',
|
||||
terminalSkipWait: 'Pular espera',
|
||||
terminalDepsFailed: 'A dependência do terminal node-pty falhou ao carregar',
|
||||
terminalDepsHint: 'Execute o comando abaixo em um terminal ou cmd na máquina do DSH para reparar e tente novamente (node-pty permanece em sincronia com a versão do núcleo do DSH):',
|
||||
terminalDepsProfile: ' (perfil detectado: {profile})',
|
||||
terminalShellNotFound: 'Shell configurado não encontrado: {name} — verifique o caminho do shell em Configurações → Side card → Terminal',
|
||||
preview: 'Pré-visualizar',
|
||||
toc: 'Sumário',
|
||||
edit: 'Editar',
|
||||
@@ -127,12 +141,6 @@ export const pt: Record<string, string> = {
|
||||
changesOpenDiffTab: 'Abrir em uma aba de diff',
|
||||
changesClosePreview: 'Fechar prévia',
|
||||
changesResizePreview: 'Ajustar altura da prévia',
|
||||
changesDiffOpenTitle: 'O diff abre como',
|
||||
changesDiffOpenDesc: 'Onde a ação "abrir em uma aba de diff" coloca o diff',
|
||||
changesDiffOpenFloat: 'Janela livre',
|
||||
changesDiffOpenFloatDesc: 'Uma janela flutuante centralizada — arraste, redimensione, mantenha no topo',
|
||||
changesDiffOpenPane: 'Painel',
|
||||
changesDiffOpenPaneDesc: 'Ancorado abaixo do painel de origem (divisão de diff estilo VSCode)',
|
||||
changesLoadError: 'Os registros de arquivos da sessão estão indisponíveis no momento',
|
||||
staged: 'Preparadas',
|
||||
unstaged: 'Não preparadas',
|
||||
@@ -181,6 +189,13 @@ export const pt: Record<string, string> = {
|
||||
addToConversation: 'Adicionar à conversa',
|
||||
copyRelative: 'Copiar caminho relativo',
|
||||
copyAbsolute: 'Copiar caminho absoluto',
|
||||
rename: 'Renomear',
|
||||
renameInvalid: 'O nome não pode ser vazio nem conter separadores de caminho.',
|
||||
delete: 'Excluir',
|
||||
deleteTitle: 'Excluir "{name}"?',
|
||||
deleteDescFile: 'Isso exclui o arquivo permanentemente. Esta ação não pode ser desfeita.',
|
||||
deleteDescDir: 'Isso exclui o diretório e todo o seu conteúdo permanentemente. Esta ação não pode ser desfeita.',
|
||||
dismiss: 'Fechar',
|
||||
download: 'Baixar',
|
||||
uploadFiles: 'Enviar arquivos',
|
||||
uploadFolder: 'Enviar pasta',
|
||||
@@ -199,13 +214,6 @@ export const pt: Record<string, string> = {
|
||||
settingsIntro: 'Gerencie o que o cartão lateral mostra e como ele se comporta',
|
||||
settingsPopupDesc: 'Configurar opções relacionadas para {feature}',
|
||||
settingsDone: 'Concluído',
|
||||
settingsOpenTitle: 'Abrir por padrão em novas conversas',
|
||||
settingsOpenDesc: 'Expandir o cartão lateral automaticamente em conversas totalmente novas; conversas existentes mantêm seus próprios layouts',
|
||||
settingsWidthTitle: 'Participação padrão na largura',
|
||||
settingsWidthDesc: 'Participação padrão do cartão lateral na largura da janela em novas conversas (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'Abrir arquivos do chat na barra lateral',
|
||||
settingsOpenPathDesc: 'Abrir links de arquivos no chat (linhas de ferramentas, arquivos produzidos, menções) no editor da barra lateral em vez do aplicativo padrão do sistema',
|
||||
settingsOpenToolsTitle: 'Injetar a ferramenta de abertura na barra lateral para o modelo',
|
||||
settingsOpenToolsDesc: 'Quando ativado, o modelo pode abrir arquivos, pastas e páginas HTTP(S) na barra lateral pela ferramenta sidebar_open (desativado por padrão)',
|
||||
settingsTitleBarTitle: 'Modo de compatibilidade de posição',
|
||||
@@ -404,4 +412,29 @@ export const pt: Record<string, string> = {
|
||||
pluginDocsPanelDesc: 'Documentos globais na barra lateral do DSH: leia suas próprias notas Markdown de qualquer workspace — uma lista de arquivos, um sumário, abrir no Chrome / VS Code e botões de copiar; o diretório de documentos é configurável (padrão ~/.dsh/docs)',
|
||||
pluginEgoBrowserDesc: 'O navegador de agente para o DeepSeek Harness: 32 ferramentas ego_* controlam um Chromium real; uma aba nativa «ego browser» na barra lateral mostra ao vivo cada página visitada pelo agente — você pode clicar, arrastar e digitar para assumir. Registra a aba automaticamente quando o better-sidebar está presente; caso contrário, uma bolha flutuante',
|
||||
pluginBilingualReaderDesc: 'Leia PDFs na barra lateral do DSH: visualização nativa de PDF, selecione o texto para traduzir com o LLM, com contexto e totalmente isolado da conversa principal',
|
||||
changesRedactOn: "Máscara ativa: segredos exibidos como [REDACTED]; clique para desativar (por sua conta)",
|
||||
changesRedactOff: "Máscara desativada: conteúdo exibido como está; clique para ativar",
|
||||
changesRedactOnLabel: "Máscara",
|
||||
changesRedactOffLabel: "Sem máscara",
|
||||
changesRedactBanner: "Mascarado",
|
||||
pluginSentinelName: 'dsh-sentinel Sistema de despertar',
|
||||
pluginEgoBrowserName: 'ego-browser Navegador de agentes',
|
||||
pluginBetterOverleafName: 'dsh-better-overleaf Aba Overleaf',
|
||||
pluginDocsPanelName: 'dsh-docs-panel Documentos globais',
|
||||
pluginFlowglassName: 'dsh-flowglass Flowglass',
|
||||
pluginGitForgeName: 'dsh-git-forge Credenciais Git',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Remotos Git',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench Bancada GitHub',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa Selecionar e perguntar',
|
||||
pluginSidenoteName: 'dsh-sidenote Chat lateral',
|
||||
pluginServerDeckName: 'dsh-server-deck Deck de servidores',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel Túnel SSH',
|
||||
pluginTurnReviewName: 'dsh-turn-review Revisão do turno',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader Leitor bilíngue',
|
||||
pluginOfficeName: 'Pré-visualização Office',
|
||||
pluginMdExportName: 'Exportação Markdown',
|
||||
pluginCodeNavName: 'Navegador de pré-visualização de código',
|
||||
pluginVideoPreviewName: 'Pré-visualização de vídeo',
|
||||
presetDshDesktopDesc: 'Modo avançado Electron (sem moldura): o macOS reserva 20px no topo; o Windows reserva 32px para a barra de título sem WCO',
|
||||
}
|
||||
|
||||
+49
-16
@@ -13,13 +13,27 @@
|
||||
|
||||
export const ru: Record<string, string> = {
|
||||
files: 'Файлы',
|
||||
guideDescFiles: 'Дерево файлов рабочей области: клик открывает просмотр или редактор',
|
||||
guideDescGit: 'Изменения Git и правки файлов этой сессии в одной вкладке',
|
||||
guideDescSubagent: 'Топология субагентов и фоновые задачи в реальном времени',
|
||||
guideDescSidechat: 'Независимый дополнительный тред с контекстом этой сессии',
|
||||
guideDescTerminal: 'Настоящий shell (xterm + pty) с переподключением',
|
||||
guideDescBrowser: 'Встроенный браузер, перехватывает ссылки из чата',
|
||||
changesSessionEmpty: 'В этой сессии пока нет файловых операций',
|
||||
changesRead: 'Чтение',
|
||||
changesMdReading: 'Чтение',
|
||||
changesHtmlRender: "Рендер",
|
||||
changesHtmlRaw: "Исходник",
|
||||
changesPdfRender: "Рендер",
|
||||
changesPdfRaw: "Исходник",
|
||||
changesMdRaw: 'Исходный текст',
|
||||
changesWrite: 'Запись',
|
||||
changesEdit: 'Изменение',
|
||||
changesRunning: 'выполняется',
|
||||
changesError: 'ошибка',
|
||||
changesFold: '{count} строк… нажмите, чтобы развернуть',
|
||||
changesFoldLoading: 'Разворачивание…',
|
||||
changesFoldUnavailable: 'Контекст недоступен',
|
||||
changesContext: 'контекст',
|
||||
changesPriorUnknown: 'Содержимое до изменения вне окна; показано как полностью добавленное',
|
||||
explorer: 'Проводник',
|
||||
@@ -69,7 +83,6 @@ export const ru: Record<string, string> = {
|
||||
closeOtherTabs: 'Закрыть остальные вкладки',
|
||||
closeLeftTabs: 'Закрыть вкладки слева',
|
||||
closeRightTabs: 'Закрыть вкладки справа',
|
||||
moveToFreeWindow: 'Переместить в свободное окно',
|
||||
floatDropHint: 'Отпустите, чтобы открыть в свободном окне',
|
||||
dockToSidebar: 'Вернуть на боковую панель',
|
||||
pinTerminal: 'Закрепить терминал',
|
||||
@@ -84,16 +97,17 @@ export const ru: Record<string, string> = {
|
||||
pinnedTerminalScopeGlobal: 'Закреплён глобально',
|
||||
pinnedRailLabel: 'Закреплённые терминалы',
|
||||
closePinnedTerminal: 'Закрыть терминал',
|
||||
collapse: 'Свернуть боковую панель',
|
||||
expand: 'Развернуть боковую панель',
|
||||
collapseBottomPanel: 'Свернуть нижнюю панель',
|
||||
expandBottomPanel: 'Развернуть нижнюю панель',
|
||||
terminalError: 'Сбой подключения терминала',
|
||||
terminalConnectFailed: 'Терминал не смог подключиться после нескольких попыток',
|
||||
terminalRetry: 'Повторить',
|
||||
terminalWaitBanner: 'Агент ожидает {needle}',
|
||||
terminalSkipWait: 'Пропустить ожидание',
|
||||
terminalDepsFailed: 'Не удалось загрузить зависимость терминала node-pty',
|
||||
terminalDepsHint: 'Выполните приведённую ниже команду в терминале или cmd на машине DSH, затем нажмите «Повторить» (node-pty остаётся синхронизированным с версией ядра DSH):',
|
||||
terminalDepsProfile: ' (обнаружен profile: {profile})',
|
||||
terminalShellNotFound: 'Настроенная оболочка не найдена: {name} — проверьте путь к оболочке в Настройки → Side card → Terminal',
|
||||
preview: 'Предпросмотр',
|
||||
toc: 'Оглавление',
|
||||
edit: 'Изменить',
|
||||
@@ -140,12 +154,6 @@ export const ru: Record<string, string> = {
|
||||
changesOpenDiffTab: 'Открыть в отдельной вкладке',
|
||||
changesClosePreview: 'Закрыть просмотр',
|
||||
changesResizePreview: 'Изменить высоту просмотра',
|
||||
changesDiffOpenTitle: 'Diff открывается как',
|
||||
changesDiffOpenDesc: 'Куда действие «открыть в отдельной вкладке» помещает diff',
|
||||
changesDiffOpenFloat: 'Свободное окно',
|
||||
changesDiffOpenFloatDesc: 'Плавающее окно по центру экрана — перетаскивание, масштаб, поверх всех',
|
||||
changesDiffOpenPane: 'Панель',
|
||||
changesDiffOpenPaneDesc: 'Прикреплено под исходной панелью (разделение diff в стиле VSCode)',
|
||||
changesLoadError: 'Записи файлов сессии сейчас недоступны',
|
||||
staged: 'В индексе',
|
||||
unstaged: 'Не в индексе',
|
||||
@@ -194,6 +202,13 @@ export const ru: Record<string, string> = {
|
||||
addToConversation: 'Добавить в диалог',
|
||||
copyRelative: 'Копировать относительный путь',
|
||||
copyAbsolute: 'Копировать абсолютный путь',
|
||||
rename: 'Переименовать',
|
||||
renameInvalid: 'Имя не может быть пустым или содержать разделители пути.',
|
||||
delete: 'Удалить',
|
||||
deleteTitle: 'Удалить «{name}»?',
|
||||
deleteDescFile: 'Файл будет удалён безвозвратно. Это действие нельзя отменить.',
|
||||
deleteDescDir: 'Каталог и всё его содержимое будут удалены безвозвратно. Это действие нельзя отменить.',
|
||||
dismiss: 'Закрыть',
|
||||
download: 'Скачать',
|
||||
uploadFiles: 'Загрузить файлы',
|
||||
uploadFolder: 'Загрузить папку',
|
||||
@@ -213,13 +228,6 @@ export const ru: Record<string, string> = {
|
||||
settingsIntro: 'Управление содержимым и поведением боковой карточки по умолчанию',
|
||||
settingsPopupDesc: 'Настройка параметров «{feature}»',
|
||||
settingsDone: 'Готово',
|
||||
settingsOpenTitle: 'Открывать по умолчанию для новых сессий',
|
||||
settingsOpenDesc: 'Автоматически разворачивать боковую карточку для новых сессий; у существующих сессий сохраняется их собственная раскладка',
|
||||
settingsWidthTitle: 'Доля ширины по умолчанию',
|
||||
settingsWidthDesc: 'Доля боковой карточки в ширине окна для новых сессий, в процентах (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'Открывать файлы чата в боковой панели',
|
||||
settingsOpenPathDesc: 'При щелчке по ссылке на файл в чате (строки инструментов, списки результатов, упоминания) файл открывается в редакторе боковой панели, а не в системном приложении по умолчанию',
|
||||
settingsOpenToolsTitle: 'Внедрить инструмент открытия в боковой панели для модели',
|
||||
settingsOpenToolsDesc: 'Если включено, модель может открывать файлы, папки и HTTP(S)-страницы в боковой панели с помощью инструмента sidebar_open (по умолчанию выключено)',
|
||||
settingsTitleBarTitle: 'Режим совместимости заголовка',
|
||||
@@ -419,4 +427,29 @@ export const ru: Record<string, string> = {
|
||||
pluginDocsPanelDesc: '«Глобальные документы» в боковой панели DSH: глобальные Markdown-заметки, доступные из любой рабочей области — список для чтения, навигация по плавающему оглавлению, открытие в Chrome / VS Code, копирование кода; каталог настраивается (по умолчанию ~/.dsh/docs)',
|
||||
pluginEgoBrowserDesc: 'Браузер агента для DeepSeek Harness: 32 инструмента ego_* управляют настоящим Chromium; встроенная вкладка «ego browser» в боковой панели показывает в реальном времени каждую страницу, которую посещает агент — кликайте, перетаскивайте и вводите текст, чтобы перехватить управление. Вкладка регистрируется автоматически при наличии better-sidebar, иначе используется плавающий пузырёк',
|
||||
pluginBilingualReaderDesc: 'Читайте PDF в боковой панели DSH: нативный просмотр PDF, выделите текст для перевода с помощью LLM, используя контекст и полностью изолированно от основного разговора',
|
||||
changesRedactOn: "Маскировка включена: секреты показываются как [REDACTED]; нажмите, чтобы отключить (на свой риск)",
|
||||
changesRedactOff: "Маскировка выключена: содержимое показывается как есть; нажмите, чтобы включить",
|
||||
changesRedactOnLabel: "Маскировка",
|
||||
changesRedactOffLabel: "Без маскировки",
|
||||
changesRedactBanner: "Замаскировано",
|
||||
pluginSentinelName: 'dsh-sentinel Система пробуждения',
|
||||
pluginEgoBrowserName: 'ego-browser Браузер агента',
|
||||
pluginBetterOverleafName: 'dsh-better-overleaf Вкладка Overleaf',
|
||||
pluginDocsPanelName: 'dsh-docs-panel Глобальные документы',
|
||||
pluginFlowglassName: 'dsh-flowglass Flowglass',
|
||||
pluginGitForgeName: 'dsh-git-forge Учётные данные Git',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Удалённые репозитории Git',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench Рабочая среда GitHub',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa Выделить и спросить',
|
||||
pluginSidenoteName: 'dsh-sidenote Боковой чат',
|
||||
pluginServerDeckName: 'dsh-server-deck Панель серверов',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel SSH-туннель',
|
||||
pluginTurnReviewName: 'dsh-turn-review Ревью раунда',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader Двуязычный ридер',
|
||||
pluginOfficeName: 'Предпросмотр Office',
|
||||
pluginMdExportName: 'Экспорт Markdown',
|
||||
pluginCodeNavName: 'Навигатор предпросмотра кода',
|
||||
pluginVideoPreviewName: 'Предпросмотр видео',
|
||||
presetDshDesktopDesc: 'Расширенный режим Electron (без рамки): macOS резервирует 20px сверху; Windows без WCO резервирует 32px под строку заголовка',
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user