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:
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+341
-508
File diff suppressed because it is too large
Load Diff
Executable
+294
@@ -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)使新副本生效。"
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 },
|
||||
]
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, '"')
|
||||
const at = insertionPoint(html)
|
||||
return `${html.slice(0, at)}<base href="${href}">${html.slice(at)}`
|
||||
}
|
||||
|
||||
+40
-10
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('智能体')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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/"y/"></head>')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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=""')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user