feat(sync): re-base the fork onto upstream dsh-better-sidebar v0.24.1 (fork 1.3.0)

Branch for hardware verification on a host that meets v0.24.1's peer range.

Base: upstream `dsh-better-sidebar` v0.24.1 (tag v0.24.1 = a2751cf), MIT.
Measured delta carried over from the v0.19.1 line: 78 rename-only files, 11 real
edits, 10 additions. Ported with the same measurement method as the v0.19.1
re-base: build a normalized base (upstream tree + the fork rename, excluding
README.md / package.json / docs/plans/* which deliberately keep the upstream
name), extract the delta against it, then three-way apply onto the renamed
v0.24.1 tree (`git apply -3`). 13 conflicts resolved by hand.

Identity:
- version 1.3.0, SIDEBAR_SERVICE_VERSION 1.3.0 (package.json ↔ service.ts pair
  is asserted by tests/service.spec.ts), forkedFrom -> v0.24.1/a2751cf.
- package.json keeps the union of both sides' metadata: upstream's new
  `zustand` + `icon`/`locale` files entries, plus the fork's `scripts/switch.sh`
  entry and its `forkedFrom` block.
- README.md takes upstream v0.24.1 wholesale (its own v0.24.1 changelog and all
  16 sections) and keeps the fork's two deltas: the fork-notice block at the top
  (now documenting the 0.2.0-rc.1+ requirement) and the fork package name.
- peerDependencies stay upstream's `^0.2.0-rc.1` — this line does NOT load on
  DSH 0.1.x: the host's semver pre-check rejects a caret range across a minor
  and silently disables the row (no error, no load).

Carried features, all re-verified against the new base:
- apes:* information-layer tabs (src/client/apes/) — the subtree's session feed
  changed shape in 0.1.7 and the port follows the host's own migration notes in
  context-types.ts: the per-session catalog is now
  `projectionsBySession[id].values.subagentCatalog` (a flat entry list) instead
  of 0.1.6's `subagentsByParent` catalog with `entries`/`kind`/`activity`, and
  the removed `list.current` is answered by `props.scope.sessionId`. Rows are
  normalized to { id, label, mode } with activity read from the durable
  summaries, so catalog and byId sources render alike.
- HTML preview delivery through srcdoc (sandbox kept intact).
- Panel host mounted inside the shell's framed content viewport.

Gate: typecheck clean; build clean; 1515 passed / 9 skipped (141 files).

NOT applied to ~/.dsh/profiles/desktop — that profile runs DSH 2.0.10 with
@deepseek-ai/dsh 0.1.5-rc.2, where this line is silently disabled by design.
This commit is contained in:
figmar
2026-10-04 00:58:28 +08:00
parent 23ca3daac3
commit e5bbef1907
21 changed files with 1659 additions and 590 deletions
+62 -37
View File
@@ -1,21 +1,46 @@
# dsh-better-sidebar
# dsh-apes-sidebar
> [!NOTE]
> **Fork notice** — 本项目是 [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) 的 fork,
> 基线 **upstream v0.24.1**(commit `a2751cf`,MIT)。
>
> - **本 fork 的增量**:`src/client/apes/` 下的 3 个信息层 tab(`apes:agents` 智能体 / `apes:apps` 应用 /
> `apes:profile` 画像),按 DSH 标准 `TabDescriptor` 接口注册,自动获得上游原生适配层的全部待遇
> (进原生 guide、tab 芯片图标、开合与分栏)。
> - **宿主版本要求(与上游 v0.24.1 一致)**:DSH **0.2.0-rc.1+**(peer `^0.2.0-rc.1`)。装在更旧的宿主上会被
> 启动兼容性预检判定失败、**整行静默禁用**(既不报错也不加载)。
> - **自 v0.19.1 起的上游变化**:终端 / 浏览器交给宿主内置(DSH 0.1.6 起自带右列终端与浏览器)、工作区路径围栏取消
> (文件读写按进程权限)、右侧栏面板走 DSH 原生右列。
> - 版本线独立于上游:`1.3.0`(fork)。以下文档主体沿用上游 v0.24.1 原文,包名已替换为本 fork 名。
> [!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。
> [!IMPORTANT]
> **v0.19.0 起接入 DSH 原生侧边栏**:右列就是 DSH 自己的右侧栏,插件把每个 tab 类型注册为原生 tab(不再自绘右侧面板),只保留自绘的底部工作台与开放给所有插件的 `ctx.betterSidebar` 服务。
>
> **v0.24.1 起要求 DSH `0.2.0-rc.1+`**(peer 下限 `^0.2.0-rc.1`)。0.2.0 对本插件所用的全部宿主 API 是**纯增量**(零导出删除、会话格式仍 v4、CLI 与客户端运行时未变),所以这一版没有运行时兼容分支,只把支持线整体前移。**DSH 0.1.7 线的用户请固定 `dsh-better-sidebar@0.22.1`——caret 范围跨 minor 不成立,`^0.1.7-rc.1` 在 0.2.0 宿主上会被启动预检静默禁用**;按 DSH 版本选插件版本的对照表见[安装](#-安装)。
> **v0.24.1 起要求 DSH `0.2.0-rc.1+`**(peer 下限 `^0.2.0-rc.1`)。0.2.0 对本插件所用的全部宿主 API 是**纯增量**(零导出删除、会话格式仍 v4、CLI 与客户端运行时未变),所以这一版没有运行时兼容分支,只把支持线整体前移。**DSH 0.1.7 线的用户请固定 `dsh-apes-sidebar@0.22.1`——caret 范围跨 minor 不成立,`^0.1.7-rc.1` 在 0.2.0 宿主上会被启动预检静默禁用**;按 DSH 版本选插件版本的对照表见[安装](#-安装)。
<!-- 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.24.1):0.2.0-rc.1+" src="https://img.shields.io/badge/DSH-0.2.0--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://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" /><br /><br />
<b>右侧栏 + 底部面板双工作台</b>,并把 <code>ctx.betterSidebar</code> 服务开放给所有插件——<br />
通过 <code>registerTab</code> / <code>registerFileViewer</code> 注册新的侧边栏页面与文件预览器。
@@ -26,7 +51,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>
@@ -64,25 +89,25 @@
**支持的 DSH 版本**:
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.24.0):0.2.0-rc.1+" src="https://img.shields.io/badge/DSH-0.2.0--rc.1%2B-4d6bfe" /></a>
> 📌 **通道与支持线**:`v0.24.1` 适配 DSH **0.2.0-rc.1+**(0.2.0 首个候选版走 npm `next` 通道,`latest` 仍是 0.1.7-rc.2)。**装 DSH 请写精确版本号**:`npm i -g @deepseek-ai/dsh@0.2.0-rc.1`。**0.1.7 线的用户请固定 `dsh-better-sidebar@0.22.1`**:0.2.0 是宿主 minor 变更,`^0.1.7-rc.1` 这类 caret 范围在 0.2.0 上会被宿主的启动兼容性预检判定失败、整行静默禁用。
> 📌 **通道与支持线**:`v0.24.1` 适配 DSH **0.2.0-rc.1+**(0.2.0 首个候选版走 npm `next` 通道,`latest` 仍是 0.1.7-rc.2)。**装 DSH 请写精确版本号**:`npm i -g @deepseek-ai/dsh@0.2.0-rc.1`。**0.1.7 线的用户请固定 `dsh-apes-sidebar@0.22.1`**:0.2.0 是宿主 minor 变更,`^0.1.7-rc.1` 这类 caret 范围在 0.2.0 上会被宿主的启动兼容性预检判定失败、整行静默禁用。
> 🧭 **按你的 DSH 版本选插件版本**:
>
> | 你的 DSH 版本 | 安装命令 | 版本 / peer 声明 |
> | --- | --- | --- |
> | **0.2.0-rc.1+**(含之后的 0.2.0 正式版) | `dsh plugin --profile web add dsh-better-sidebar@latest` | **0.24.1**,`^0.2.0-rc.1` |
> | **0.1.7-rc.1 ~ 0.1.7-rc.2**(含 0.1.7 正式版;npm `latest` 目前仍是 0.1.7-rc.2) | `dsh plugin --profile web add dsh-better-sidebar@0.22.1` | **0.22.1**,`^0.1.7-rc.1` |
> | **0.2.0-rc.1+**(含之后的 0.2.0 正式版) | `dsh plugin --profile web add dsh-apes-sidebar@latest` | **0.24.1**,`^0.2.0-rc.1` |
> | **0.1.7-rc.1 ~ 0.1.7-rc.2**(含 0.1.7 正式版;npm `latest` 目前仍是 0.1.7-rc.2) | `dsh plugin --profile web add dsh-apes-sidebar@0.22.1` | **0.22.1**,`^0.1.7-rc.1` |
> | 0.1.7-alpha.1 / 0.1.7-alpha.2 | **没有可装版本**——先把 DSH 升到 rc.1,再跑上一行:<br>`npm i -g @deepseek-ai/dsh@0.1.7-rc.1` | — |
> | 0.1.6-alpha.2 及更早、`0.1.5-rc.*`(含 npm `latest` 的 0.1.5-rc.3) | `dsh plugin --profile web add dsh-better-sidebar@0.19.1` | **0.19.1**,`^0.1.5-rc.1` |
> | `0.1.5-alpha.2` | `dsh plugin --profile web add dsh-better-sidebar@0.19.0-alpha.1` | `^0.1.5-alpha.2` |
> | `0.1.2-rc.*` | `dsh plugin --profile web add dsh-better-sidebar@0.18.1` | `^0.1.2-rc.1` |
> | `0.1.2-alpha.2` | `dsh plugin --profile web add dsh-better-sidebar@0.18.0-alpha.0` | `^0.1.2-alpha.2` |
> | `0.1.0-rc.8` / `0.1.1` | `dsh plugin --profile web add dsh-better-sidebar@0.17.1` | `^0.1.0-rc.8` |
> | 0.1.6-alpha.2 及更早、`0.1.5-rc.*`(含 npm `latest` 的 0.1.5-rc.3) | `dsh plugin --profile web add dsh-apes-sidebar@0.19.1` | **0.19.1**,`^0.1.5-rc.1` |
> | `0.1.5-alpha.2` | `dsh plugin --profile web add dsh-apes-sidebar@0.19.0-alpha.1` | `^0.1.5-alpha.2` |
> | `0.1.2-rc.*` | `dsh plugin --profile web add dsh-apes-sidebar@0.18.1` | `^0.1.2-rc.1` |
> | `0.1.2-alpha.2` | `dsh plugin --profile web add dsh-apes-sidebar@0.18.0-alpha.0` | `^0.1.2-alpha.2` |
> | `0.1.0-rc.8` / `0.1.1` | `dsh plugin --profile web add dsh-apes-sidebar@0.17.1` | `^0.1.0-rc.8` |
>
> 命令里的 `web` 换成你自己的 profile 名即可。**旧版本一律写精确版本号**(`@0.19.1` 而不是 `@latest`),因为 `latest` 会随新正式版前移;反过来也**不要**在 0.1.7 的 alpha 上装 0.19.1,装上只会坏。
```sh
dsh plugin --profile web add dsh-better-sidebar@latest
dsh plugin --profile web add dsh-apes-sidebar@latest
```
> 本版不依赖任何需要构建脚本的包(终端连同 `node-pty` 已整体交还 DSH),安装**一步到位**;装完后可在 DSH 自带的 **Plugins 页面**直接启停。
@@ -92,8 +117,8 @@ 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(latest 即当前正式版)
帮我安装 dsh-apes-sidebar 插件(DSH 侧边栏工作台),步骤:
1. 执行 dsh plugin --profile web add dsh-apes-sidebar@latest(latest 即当前正式版)
2. 完成后提醒我硬刷新浏览器(Cmd/Ctrl+Shift+R)
遇到报错先查 https://github.com/omdsh-dev/DSH-better-sidebar README 的常见问题表。
```
@@ -104,7 +129,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)。
@@ -119,10 +144,10 @@ dsh plugin --profile web add dsh-better-sidebar@latest
| 报 `Ignored build scripts` | pnpm 11 拦截了某个传递依赖的构建脚本。在 profile 目录(`~/.dsh/profiles/web`)跑 `pnpm approve-builds` 按提示放行——本插件自身已无构建脚本依赖(终端删除后 `node-pty` 不在依赖里)。 |
| 报 `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 0.1.7 删除了插件可注册的设置命名空间:偏好现在写在 profile 里本插件的**挂载行**上(默认 entry id `better-sidebar`),不再是 `~/.dsh/settings.yaml`。插件会在首次启动时把旧 `settings.yaml`(已被宿主改名为 `settings.yaml.imported`)里 `dsh-better-sidebar` 段一次性回迁,只迁移当前 schema 仍声明的字段、且只在该行还没有用户值时执行,不会覆盖升级后新设的值。 |
| 页面出现**两个侧边栏** | 双挂载。旧的手动挂载行:`~/.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` 之前。 |
| 升级后设置页的值去哪了 | DSH 0.1.7 删除了插件可注册的设置命名空间:偏好现在写在 profile 里本插件的**挂载行**上(默认 entry id `better-sidebar`),不再是 `~/.dsh/settings.yaml`。插件会在首次启动时把旧 `settings.yaml`(已被宿主改名为 `settings.yaml.imported`)里 `dsh-apes-sidebar` 段一次性回迁,只迁移当前 schema 仍声明的字段、且只在该行还没有用户值时执行,不会覆盖升级后新设的值。 |
| 终端无法使用 / 提示 shell 启动失败 | 终端由 **DSH 自身的 `ui-sidebar-terminal`** 提供(本插件不再自带终端与 `node-pty`,也没有终端相关设置项)。遇到问题请查 DSH 侧文档;若报错提到构建脚本,见上一行。 |
| 提示 `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>
@@ -134,11 +159,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 追加挂载行(这一行的 `config` 就是本插件的设置表单:部署限额 `readLimit` / `mediaLimit` / `uploadLimit` / `listLimit` 加用户偏好字段,设置页写的就是它;不写则全部用 schema 默认值):
- insert:
- id: better-sidebar
name: 'dsh-better-sidebar'
name: 'dsh-apes-sidebar'
config:
readLimit: 524288
4. 在 ~/.dsh/profiles/web 执行 pnpm install
@@ -159,7 +184,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`。切换通道前先移除另一通道的挂载。
@@ -216,7 +241,7 @@ dsh registry enable dsh-external/dsh-better-sidebar
> 📦 **正式版**(npm `latest`):支持线**不变**——仍仅支持 **DSH 0.1.7-rc.1+**(peer 下限 `^0.1.7-rc.1`,CI 钉 `@deepseek-ai/dsh@0.1.7-rc.1`),0.21.1 / 0.22.0 的用户直接升级即可。修掉两个**真机可复现、单测却全绿**的缺陷;**DSH 0.1.6-alpha.2 及更早请继续固定 v0.19.1**。
- 🐛 **`files` 接管被孤儿化 → 报错刷屏 + 文件树空态**(社区 #770 / #771,官方桌面壳日志实证):客户端条目替换(插件市场更新 / Plugins 页禁用→启用 / HMR 重打)时,`sync()` 的清理循环会把**不属于描述符**的 `files` 接管释放掉、又在同一轮里重建——而重建发生在**已经 inactive** 的插件上下文上:`tabs.register` 建在宿主上下文上照样取走了 id,紧随的 `ctx.slots.inject` 却抛 `cannot create effect on inactive context`,于是 disposer 丢失、该 id 在**整个页面生命周期内不可再注册**(表现就是 `native register files error: … already registered` 刷屏 + 文件树落到宿主空态,只有刷新页面才恢复)。现在清理循环**跳过 `FILES_KIND`**(接管的寿命只由编辑器类型开关与 seat disposer 决定),并且**任何在宿主取走 id 之后失败的注册都会回滚释放**(含已建好的槽位),失败只留一个「下次通知可重试」的状态。修复取自社区 PR #777(@yanzhaohui1999)。
- 🖥️ **macOS 桌面版窗口拖拽 / 双击标题栏缩放失效**(#772):插件宿主是直挂 `body` 的子元素,宿主的 `html[data-platform=darwin] body > :not(#root) { -webkit-app-region: no-drag }` 命中它,而 app-region **无视 `pointer-events`**——铺满视口的面板层把下面每条拖拽带一起抵消(拖第一次还行、之后全失效)。现在 `[data-dsh-better-sidebar]` / `[data-dsh-panel-host]` / 放大视图 `.mermaidModal` 都用中性值 `initial !important` 退出计算,层内的面板与控件保持 `no-drag`(点击不被吞);合并社区 PR #773 并补齐放大视图这最后一个铺满视口的 body 直挂层。
- 🖥️ **macOS 桌面版窗口拖拽 / 双击标题栏缩放失效**(#772):插件宿主是直挂 `body` 的子元素,宿主的 `html[data-platform=darwin] body > :not(#root) { -webkit-app-region: no-drag }` 命中它,而 app-region **无视 `pointer-events`**——铺满视口的面板层把下面每条拖拽带一起抵消(拖第一次还行、之后全失效)。现在 `[data-dsh-apes-sidebar]` / `[data-dsh-panel-host]` / 放大视图 `.mermaidModal` 都用中性值 `initial !important` 退出计算,层内的面板与控件保持 `no-drag`(点击不被吞);合并社区 PR #773 并补齐放大视图这最后一个铺满视口的 body 直挂层。
- ✅ **守住它们**:新增单元用例把「接管不得被通知拆建」「注册失败必须回滚已占用 id 与已建槽位」钉在注册表**事件日志**上(未修复代码上 4/4 红),并新增**部署级回归门** `tests/e2e/native-reload.e2e.ts`(在 npm 0.22.0 上连续 3 次运行全红、修复版连续 3 次全绿(1 个用例重复跑三次));拖拽契约由单元用例 + 挂载 lane 的**真实级联探针**(按宿主规则读计算值)守护。验证:`pnpm test` 122 files / 1293 passed / 9 skipped,`pnpm test:mount` 与 `test:mount:aggregate` 绿。事故记录见 [docs/plans/2026-09-28-native-files-takeover-reload-leak.md](./docs/plans/2026-09-28-native-files-takeover-reload-leak.md)。
### v0.22.0
@@ -256,9 +281,9 @@ dsh registry enable dsh-external/dsh-better-sidebar
### ➕ 添加插件(推荐插件目录)
设置页「侧边卡片」两个网格末尾的**虚线卡片**分别打开 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` 守护。
## 🛠️ 开发与构建
@@ -307,7 +332,7 @@ Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元
`ctx.betterSidebar` 服务向所有插件开放两个扩展点:**`registerTab`(注册侧边栏页面)** 与 **`registerFileViewer`(注册文件预览器)**。内置的 5 tab + 3 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({
@@ -321,7 +346,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 />
@@ -344,7 +369,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 笔记,任何工作区随时可读 |
@@ -357,7 +382,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 镜像与双向同步 |
@@ -372,7 +397,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>
@@ -383,19 +408,19 @@ 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` 守护)。
## 🤝 参与贡献
- **代码改动走 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 说明)
@@ -417,5 +442,5 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
---
<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>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"id": "dsh-external/dsh-apes-sidebar",
"version": "0.24.1",
"version": "1.3.0",
"main": "./lib/index.js",
"description": "更好用的右侧边栏——文件树、编辑器、文件变动、任务和侧边对话都收在这一栏里,每个会话互不干扰。暴露服务供其他插件注册侧边栏页面与文件预览器",
"engines": {
+17 -6
View File
@@ -1,7 +1,7 @@
{
"name": "dsh-better-sidebar",
"version": "0.24.1",
"description": "Better Sidebar for DSH — file tree, editor, changes, tasks and side chat in one column, separate for every conversation. Also exposes a service other plugins use to register sidebar tabs and file viewers.",
"name": "dsh-apes-sidebar",
"version": "1.3.0",
"description": "APE fork of dsh-better-sidebar (base v0.24.1): session-isolated right sidebar — file tree, editor, changes, tasks and side chat — built on the host's native sidebar, plus apes:* information-layer tabs (agents / apps / profile). Forked from omdsh-dev/DSH-better-sidebar under MIT.",
"type": "module",
"repository": {
"type": "git",
@@ -74,7 +74,8 @@
"README_EN.md",
"LICENSE",
"icon.svg",
"locale/*.json"
"locale/*.json",
"scripts/switch.sh"
],
"scripts": {
"build": "node -e \"require('node:fs').rmSync('lib',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && tsdown",
@@ -89,7 +90,11 @@
"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 ."
"lint": "eslint .",
"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": {
@@ -227,5 +232,11 @@
"vitest": "^4.1.8",
"zustand": "~4.4.7"
},
"icon": "./icon.svg"
"icon": "./icon.svg",
"forkedFrom": {
"name": "dsh-better-sidebar",
"version": "0.24.1",
"commit": "a2751cf",
"url": "https://github.com/omdsh-dev/DSH-better-sidebar"
}
}
+341 -508
View File
File diff suppressed because it is too large Load Diff
+294
View File
@@ -0,0 +1,294 @@
#!/usr/bin/env bash
# =============================================================================
# dsh-apes-sidebar 两线切换脚本(apes fork 线 ↔ upstream better-sidebar 线)
#
# 同一个 DSH profile 内**二选一**切换侧边栏插件——双装不可行(两条线都注册
# 进 `ctx.betterSidebar`,cordis entry id 冲突,第二个被丢弃)。切换 =
# 换 profile 依赖 + 换 dsh.profile.bundles + pnpm install + 提示重启。
#
# aps 本 fork 线(dsh-apes-sidebar,含 4 个 apes:* 信息层 tab)
# 依赖:dsh-apes-sidebar: <file: 本地路径>(本地开发)或
# dsh-apes-sidebar@<版本>(npm,发布后)
# upstream upstream 线(dsh-better-sidebar,官方 8 tab + 生态)
# 依赖:dsh-better-sidebar@<版本>(registry,默认 latest)
#
# 用法:
# bash scripts/switch.sh aps # 切到本 fork 线(本地 file: 安装)
# bash scripts/switch.sh upstream # 切到 upstream 线(registry 安装)
# bash scripts/switch.sh upstream 0.18.0 # 指定 upstream 版本
# bash scripts/switch.sh --status # 只看当前线,不改任何东西
#
# 通用 flag(两线均适用):
# --profile <名> 目标 profile(缺省 desktop)
# --dry-run 只打印将执行的操作,不写任何文件
# -h/--help 打印本帮助
#
# 环境(均可省略,脚本自动探测):
# DSH_HOME 默认 ~/.dsh
# REGISTRY 默认 https://registry.npmjs.org
# DSH_CMD 默认 PATH 上的 dsh,缺省 npx -y --package @deepseek-ai/dsh
# APS_SRC aps 线本地源码路径(缺省 = 本仓库根,即 file: 指向自己)
#
# 机制(幂等,可重复跑):
# 1. 探测当前线(依赖里哪个包在)
# 2. 与目标线相同 → 提示后直接退出(不重装)
# 3. 移除另一线的依赖 + 从 bundles 摘除另一线的 bundle
# 4. 写入目标线依赖 + 确保 workspace 放行 node-pty/protobufjs 构建
# 5. cd profile && pnpm install(reconcileProfilePnpmWorkspace 会重建
# node_modules——这是预期行为,两线不共存)
# 6. 确保 bundles 含目标线包名(bundle 通道挂载生效的判据)
# 7. 幂等移除 cordis.patch.yml 里对手线的手动挂载行(避免双挂载)
# 8. 提示重启 DSH
#
# 回滚:跑 `bash scripts/switch.sh <另一线>` 即可切回去。
# =============================================================================
set -euo pipefail
for arg in "$@"; do
if [ "$arg" = "-h" ] || [ "$arg" = "--help" ]; then
cat <<'EOF'
dsh-apes-sidebar 两线切换(同一 profile 内二选一)
用法:
bash scripts/switch.sh <aps|upstream> [版本]
bash scripts/switch.sh --status
aps 切到本 fork 线(dsh-apes-sidebar,4 个 apes:* tab)
upstream 切到 upstream 线(dsh-better-sidebar,官方)
<版本> 仅对 upstream 生效(缺省 latest)
--status 只看当前线,不改动
--profile 目标 profile(缺省 desktop)
--dry-run 只打印操作,不写文件
-h/--help 本帮助
环境变量:DSH_HOME、REGISTRY、DSH_CMD、APS_SRC(aps 本地源码路径)
EOF
exit 0
fi
done
DSH_HOME="${DSH_HOME:-${HOME:-${USERPROFILE:-}}/.dsh}"
REGISTRY="${REGISTRY:-https://registry.npmjs.org}"
DSH_CMD="${DSH_CMD:-dsh}"
# aps 线本地源码:缺省 = 本仓库根(脚本在 scripts/ 下,根 = 上一级)
APS_SRC="${APS_SRC:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
APS_PKG="dsh-apes-sidebar"
UP_PKG="dsh-better-sidebar"
DRY_RUN=false
STATUS_ONLY=false
TARGET=""
UP_VERSION=""
PROFILE_NAME="desktop"
while [ $# -gt 0 ]; do
case "$1" in
--status) STATUS_ONLY=true ;;
--dry-run) DRY_RUN=true ;;
--profile)
if [ $# -lt 2 ]; then echo "--profile 需要一个 profile 名(如 desktop)" >&2; exit 2; fi
PROFILE_NAME="$2"; shift ;;
aps|upstream) TARGET="$1" ;;
-h|--help) : ;;
-*) echo "未知参数: ${1}(用 -h 查看用法)" >&2; exit 2 ;;
*) UP_VERSION="$1" ;; # 版本参数(仅 upstream 用)
esac
shift
done
PROFILE_DIR="$DSH_HOME/profiles/$PROFILE_NAME"
PKG_JSON="$PROFILE_DIR/package.json"
WS_YML="$PROFILE_DIR/pnpm-workspace.yaml"
PATCH_YML="$PROFILE_DIR/cordis.patch.yml"
say() { printf '\033[32m[switch]\033[0m %s\n' "$*"; }
warn() { printf '\033[33m[warn]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[31m[error]\033[0m %s\n' "$*" >&2; exit 1; }
command -v node >/dev/null 2>&1 || die "未找到 node(DSH 运行需要 Node.js ≥ 20)。"
[ -d "$PROFILE_DIR" ] || die "找不到 profile 目录:${PROFILE_DIR}(请先初始化 ${PROFILE_NAME} profile)"
[ -f "$PKG_JSON" ] || die "找不到 ${PKG_JSON}(profile 未初始化)"
# ── 探测当前线(依赖里哪个包在)─────────────────────────────────────────
current_line() {
node -e '
const p = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
const deps = p.dependencies || {};
if (deps["dsh-apes-sidebar"]) console.log("aps");
else if (deps["dsh-better-sidebar"]) console.log("upstream");
else console.log("none");
' "$PKG_JSON"
}
if [ "$STATUS_ONLY" = true ]; then
CUR="$(current_line)"
say "profile: ${PROFILE_NAME} (${PROFILE_DIR})"
case "$CUR" in
aps) say "当前线:aps(${APS_PKG})" ;;
upstream) say "当前线:upstream(${UP_PKG})" ;;
none) say "当前线:无(两条线都未装)" ;;
esac
# 附带显示 bundles 里的 sidebar 条目
node -e '
const p = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
const b = (p.dsh && p.dsh.profile && p.dsh.profile.bundles) || [];
const side = b.filter(x => /sidebar/.test(x));
console.log("bundles 里的 sidebar 条目: " + (side.length ? side.join(", ") : "(无)"));
' "$PKG_JSON"
exit 0
fi
[ -n "$TARGET" ] || die "缺少目标线(aps | upstream)。用 --status 查看当前线。"
CUR="$(current_line)"
case "$TARGET" in
aps) T_PKG="$APS_PKG"; T_DEP="file:${APS_SRC}" ;;
upstream)
T_PKG="$UP_PKG"
# pnpm 从依赖 key 取包名,值只给版本 spec(不含包名,否则双 @)
case "${UP_VERSION:-latest}" in
latest) T_DEP="latest" ;;
*) T_DEP="$UP_VERSION" ;;
esac
;;
esac
[ "$CUR" = "$TARGET" ] && {
say "当前已是 ${TARGET} 线(${T_PKG}),无需切换。"
say "如需强制重装:先跑 --status 确认,再手动移除依赖后重跑。"
exit 0
}
OTHER_PKG=""
case "$TARGET" in
aps) OTHER_PKG="$UP_PKG" ;;
upstream) OTHER_PKG="$APS_PKG" ;;
esac
say "切换:${CUR} → ${TARGET}(profile: ${PROFILE_NAME})"
say "目标依赖:${T_DEP}"
if [ "$DRY_RUN" = true ]; then
say "[dry-run] 步骤 1:移除 ${OTHER_PKG:-(无)} 依赖 + 从 bundles 摘除 ${OTHER_PKG:-(无)}"
say "[dry-run] 步骤 2:写入 ${T_PKG} 依赖(${T_DEP})"
say "[dry-run] 步骤 3:确保 ${WS_YML} 放行 node-pty/protobufjs 构建"
say "[dry-run] 步骤 4:cd ${PROFILE_DIR} && pnpm install(重建 node_modules)"
say "[dry-run] 步骤 5:确保 bundles 含 ${T_PKG}"
say "[dry-run] 步骤 6:幂等移除 ${PATCH_YML} 里 ${OTHER_PKG:-对手线} 的手动挂载行"
say "[dry-run] 步骤 7:提示重启 DSH"
exit 0
fi
# ── 步骤 1+2+5 合并:一次 node 调用完成依赖 + bundles 的对称改写 ─────────
# 移除对手线依赖 + bundle,写入目标线依赖 + 确保目标 bundle 在列。幂等。
node -e '
const fs = require("fs");
const p = process.argv[1];
const keep = process.argv[2]; // 目标包名
const depSpec = process.argv[3]; // 目标依赖 spec(file:... 或 pkg@ver)
const remove = process.argv[4]; // 对手包名(可能为空串)
const t = JSON.parse(fs.readFileSync(p, "utf8"));
t.dependencies = t.dependencies || {};
// 移除对手线依赖
if (remove) { const had = t.dependencies[remove]; delete t.dependencies[remove]; if (had) console.log("removed dep: " + remove + "=" + had); }
// 写入目标线依赖
t.dependencies[keep] = depSpec;
// bundles:摘除对手线,确保目标线在列
const prof = (t.dsh && t.dsh.profile) || {};
const b = prof.bundles || [];
const nb = b.filter(x => x !== remove);
if (!nb.includes(keep)) nb.push(keep);
t.dsh = t.dsh || {}; t.dsh.profile = prof; prof.bundles = nb;
fs.writeFileSync(p, JSON.stringify(t, null, 2) + "\n");
console.log("deps now: " + JSON.stringify(t.dependencies));
console.log("bundles now: " + nb.join(", "));
' "$PKG_JSON" "$T_PKG" "$T_DEP" "$OTHER_PKG"
say "依赖 + bundles 已改写"
# ── 步骤 3:确保 workspace 放行 node-pty/protobufjs 构建(幂等)─────────
if [ -f "$WS_YML" ]; then
WS_RESULT="$(node -e '
const fs = require("fs");
const p = process.argv[1];
let t = fs.readFileSync(p, "utf8");
const before = t;
t = t.replace(/^(\s*)(node-pty|protobufjs):.*$/gm, "$1$2: true");
if (!/^\s*allowBuilds:\s*$/m.test(t)) {
t += "\nallowBuilds:\n node-pty: true\n protobufjs: true\n";
} else {
for (const k of ["node-pty", "protobufjs"]) {
if (!new RegExp("^\\s*" + k + ":\\s*true\\s*$", "m").test(t)) {
t = t.replace(/^(\s*allowBuilds:\s*)$/m, "$1\n " + k + ": true");
}
}
}
if (t !== before) fs.writeFileSync(p, t);
console.log(t === before ? "unchanged" : "updated");
' "$WS_YML")"
[ "$WS_RESULT" = "updated" ] && say "已确保 ${WS_YML}:allowBuilds(node-pty/protobufjs: true)" \
|| say "workspace 设置已就绪,跳过"
else
warn "找不到 ${WS_YML},跳过 workspace 预写(若 pnpm install 报 ignored build scripts 需手动补)"
fi
# ── 步骤 4:pnpm install(重建 node_modules——两线不共存,这是预期)──────
command -v pnpm >/dev/null 2>&1 || die "未找到 pnpm。请安装 pnpm 9+ 并加入 PATH。"
say "执行 cd ${PROFILE_DIR} && pnpm install(reconcileProfilePnpmWorkspace 会重建 node_modules)..."
if ! (cd "$PROFILE_DIR" && pnpm install); then
die "pnpm install 失败。请确认网络可用、依赖 spec 正确,然后重试。"
fi
# ── 步骤 5 校验:bundles 含目标包(挂载生效判据)────────────────────────
if ! node -e '
const p = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
const b = (p.dsh && p.dsh.profile && p.dsh.profile.bundles) || [];
process.exit(b.includes(process.argv[2]) ? 0 : 1);
' "$PKG_JSON" "$T_PKG"; then
warn "${T_PKG} 未出现在 dsh.profile.bundles——挂载未注册,请检查 ${PKG_JSON}。"
exit 1
fi
say "bundle 已注册:dsh.profile.bundles 含 ${T_PKG}(下次启动自动挂载)"
# ── 步骤 6:幂等移除对手线在 cordis.patch.yml 里的手动挂载行 ─────────────
if [ -f "$PATCH_YML" ]; then
MOUNT_RESULT="$(node -e '
const fs = require("fs");
const p = process.argv[1];
const removePkg = process.argv[2];
if (!removePkg) { console.log("none"); process.exit(0); }
const lines = fs.readFileSync(p, "utf8").split("\n");
const out = [];
let i = 0;
let removed = false;
while (i < lines.length) {
const line = lines[i];
if (/^[ \t]*- insert:\s*$/.test(line)) {
const block = [line];
let j = i + 1;
while (j < lines.length && lines[j].trim() !== "" && !/^- \s/.test(lines[j])) {
block.push(lines[j]);
j++;
}
if (block.some((l) => new RegExp("id:\\s*" + removePkg.replace(/[-/]/g, "\\$&") + "\\b").test(l))) {
while (out.length && /^[ \t]*#/.test(out[out.length - 1])) out.pop();
i = j;
removed = true;
continue;
}
}
out.push(line);
i++;
}
if (!removed) console.log("none");
else { const t = out.join("\n").replace(/\n{3,}/g, "\n\n"); fs.writeFileSync(p, t); console.log("removed"); }
' "$PATCH_YML" "$OTHER_PKG")"
[ "$MOUNT_RESULT" = "removed" ] && say "已从 ${PATCH_YML} 移除 ${OTHER_PKG} 的手动挂载行" \
|| say "无 ${OTHER_PKG} 手动挂载行,跳过"
else
say "无 ${PATCH_YML},跳过手动挂载行清理"
fi
# ── 步骤 7:提示重启 ────────────────────────────────────────────────────
say "切换完成:${CUR} → ${TARGET}(${T_PKG})"
say "下一步:重启 DSH 并硬刷新(Cmd/Ctrl+Shift+R)使新副本生效。"
+78
View File
@@ -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}
/>
)
}
+8 -10
View File
@@ -36,7 +36,7 @@ import { LazyMermaidMarkdown, MarkdownDocument, type MarkdownHtmlMedia } from '.
import { MdToc } from './md-toc.tsx'
import { splitMermaidBlocks } from './mermaid-blocks.ts'
import { t } from './locales.ts'
import { HTML_IFRAME_SANDBOX } from './html-preview.ts'
import { HtmlPreviewFrame } from './HtmlPreviewFrame.tsx'
import type { EditorToolbarState, FileViewerProps } from './service.ts'
import css from './sidebar.module.css'
@@ -551,16 +551,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}
/>
</>
+50
View File
@@ -0,0 +1,50 @@
/**
* Tab glyphs for the apes tabs (智能体 / 应用 / 画像).
*
* These follow the SAME recipe as this repo's own icon set in
* `src/client/icons.tsx` — the one the sidebar already uses for 终端 /
* 浏览器 / 文件变动: a 16×16 viewBox whose artwork spans 1.5–14.5, a 1.5px
* `currentColor` stroke, round caps/joins, `fill: none`.
*
* That recipe is what makes an icon sit at the native visual weight in the
* `+` new-tab menu and the right-panel tile grid: DSH's own glyphs fill
* ~92–96% of the box, so artwork padded any tighter renders visibly small
* next to them.
*
* (An earlier revision drew these as fill-only paths spanning only 10–12 of
* the 16 units — that is why they read as a separate, lighter family.)
*/
import type { ReactNode } from 'react'
/** Agent glyph: a rounded robot head with two fill eyes. */
export function IconAgentOutline16({ size = 16, className }: { size?: number; className?: string }): ReactNode {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
<rect x="1.5" y="3" width="13" height="11.5" rx="4.2" stroke="currentColor" strokeWidth="1.5" />
<circle cx="5.8" cy="8.6" r="1.25" fill="currentColor" />
<circle cx="10.2" cy="8.6" r="1.25" fill="currentColor" />
</svg>
)
}
/** Apps glyph: a 2×2 grid of rounded squares. */
export function IconAppsOutline16({ size = 16, className }: { size?: number; className?: string }): ReactNode {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
<rect x="1.5" y="1.5" width="5.5" height="5.5" rx="1.8" stroke="currentColor" strokeWidth="1.5" />
<rect x="9" y="1.5" width="5.5" height="5.5" rx="1.8" stroke="currentColor" strokeWidth="1.5" />
<rect x="1.5" y="9" width="5.5" height="5.5" rx="1.8" stroke="currentColor" strokeWidth="1.5" />
<rect x="9" y="9" width="5.5" height="5.5" rx="1.8" stroke="currentColor" strokeWidth="1.5" />
</svg>
)
}
/** Profile glyph: a head over shoulders. */
export function IconUserOutline16({ size = 16, className }: { size?: number; className?: string }): ReactNode {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
<circle cx="8" cy="4.9" r="3.4" stroke="currentColor" strokeWidth="1.5" />
<path d="M1.5 14.5c0-3.3 2.8-5.3 6.5-5.3s6.5 2 6.5 5.3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
+43
View File
@@ -0,0 +1,43 @@
/**
* apes:* information-layer tabs — the fork's own contribution on top of the
* upstream dsh-better-sidebar. Three tabs ship (agents / apps / profile);
* `apes:tasks` was removed (C6) because it overlaps the official DSH session
* tree in the native sidebar, and `messages` is deferred (no DSH
* message-stream API yet — tracked separately).
*
* Isolated under `src/client/apes/` so the upstream `builtins/` tree stays
* untouched: cherry-picking upstream releases only has to keep the single
* `registerApesTabs(...)` call site in `index.tsx` (see D1 = module B).
*
* Every descriptor is registered through the same `BetterSidebarService`
* the builtins use (dogfood), so the apes tabs get the full sidebar
* lifecycle for free: + menu, enable/disable switches, badge pills,
* dedupe/focus, and HMR-safe disposal via the returned disposer.
*/
import type { Context } from '../../context-types.ts'
import type { BetterSidebarService } from '../service.ts'
import { apesTabs } from './tabs.tsx'
// Re-exported so index.tsx can register the apes dictionaries + attach the
// locale service in one import (keeps the apes module self-contained).
export { APES_LOCALE_NS, apesZh, apesEn, attachApesLocale } from './locales.ts'
/**
* Register the apes:* tabs with the sidebar service. Returns a disposer that
* unregisters all of them (cordis auto-invokes it on fiber disposal, so HMR
* re-activation re-registers cleanly — same contract as registerBuiltins).
*/
export function registerApesTabs(
_ctx: Context,
service: BetterSidebarService,
): () => void {
const disposers: (() => void)[] = []
for (const tab of apesTabs) {
disposers.push(service.registerTab(tab))
}
return () => {
for (const d of disposers) {
try { d() } catch { /* already disposed */ }
}
}
}
+87
View File
@@ -0,0 +1,87 @@
/**
* apes:* tab i18n — isolated from the upstream `betterSidebar` locale
* namespace (D1 = module B). The fork keeps its own `apes` namespace and
* its own `t()` so that adding apes keys never touches the upstream
* `locales.ts` (whose `CopyKey = keyof typeof zh` is closed over its own
* dicts). The `t()` mirrors the upstream implementation's resolution order
* (active-locale chain → key fallback) but reads the apes dicts; it is NOT
* wired to the better-locale override store in Phase 1 (those third-language
* overrides ride the lazy `locale` chunk that only covers `betterSidebar`
* entries — apes keys fall back to zh/en, which is acceptable for the
* placeholder round and gets the override integration in Phase 2).
*/
/** The apes locale namespace (registered with ctx.locale under this key). */
export const APES_LOCALE_NS = 'apes'
/** The zh dictionary (source of truth). */
export const apesZh = {
// View titles
apesAgentsTitle: '智能体',
apesAppsTitle: '应用',
apesProfileTitle: '画像',
// Agents view
apesAgentsEmpty: '当前会话没有子智能体',
apesAgentsRoot: '主智能体',
apesAgentRunning: '运行中',
apesAgentInactive: '空闲',
apesAgentOneShot: '一次性',
apesAgentContinuable: '可续',
// Profile view
apesProfileCurrent: '当前会话',
apesProfileCwd: '工作目录',
apesProfileNoCwd: '未知',
apesProfileRunning: '运行中',
apesProfileIdle: '空闲',
// Apps view
apesAppsNoFeed: '当前 DSH 版本未提供工作区应用数据源',
apesAppsWorkspace: '工作区',
} as const
/** The en dictionary (checked against zh — every zh key must appear here). */
export const apesEn: Record<keyof typeof apesZh, string> = {
apesAgentsTitle: 'Agents',
apesAppsTitle: 'Apps',
apesProfileTitle: 'Profile',
apesAgentsEmpty: 'No subagents in this session',
apesAgentsRoot: 'Main agent',
apesAgentRunning: 'running',
apesAgentInactive: 'idle',
apesAgentOneShot: 'one-shot',
apesAgentContinuable: 'continuable',
apesProfileCurrent: 'Current session',
apesProfileCwd: 'Working directory',
apesProfileNoCwd: 'unknown',
apesProfileRunning: 'running',
apesProfileIdle: 'idle',
apesAppsNoFeed: 'This DSH version exposes no workspace-app feed',
apesAppsWorkspace: 'Workspace',
}
export type ApesCopyKey = keyof typeof apesZh
/** Resolve the currently active DSH locale ('zh' | 'en' today). */
export function attachApesLocale(service: { getSnapshot(): { active: string } } | undefined): void {
apesLocaleService = service
}
let apesLocaleService: { getSnapshot(): { active: string } } | undefined
/**
* Translate an apes key. Resolution order: active-locale chain (zh dict when
* the DSH preference is zh, en dict otherwise) → key fallback (shows the key
* itself rather than `undefined`). Mirrors the upstream t() minus the
* better-locale override (Phase 2).
*/
export function t(key: ApesCopyKey, params?: Record<string, string | number>): string {
const active = apesLocaleService?.getSnapshot().active ?? 'zh'
const dict = active.toLowerCase().startsWith('zh') ? apesZh : apesEn
let text: string | undefined = dict[key]
if (text === undefined) text = key
if (params !== undefined) {
for (const [name, value] of Object.entries(params)) {
text = text.replaceAll(`{${name}}`, String(value))
}
}
return text
}
+241
View File
@@ -0,0 +1,241 @@
/**
* apes:* tab views (D2 = data wiring). Each tab reads live data from the
* sessions list feed (`ctx.sessions.list`) via `useSyncExternalStore` and
* reuses the fork's PURE lineage/job helpers — these are framework-free and
* live outside `builtins/`, so importing them does not couple the apes module
* to upstream UI and survives cherry-picks.
*
* Three information-layer tabs (C6: `apes:tasks` removed — it overlaps the
* official DSH session tree in the native sidebar):
* - apes:agents → the subagent topology rooted at the main agent of the
* current session (rootAncestor + per-parent catalogs).
* - apes:apps → no DSH host feed today: shows the workspace (cwd) of the
* current session, otherwise an honest "no feed" note.
* - apes:profile → the current session's summary row (title, cwd, running).
*
* Visual unity (C7): colors use the DSW design-token aliases
* (`--dsw-alias-*`) so the apes panels match the native DSH sidebar
* surface; the runtime defines these on :root. Font sizes / spacing /
* radius stay inline (DSH exposes no public `--ds-*` geometry alias for
* plugin panels — its own vendor bundles inline those too).
*
* Data sources (structural mirror — runtime provided by the DSH host):
* - `ctx.sessions.list.getSnapshot()` → { byId, projectionsBySession? };
* `subscribe(cb)` is uSES-safe. The host's per-session projection carries
* the direct-child catalog (`values.subagentCatalog`).
* - `scope.sessionId` = the session this tab belongs to (the host's 0.1.6
* `list.current` is gone; 0.1.7 sanctions `ctx.sidebarRight.mounted` for
* "which seat is on screen", which the scope already answers here).
*/
import { useCallback, useMemo, useState, type CSSProperties } from 'react'
import { useSyncExternalStore } from 'react'
import { IconAgentOutline16, IconAppsOutline16, IconUserOutline16 } from './icons.tsx'
import type { TabComponentProps, TabDescriptor } from '../service.ts'
import type {
SidebarSessionList,
SidebarSessionSummary,
SidebarSubagentCatalogEntry,
} from '../../context-types.ts'
import { rootAncestor, isSideThreadSummary } from '../subagent-lineage.ts'
import { t } from './locales.ts'
/**
* DSW design-token color aliases. The DSH runtime defines these on :root
* (same set the native dsh-client-ui-* vendor bundles consume). We reference
* them by name via `var(...)` with a literal fallback so the panels still
* render in a non-DSH context (unit harness) with a sane dark palette.
*/
const DSW = {
bgBase: 'var(--dsw-alias-bg-base, #1a1a1a)',
bgLayer1: 'var(--dsw-alias-bg-layer-1, #202020)',
borderL1: 'var(--dsw-alias-border-l1, #2a2a2a)',
borderL2: 'var(--dsw-alias-border-l2, #303030)',
labelPrimary: 'var(--dsw-alias-label-primary, #e8e8e8)',
labelSecondary: 'var(--dsw-alias-label-secondary, #a0a0a0)',
labelTertiary: 'var(--dsw-alias-label-tertiary, #7a7a7a)',
success: 'var(--dsw-alias-state-success-primary, #52c41a)',
interactiveHover: 'var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,0.06))',
} as const
/** Shared inline styles (KISS: one object, no CSS module). Colors via DSW. */
const S: Record<string, CSSProperties> = {
body: { padding: 12, display: 'flex', flexDirection: 'column', gap: 12 },
title: { margin: 0, fontSize: 14, fontWeight: 600, color: DSW.labelPrimary },
hint: { margin: 0, fontSize: 12, color: DSW.labelTertiary },
list: { display: 'flex', flexDirection: 'column', gap: 8 },
card: { border: `1px solid ${DSW.borderL1}`, borderRadius: 8, padding: 10, display: 'flex', flexDirection: 'column', gap: 6, background: DSW.bgLayer1 },
cardRow: { display: 'flex', alignItems: 'center', gap: 8 },
cardLabel: { fontSize: 13, fontWeight: 500, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: DSW.labelPrimary },
cardSecondary: { fontSize: 11, color: DSW.labelSecondary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
badge: { fontSize: 11, color: DSW.labelTertiary, flexShrink: 0 },
}
/** A small colored status dot (self-contained, not the builtin StateDot). */
function Dot(props: { color: string }) {
const style: CSSProperties = {
width: 8, height: 8, borderRadius: '50%', background: props.color, flexShrink: 0, display: 'inline-block',
}
return <span aria-hidden style={style} />
}
/** Subscribe to the sessions list feed (uSES-safe). Returns the live snapshot. */
function useSessionList(ctx: TabComponentProps['ctx']): SidebarSessionList {
const sessions = ctx.sessions
return useSyncExternalStore(
useMemo(() => (callback: () => void) => sessions.list.subscribe(callback), [sessions]),
useCallback(() => sessions.list.getSnapshot(), [sessions]),
)
}
/** One direct-child row, normalized from whichever source answered. */
interface AgentChildRow {
readonly id: string
readonly label: string
readonly mode: SidebarSubagentCatalogEntry['mode']
}
/**
* One level of the subagent topology (rooted at the main agent): the durable
* children of a session, read from the session summaries.
*/
function agentChildren(
byId: Readonly<Record<string, SidebarSessionSummary>>,
parentSessionId: string,
): SidebarSessionSummary[] {
return Object.values(byId).filter(
s => s.origin === 'subagent' && s.parentId === parentSessionId && !isSideThreadSummary(s),
)
}
/** apes:agents — the subagent topology of the current session's tree. */
function ApesAgentsView(props: TabComponentProps) {
const list = useSessionList(props.ctx)
const { byId } = list
const rootId = useMemo(() => rootAncestor(byId, props.scope.sessionId), [byId, props.scope.sessionId])
const rootSummary = rootId === undefined ? undefined : byId[rootId]
// The topology: the main agent + its direct subagent children (one level,
// with per-child activity). The host's projection (0.1.7) is preferred for
// the child list when it has loaded; otherwise the durable byId rows answer.
// Activity and labels still come from byId, so both sources render alike.
const catalog = rootId === undefined ? undefined : list.projectionsBySession?.[rootId]?.values.subagentCatalog
const children: AgentChildRow[] = catalog !== undefined
? catalog.map(entry => ({
id: entry.id, label: entry.label ?? byId[entry.id]?.displayTitle ?? entry.id, mode: entry.mode,
}))
: agentChildren(byId, rootId ?? '').map(s => ({ id: s.id, label: s.displayTitle, mode: 'continuable' as const }))
return (
<div style={S.body}>
<h2 style={S.title}>{t('apesAgentsTitle')}</h2>
{rootSummary === undefined && children.length === 0
? <p style={S.hint}>{t('apesAgentsEmpty')}</p>
: (
<div style={S.list}>
{/* Root (main agent) node */}
{rootSummary !== undefined && (
<div style={S.card}>
<div style={S.cardRow}>
<Dot color={rootSummary.running === true ? DSW.success : DSW.labelTertiary} />
<span style={S.cardLabel} title={rootSummary.displayTitle}>{t('apesAgentsRoot')}: {rootSummary.displayTitle}</span>
</div>
<div style={S.cardRow}>
<span style={S.badge}>{rootSummary.running === true ? t('apesAgentRunning') : t('apesAgentInactive')}</span>
</div>
</div>
)}
{/* Direct subagent children */}
{children.map(entry => {
const running = byId[entry.id]?.running === true
return (
<div key={entry.id} style={{ ...S.card, marginLeft: 14, borderLeft: `2px solid ${DSW.borderL2}` }}>
<div style={S.cardRow}>
<Dot color={running ? DSW.success : DSW.labelTertiary} />
<span style={S.cardLabel} title={entry.label}>{entry.label}</span>
</div>
<div style={S.cardRow}>
<span style={S.cardSecondary}>
{entry.mode === 'one-shot' ? t('apesAgentOneShot') : t('apesAgentContinuable')}
{' · '}
{running ? t('apesAgentRunning') : t('apesAgentInactive')}
</span>
</div>
</div>
)
})}
</div>
)}
</div>
)
}
/** apes:profile — the current session's identity (title, cwd, running state). */
export function ApesProfileView(props: TabComponentProps) {
const list = useSessionList(props.ctx)
const summary = list.byId[props.scope.sessionId]
const cwd = summary?.cwd ?? props.scope.cwd
return (
<div style={S.body}>
<h2 style={S.title}>{t('apesProfileTitle')}</h2>
{summary === undefined
? <p style={S.hint}>{t('apesProfileNoCwd')}</p>
: (
<div style={S.list}>
<div style={S.card}>
<div style={S.cardRow}>
<Dot color={summary.running === true ? DSW.success : DSW.labelTertiary} />
<span style={S.cardLabel} title={summary.displayTitle}>{t('apesProfileCurrent')}</span>
</div>
<div style={S.cardRow}>
<span style={S.badge}>{summary.running === true ? t('apesProfileRunning') : t('apesProfileIdle')}</span>
</div>
</div>
<div style={S.card}>
<div style={S.cardRow}>
<span style={S.cardLabel}>{summary.displayTitle}</span>
</div>
<div style={S.cardRow}>
<span style={S.cardSecondary}>{t('apesProfileCwd')}: {cwd ?? t('apesProfileNoCwd')}</span>
</div>
</div>
</div>
)}
</div>
)
}
/** apes:apps — no DSH host feed today; shows the current workspace (cwd). */
function ApesAppsView(props: TabComponentProps) {
const list = useSessionList(props.ctx)
const summary = list.byId[props.scope.sessionId]
const cwd = summary?.cwd ?? props.scope.cwd
return (
<div style={S.body}>
<h2 style={S.title}>{t('apesAppsTitle')}</h2>
<p style={S.hint}>{t('apesAppsNoFeed')}</p>
<div style={S.card}>
<div style={S.cardRow}>
<span style={S.cardLabel}>{t('apesAppsWorkspace')}</span>
</div>
<div style={S.cardRow}>
<span style={S.cardSecondary}>{cwd ?? t('apesProfileNoCwd')}</span>
</div>
</div>
</div>
)
}
/**
* The three apes:* tab descriptors (C6: `apes:tasks` removed — overlaps the
* official DSH session tree). Order 901-903: agents/apps/profile. Each is
* already shaped as `TabDescriptor` (component + single-instance + icon
* element + i18n title function) so `registerApesTabs` can hand them straight
* to `service.registerTab`. Information-layer tabs are single-instance.
*/
export const apesTabs: TabDescriptor[] = [
{ id: 'apes:agents', title: () => t('apesAgentsTitle'), icon: (size: number) => <IconAgentOutline16 size={size} />, order: 60, single: true, component: ApesAgentsView },
{ id: 'apes:apps', title: () => t('apesAppsTitle'), icon: (size: number) => <IconAppsOutline16 size={size} />, order: 65, single: true, component: ApesAppsView },
{ id: 'apes:profile', title: () => t('apesProfileTitle'), icon: (size: number) => <IconUserOutline16 size={size} />, order: 70, single: true, component: ApesProfileView },
]
+8 -9
View File
@@ -15,7 +15,7 @@ import { htmlUrl } from '../api.ts'
import { t } from '../locales.ts'
import { baseName } from '../paths.ts'
import { resolveSidebarPath } from '../paths.ts'
import { HTML_IFRAME_SANDBOX } from '../html-preview.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'
@@ -78,19 +78,18 @@ function diffOf(op: FileOp, prior: string | undefined): readonly DiffRow[] {
return []
}
/** The render view of one html op target: the route-src iframe. Extracted
* (and exported) so the always-sandboxed contract is pinned directly by the
* sandbox spec — this surface has NO no-sandbox escape hatch. */
/** 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}>
<iframe
<HtmlPreviewFrame
className={css.htmlFrame}
url={props.src}
sandboxed
title={props.title}
src={props.src}
sandbox={HTML_IFRAME_SANDBOX}
referrerPolicy="no-referrer"
allow=""
/>
</div>
)
+21
View File
@@ -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
}
+46 -1
View File
@@ -1,7 +1,8 @@
/**
* 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.
* both preview iframes load with, plus the document-base rewrite the srcdoc
* delivery path needs.
*/
/**
@@ -13,3 +14,47 @@
* 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, '&quot;')
const at = insertionPoint(html)
return `${html.slice(0, at)}<base href="${href}">${html.slice(at)}`
}
+40 -10
View File
@@ -15,6 +15,7 @@ import { createSidebarStore } from './state.ts'
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 { Sidebar } from './Sidebar.tsx'
import { RenderBoundary } from './RenderBoundary.tsx'
import { createNativeTabRecords } from './native/tab-adapter.tsx'
@@ -25,6 +26,7 @@ import { isTargetAvailable, openInterceptedLink, registerLinkInterception, shoul
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'
@@ -66,10 +68,16 @@ export function apply(ctx: Context): void {
// plugin's dictionaries into the shared locale registry. The disposers
// 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): 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)
const offEn = ctx.locale.register(LOCALE_NS, 'en', en)
return () => { offZh(); offEn() }
const offApesZh = ctx.locale.register(APES_LOCALE_NS, 'zh', apesZh as Record<string, string>)
const offApesEn = ctx.locale.register(APES_LOCALE_NS, 'en', apesEn as Record<string, string>)
return () => { offZh(); offEn(); offApesZh(); offApesEn() }
}, 'dsh-apes-sidebar: dictionaries')
// Opt-in third-language support through @huanlin/dsh-plugin-better-locale.
@@ -200,6 +208,16 @@ export function apply(ctx: Context): void {
() => registerBuiltins(ctx, service),
'dsh-apes-sidebar: register built-in tabs and viewers',
)
// The apes:* information-layer tabs (the fork's own contribution, isolated
// 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',
)
try {
// rc.8+ exposes the client module system as the `ctx.modules` service;
// the chunk loader needs it to resolve its externals, so inject it
@@ -231,17 +249,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
@@ -295,7 +322,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
+1 -1
View File
@@ -639,7 +639,7 @@ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDes
* The plugin version this service instance reports. Keep in lockstep with
* `package.json`'s version — `tests/service.spec.ts` asserts the pair.
*/
export const SIDEBAR_SERVICE_VERSION = '0.24.1'
export const SIDEBAR_SERVICE_VERSION = '1.3.0'
/**
* Monotonic capability list consumers use to gate new API usage (features
+122
View File
@@ -0,0 +1,122 @@
/**
* apes:* tab registration tests (Phase 1/1b, C6: 3 tabs). The fork registers
* its three information-layer tabs (agents / apps / profile) through the same
* BetterSidebarService the upstream builtins use (dogfood). `apes:tasks` was
* removed in C6 — it overlaps the official DSH session tree in the native
* sidebar. The spec pins the registration contract (id / single / order /
* component presence) and the isolated i18n contract (the apes namespace
* resolves zh vs en through the attached locale service, and every apesZh
* key has an apesEn entry).
*
* Runs in the node vitest environment like builtins.spec.ts — no DOM, no
* real DSH host (a live cordis scratch mount is Phase 2+).
*/
import { describe, expect, it } from 'vitest'
// Browser globals before the client graph loads (mirror of builtins.spec.ts).
import './browser-globals.ts'
import type { Context } from '../src/context-types.ts'
import { createBetterSidebarService } from '../src/client/service.ts'
import { createSidebarStore } from '../src/client/state.ts'
import { registerApesTabs } from '../src/client/apes/index.ts'
import { apesZh, apesEn, attachApesLocale, t, type ApesCopyKey } from '../src/client/apes/locales.ts'
function setup(): {
service: ReturnType<typeof createBetterSidebarService>
store: ReturnType<typeof createSidebarStore>
dispose: () => void
} {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const dispose = registerApesTabs({} as Context, service)
return { service, store, dispose }
}
describe('apes:* tab registrations', () => {
it('registers the 3 apes tabs with the apes: namespace', () => {
const { service, dispose } = setup()
try {
expect(service.getTabs().map(t => t.id).sort()).toEqual(
['apes:agents', 'apes:apps', 'apes:profile'],
)
} finally {
dispose()
}
})
it('every apes tab is single-instance, visible, and component-carrying', () => {
const { service, dispose } = setup()
try {
for (const id of ['apes:agents', 'apes:apps', 'apes:profile']) {
const tab = service.getTab(id)
expect(tab, `${id} registered`).toBeDefined()
expect(tab?.single, `${id} single`).toBe(true)
expect(tab?.hidden, `${id} hidden`).not.toBe(true)
expect(tab?.component, `${id} component`).toBeDefined()
expect(tab?.icon, `${id} icon`).toBeDefined()
}
} finally {
dispose()
}
})
it('apes tabs sort after the upstream builtins (order 40+)', () => {
const { service, dispose } = setup()
try {
const orders = {
agents: service.getTab('apes:agents')?.order,
apps: service.getTab('apes:apps')?.order,
profile: service.getTab('apes:profile')?.order,
}
// C8: order 40-50 places apes tabs immediately after the native builtins
// (10-35), not in a far-away 900+ band — visual unity in the tab strip.
expect(orders.agents).toBeGreaterThanOrEqual(40)
expect(orders.apps).toBeGreaterThanOrEqual(40)
expect(orders.profile).toBeGreaterThanOrEqual(40)
// Strictly ordered: agents < apps < profile.
expect(orders.agents!).toBeLessThan(orders.apps!)
expect(orders.apps!).toBeLessThan(orders.profile!)
} finally {
dispose()
}
})
it('the disposer unregisters all apes tabs (HMR-safe)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const dispose = registerApesTabs({} as Context, service)
expect(service.getTabs()).toHaveLength(3)
dispose()
expect(service.getTabs()).toHaveLength(0)
})
})
describe('apes i18n contract', () => {
it('every apesZh key has an apesEn entry (no missing translations)', () => {
const zhKeys = Object.keys(apesZh) as ApesCopyKey[]
for (const key of zhKeys) {
expect(apesEn[key], `en entry for ${key}`).toBeDefined()
expect(typeof apesEn[key]).toBe('string')
expect((apesEn[key] ?? '').length).toBeGreaterThan(0)
}
})
it('resolves zh when the DSH preference is zh', () => {
attachApesLocale({ getSnapshot: () => ({ active: 'zh-CN' }) })
expect(t('apesAgentsTitle')).toBe('智能体')
expect(t('apesAppsTitle')).toBe('应用')
expect(t('apesProfileTitle')).toBe('画像')
})
it('resolves en when the DSH preference is en', () => {
attachApesLocale({ getSnapshot: () => ({ active: 'en' }) })
expect(t('apesAgentsTitle')).toBe('Agents')
expect(t('apesAppsTitle')).toBe('Apps')
expect(t('apesProfileTitle')).toBe('Profile')
})
it('falls back to zh when no locale service is attached', () => {
attachApesLocale(undefined)
expect(t('apesAgentsTitle')).toBe('智能体')
})
})
+95
View File
@@ -0,0 +1,95 @@
/**
* HtmlPreviewFrame delivery tests — the piece that exists ONLY because of the
* DSH Desktop renderer fence: the host refuses `/sidebar/html/…` to any
* request without the Electron renderer capability, which a frame sandboxed
* without allow-same-origin (an opaque origin) can never carry. The document
* is therefore fetched by the MAIN frame and handed over through `srcdoc`,
* with the sandbox attribute untouched.
*
* Mounted client-side (createRoot + act) because the delivery path is an
* effect; the sandbox-contract assertions live in sandbox-views.spec.tsx.
*/
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { act } from 'react-dom/test-utils'
import { HtmlPreviewFrame } from '../src/client/HtmlPreviewFrame.tsx'
import { HTML_IFRAME_SANDBOX } from '../src/client/html-preview.ts'
import { renderRoot, setupReactAct } from './test-utils.ts'
setupReactAct()
const URL_ = '/sidebar/html/s1/p/a/index.html'
const DOC = '<html><head><title>t</title></head><body><h1>hi</h1></body></html>'
/** Stub the global fetch with one canned response. */
function stubFetch(text: string, ok = true): void {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok, status: ok ? 200 : 403, text: async () => text })))
}
/** Flush the fetch's microtasks + the React commit they trigger. */
async function flush(): Promise<void> {
await act(async () => { await new Promise(resolve => { setTimeout(resolve, 0) }) })
}
afterEach(() => { vi.unstubAllGlobals() })
describe('HtmlPreviewFrame delivery', () => {
it('starts empty, never pointing the frame at the route itself', () => {
stubFetch(DOC)
const r = renderRoot(createElement(HtmlPreviewFrame, { url: URL_, sandboxed: true, title: 'index.html' }))
const iframe = r.container.querySelector('iframe')!
expect(iframe.getAttribute('srcdoc')).toBe('')
expect(iframe.getAttribute('src')).toBe(null)
// The boundary is unchanged while the document is in flight.
expect(iframe.getAttribute('sandbox')).toBe(HTML_IFRAME_SANDBOX)
r.unmount()
})
it('hands the fetched markup to the frame through srcdoc, base-rewritten, sandbox kept', async () => {
stubFetch(DOC)
const r = renderRoot(createElement(HtmlPreviewFrame, { url: URL_, sandboxed: true, title: 'index.html' }))
await flush()
const iframe = r.container.querySelector('iframe')!
const srcdoc = iframe.getAttribute('srcdoc')
expect(srcdoc).toContain('<base href="/sidebar/html/s1/p/a/">')
expect(srcdoc).toContain('<h1>hi</h1>')
// Still no route src: the opaque frame must not be asked to fetch.
expect(iframe.getAttribute('src')).toBe(null)
expect(iframe.getAttribute('sandbox')).toBe(HTML_IFRAME_SANDBOX)
expect(HTML_IFRAME_SANDBOX).not.toContain('allow-same-origin')
r.unmount()
})
it('drops the sandbox attribute for the warned unlock path (the frame inherits the GUI origin)', async () => {
stubFetch(DOC)
const r = renderRoot(createElement(HtmlPreviewFrame, { url: URL_, sandboxed: false, title: 'index.html' }))
await flush()
const iframe = r.container.querySelector('iframe')!
expect(iframe.getAttribute('sandbox')).toBe(null)
expect(iframe.getAttribute('srcdoc')).toContain('<h1>hi</h1>')
r.unmount()
})
it('falls back to the route URL when the fetch fails, so the route still answers', async () => {
stubFetch('', false)
const r = renderRoot(createElement(HtmlPreviewFrame, { url: URL_, sandboxed: true, title: 'index.html' }))
await flush()
const iframe = r.container.querySelector('iframe')!
expect(iframe.getAttribute('src')).toBe(URL_)
expect(iframe.getAttribute('srcdoc')).toBe(null)
r.unmount()
})
it('refetches when the previewed file changes', async () => {
stubFetch('<h1>one</h1>')
const r = renderRoot(createElement(HtmlPreviewFrame, { url: URL_, sandboxed: true, title: 'a.html' }))
await flush()
expect(r.container.querySelector('iframe')!.getAttribute('srcdoc')).toContain('<h1>one</h1>')
stubFetch('<h1>two</h1>')
r.rerender(createElement(HtmlPreviewFrame, { url: '/sidebar/html/s1/p/a/two.html', sandboxed: true, title: 'b.html' }))
await flush()
expect(r.container.querySelector('iframe')!.getAttribute('srcdoc')).toContain('<h1>two</h1>')
r.unmount()
})
})
+51
View File
@@ -0,0 +1,51 @@
/**
* HTML preview vocabulary tests: the srcdoc delivery path needs the previewed
* document's relative references to resolve back into its own
* `/sidebar/html/…` route (a srcdoc document has no URL of its own — the
* browser would otherwise resolve `./style.css` against the GUI page).
*
* Pure module, plain node environment.
*/
import { describe, expect, it } from 'vitest'
import { previewBaseOf, withPreviewBase } from '../src/client/html-preview.ts'
const URL_ = '/sidebar/html/s1/p/a/index.html'
describe('previewBaseOf', () => {
it('keeps everything up to the last slash', () => {
expect(previewBaseOf(URL_)).toBe('/sidebar/html/s1/p/a/')
})
it('keeps the UNC double slash intact (the route encodes it that way)', () => {
expect(previewBaseOf('/sidebar/html/s1//server/share/a.html')).toBe('/sidebar/html/s1//server/share/')
})
it('returns the input unchanged when it has no slash', () => {
expect(previewBaseOf('s1')).toBe('s1')
})
})
describe('withPreviewBase', () => {
it('injects the base as the first child of <head>, ahead of the document’s own elements', () => {
const out = withPreviewBase('<html><head><title>t</title></head><body></body></html>', URL_)
expect(out).toBe('<html><head><base href="/sidebar/html/s1/p/a/"><title>t</title></head><body></body></html>')
})
it('falls back to <html> when the document declares no head', () => {
const out = withPreviewBase('<html><body>x</body></html>', URL_)
expect(out).toBe('<html><base href="/sidebar/html/s1/p/a/"><body>x</body></html>')
})
it('prepends for a headless fragment', () => {
expect(withPreviewBase('<h1>x</h1>', URL_)).toBe('<base href="/sidebar/html/s1/p/a/"><h1>x</h1>')
})
it('leaves a document that declares its own base alone (the author owns it)', () => {
const doc = '<head><base href="https://example.com/"></head>'
expect(withPreviewBase(doc, URL_)).toBe(doc)
})
it('quotes the injected href', () => {
expect(withPreviewBase('<head></head>', '/x/"y/')).toBe('<head><base href="/x/&quot;y/"></head>')
})
})
+41
View File
@@ -0,0 +1,41 @@
/**
* Panel-host parenting tests — the layering half of a real defect: on DSH
* Desktop's framed modes the shell tags 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 overlay layer
* (`.dshDesktopOverlay`, z-index 1000 — where a plugin's modal renders, e.g.
* the community market's). A host parented to `document.body` sits outside
* that context, so the panel layer's documented 25 outranked every overlay
* inside it and covered host modals.
*
* jsdom: the resolution is a DOM query, so the stub-globals spec style used
* by desktop-env.spec.ts cannot exercise it.
*/
// @vitest-environment jsdom
import { beforeEach, describe, expect, it } from 'vitest'
import { panelHostParent } from '../src/client/desktop-env.ts'
beforeEach(() => { document.body.innerHTML = '' })
describe('panelHostParent', () => {
it('falls back to the body when the shell framed no content viewport', () => {
document.body.innerHTML = '<div id="root"></div>'
expect(panelHostParent(document)).toBe(document.body)
})
it('falls back to the body when there is no root at all (plain web)', () => {
expect(panelHostParent(document)).toBe(document.body)
})
it('resolves the framed content viewport when the shell stamped one', () => {
document.body.innerHTML = '<div id="root" data-dsh-desktop-content-viewport=""><div class="dshDesktopFrame"></div></div>'
const root = document.getElementById('root')
expect(panelHostParent(document)).toBe(root)
// The host lands INSIDE the shell's stacking context — beside the overlay
// layer the shell renders there, and never as a body-level sibling.
const host = document.createElement('div')
panelHostParent(document).appendChild(host)
expect(root?.contains(host)).toBe(true)
expect(document.body.lastElementChild).toBe(root)
})
})
+12 -7
View File
@@ -50,9 +50,13 @@ describe('HTML preview iframe sandbox', () => {
// ...which must never contain the dangerous tokens.
expect(HTML_IFRAME_SANDBOX).not.toContain('allow-same-origin')
expect(HTML_IFRAME_SANDBOX).not.toContain('allow-top-navigation')
// Cross-origin framing by construction: route-src (never srcdoc).
expect(iframe).toContain('src="/sidebar/html/s1/p/a/index.html"')
expect(iframe).not.toContain('srcdoc=')
// Opaque-origin framing by construction: the document arrives through
// srcdoc (fetched by the MAIN frame, the only one the host trusts with
// the /sidebar route on DSH Desktop) and the frame is never pointed at
// the route itself. Empty until that fetch resolves. (The server
// renderer passes srcDoc through camelCase, like referrerPolicy below.)
expect(iframe).toContain('srcDoc=""')
expect(iframe).not.toContain('src=')
// Referrer + permissions policy stay locked even when sandboxed.
// (React SSR renders the referrerPolicy prop camelCase as written.)
expect(iframe).toContain('referrerPolicy="no-referrer"')
@@ -126,7 +130,7 @@ describe('changes tab HTML render preview sandbox', () => {
}
}
it('render iframe is ALWAYS sandboxed (no escape hatch) with the route src, never srcdoc', () => {
it('render iframe is ALWAYS sandboxed (no escape hatch) and delivered through srcdoc', () => {
const html = renderToString(createElement(HtmlRenderPreview, { src: '/sidebar/html/s1/p/a/index.html', title: '/p/a/index.html' }))
const iframe = /<iframe[^>]*>/.exec(html)?.[0]
expect(iframe).toBeDefined()
@@ -135,9 +139,10 @@ describe('changes tab HTML render preview sandbox', () => {
expect(iframe).toContain(`sandbox="${HTML_IFRAME_SANDBOX}"`)
expect(HTML_IFRAME_SANDBOX).not.toContain('allow-same-origin')
expect(HTML_IFRAME_SANDBOX).not.toContain('allow-top-navigation')
// Cross-origin framing by construction: route-src (never srcdoc).
expect(iframe).toContain('src="/sidebar/html/s1/p/a/index.html"')
expect(iframe).not.toContain('srcdoc=')
// Same delivery path as the editor viewer: main-frame fetch -> srcdoc,
// never a route src the opaque frame could not fetch itself.
expect(iframe).toContain('srcDoc=""')
expect(iframe).not.toContain('src=')
expect(iframe).toContain('referrerPolicy="no-referrer"')
expect(iframe).toContain('allow=""')
})