Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6ef4d544a | ||
|
|
5d2d6e5801 | ||
|
|
30d0b103f6 | ||
|
|
8ad1766390 | ||
|
|
648ff27b5f | ||
|
|
1e7c72ccbe | ||
|
|
5c27882995 | ||
|
|
ad787ab22d | ||
|
|
ae71aecb83 | ||
|
|
1a0d530711 | ||
|
|
29f162dac0 | ||
|
|
5bd961f7f1 | ||
|
|
f842fa0029 | ||
|
|
0b5405117d | ||
|
|
2bace68af8 | ||
|
|
f903ac77d6 | ||
|
|
33ee02e747 | ||
|
|
7ee0b457a3 | ||
|
|
d478780e43 | ||
|
|
76fa7df264 | ||
|
|
acd4a8313c | ||
|
|
cb6f2cdc8f | ||
|
|
3825fced25 |
@@ -4,6 +4,14 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
# Cancel a superseded PR run (it is on a stale commit); never cancel a
|
||||
# push run — it is already producing the post-merge signal. The mount job
|
||||
# boots a real DSH + Chromium, so the savings matter there most.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
@@ -41,3 +49,57 @@ jobs:
|
||||
# shipped lib/types. Runs AFTER build (it reads the built output).
|
||||
- name: Check consumer type surface
|
||||
run: pnpm check:consumer-types
|
||||
|
||||
# Pack the plugin as an npm tarball, mount it into a REAL DSH instance
|
||||
# through the official `dsh plugin --profile web add` channel, and render
|
||||
# the DSH web UI headlessly (Playwright Chromium) to prove the plugin
|
||||
# mounts without crashing the shell. Drives `pnpm test:mount`, which boots
|
||||
# a keyless `dsh web` against a scratch profile and sweeps every built-in
|
||||
# sidebar tab. No API key needed — the whole lane is deterministic.
|
||||
plugin-mount:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# CI runs must never report to the production telemetry endpoint baked
|
||||
# into the DSH composition (same guard DSH's own CI uses).
|
||||
env:
|
||||
DSH_TELEMETRY_DISABLED: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 11
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Pin the DSH CLI the lane mounts into; the profile's bundle stack
|
||||
# (dsh-base / dsh-web-app) resolves from this installation. Keep in
|
||||
# step with the plugin's @deepseek-ai/* peer range when bumping.
|
||||
- name: Install dsh CLI (pinned)
|
||||
run: npm install -g @deepseek-ai/dsh@0.1.0-rc.6
|
||||
|
||||
# lib/ is gitignored; the tarball is produced from this build.
|
||||
- name: Build and pack the npm tarball
|
||||
run: pnpm build && pnpm pack
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Mount + headless-render smoke
|
||||
run: pnpm test:mount
|
||||
|
||||
- name: Upload Playwright report on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
playwright-report/
|
||||
test-results/
|
||||
if-no-files-found: ignore
|
||||
|
||||
@@ -149,3 +149,8 @@ registry/
|
||||
|
||||
# stray npm cache scratch dir
|
||||
.npm-cache-tmp/
|
||||
|
||||
# Playwright headless-render lane (tests/e2e) artifacts
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
|
||||
@@ -9,9 +9,20 @@ better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context
|
||||
## 0. 仓库硬约束(所有本仓库插件必须遵守)
|
||||
|
||||
- **禁止修改 DeepSeek Harness (DSH) 源码**:对官方源码 checkout(`~/.dsh/source/current`)零写入——不得改 harness 包、不得把 harness 改动提交到它的分支。
|
||||
- **代码改动必须走 PR**:功能 / 修复 / 测试等非文档改动一律在分支上开发(`feat/*` / `fix/*`),用 `gh pr create` 发起 PR,review 合并后才进 main;**仅纯文档类改动**(README / AGENTS.md / docs/ 等)允许直接推送到 main。
|
||||
- **挂载只走 `cordis.patch.yml` + profile 机制**(`~/.dsh/profiles/<profile>/`),插件永远作为独立包被 profile 引用,不反向侵入 DSH。
|
||||
- 需要 harness 没有的能力时,用 DSH **现成的只读/公开 API** 或插件自有路由实现(参考 §7 的 `jobs.output` 事件回放:读会话事件日志而非动注册表);如果确实做不到,先向用户说明取舍,而不是直接改 DSH。
|
||||
|
||||
### CI 挂载冒烟(`plugin-mount` job / `pnpm test:mount`)
|
||||
|
||||
仓库的 CI 有一条「npm 打包 → 真实挂载 → 无头渲染」门禁(`.github/workflows/ci.yml` 的 `plugin-mount` job),证明**打包产物**在真实 DSH 上挂载后无头渲染不会 crash:
|
||||
|
||||
1. `pnpm build && pnpm pack` 产出 tarball(与发布产物一致)。
|
||||
2. `scripts/e2e-mount.sh` 用官方 CLI 把它装进一个**全新 scratch profile**(`dsh plugin --profile web add file:<tarball>`,触发 `dsh.profile.bundles` 协调),然后启动真实 `dsh web`(keyless,`--port 0`)。
|
||||
3. `tests/e2e/mount.e2e.ts`(Playwright Chromium)加载页面,断言外壳与 `[data-dsh-better-sidebar]` 挂载、无 `dsh-better-sidebar:` 错误条、无 pageerror/插件 console 错误,并通过「+ 菜单」逐个打开内置 tab(含终端懒加载 chunk)深扫,再经 Explorer 打开 seed 文件强制加载 editor 懒加载 chunk(`client-editor.js`)——缺失的内置 tab 或 chunk 都会使门禁变红。
|
||||
|
||||
本地跑:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`(需 PATH 上有 `dsh` 或可经 npx 拉取)。DSH CLI 版本在 CI 钉住 `@deepseek-ai/dsh@0.1.0-rc.6`(与插件 peer 范围同步)。`tests/e2e` 的 spec 命名 `*.e2e.ts` + vitest `exclude` 双保险与 vitest 隔离;**改动 vitest `exclude` 时必须保留默认排除项**(`exclude` 会整体替换默认值)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 服务定位
|
||||
@@ -134,6 +145,16 @@ interface TabDescriptor {
|
||||
* 省略时用默认 { id, type, title } + seed 里的 path/diff。
|
||||
*/
|
||||
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
|
||||
/**
|
||||
* 外链点击目标认领(v0.13.0+):聊天/界面外链被接管(`browserInterceptLinks`
|
||||
* 总闸 + URL 对应协议开关均开)时,第一个 `urlTarget(url)` 命中且未被设置
|
||||
* 禁用的注册 tab 类型以 `openTab({ type, url, title: hostname })` 打开——
|
||||
* URL 即全部载荷(tab 从 `tab.path` 读取)。注册顺序先到先得;谓词抛错被
|
||||
* 吞掉(跳过该类型)。内置 browser 不声明 urlTarget,永远是隐式兜底,不会
|
||||
* 遮蔽插件声明。要同时容纳多个 URL 需用 createTab 铸造 per-URL id(browser
|
||||
* 同款模式);否则同类型二次点击被 id 安全网聚焦,新 URL 不会覆写既有 path。
|
||||
*/
|
||||
urlTarget?: (url: URL) => boolean
|
||||
/**
|
||||
* 声明式设置(v0.4.1+):每个注册的 tab 都会在 Side card 设置页获得一行
|
||||
* 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套设置行,
|
||||
@@ -146,7 +167,7 @@ interface TabDescriptor {
|
||||
*/
|
||||
settings?: {
|
||||
toggles?: readonly {
|
||||
/** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'terminalFontFamily' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks') */
|
||||
/** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'terminalFontFamily' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks' / 'browserInterceptHttp' / 'browserInterceptHttps') */
|
||||
key: string
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
@@ -278,6 +299,24 @@ ctx.effect(() =>
|
||||
)
|
||||
```
|
||||
|
||||
**认领外链点击**(v0.13.0+,`features.includes('urlTarget')` gate):聊天/界面里被拦截的 HTTP(S) 外链(协议开关开启时)路由到第一个 `urlTarget` 命中的 tab 类型,URL 作为 seed 预填 `tab.path`;内置浏览器是隐式兜底。多 URL 并存需用 `createTab` 铸造 per-URL id(browser 同款模式),否则同类型二次点击聚焦既有 tab、不覆写 path:
|
||||
```ts
|
||||
ctx.effect(() => {
|
||||
if (!ctx.betterSidebar.features.includes('urlTarget')) return
|
||||
return ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:web-docs',
|
||||
title: () => 'Docs',
|
||||
order: 80,
|
||||
urlTarget: (url) => url.hostname === 'docs.my-site.com', // 只有该域名被点击时认领
|
||||
createTab: (state) => ({
|
||||
tab: { id: `my-plugin:web-docs:${state.nextBrowser}`, type: 'my-plugin:web-docs', title: 'Docs' },
|
||||
patch: { nextBrowser: state.nextBrowser + 1 },
|
||||
}),
|
||||
component: ({ tab, scope }) => <WebDocsView url={tab.path} sessionId={scope.sessionId} />,
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 3.4 内置 tab(不可重复注册)
|
||||
|
||||
| id | order | single | hidden | 用途 |
|
||||
@@ -462,7 +501,7 @@ interface BetterSidebarService {
|
||||
readonly version: string
|
||||
/** 单调能力清单(只增不删):'badge' | 'tabLifecycle' | 'updateTab' |
|
||||
* 'openFile' | 'targetedOpen' | 'stateSubscription' | 'tabMeta' |
|
||||
* 'pluginSettings'——消费插件用 `features.includes('xxx')` 按能力 gate。 */
|
||||
* 'pluginSettings' | 'urlTarget'——消费插件用 `features.includes('xxx')` 按能力 gate。 */
|
||||
readonly features: readonly string[]
|
||||
/** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。
|
||||
* session 未激活时 state/sessionId 为 undefined。 */
|
||||
@@ -493,7 +532,7 @@ interface OpenTabSeed {
|
||||
}
|
||||
```
|
||||
|
||||
> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`、终端的 `terminalFontFamily`/`terminalFontSize`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑——`type: 'switch'` 行是复选框,`type: 'text'`/`'number'` 行是输入框(v0.11.0+)——父级卡片关闭时齿轮隐藏;`settings.toggles` 的 **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks`)。**v0.12.0 起设置 seam 已开放**:外部插件用 `settings.pluginToggles`(同款行控件,key 插件局部)或 `settings.render`(自定义面板)声明自己的设置,值持久化在 prefs 文档的 `pluginSettings[<descriptor id>]`(开放 map,宿主 schema 已有字段,无需注册)——齿轮弹窗对 tab 与 viewer 都可用(viewer 卡片 v0.12.0 起也有齿轮)。
|
||||
> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`、终端的 `terminalFontFamily`/`terminalFontSize`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑——`type: 'switch'` 行是复选框,`type: 'text'`/`'number'` 行是输入框(v0.11.0+)——父级卡片关闭时齿轮隐藏;`settings.toggles` 的 **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks` / `browserInterceptHttp` / `browserInterceptHttps`)。**v0.12.0 起设置 seam 已开放**:外部插件用 `settings.pluginToggles`(同款行控件,key 插件局部)或 `settings.render`(自定义面板)声明自己的设置,值持久化在 prefs 文档的 `pluginSettings[<descriptor id>]`(开放 map,宿主 schema 已有字段,无需注册)——齿轮弹窗对 tab 与 viewer 都可用(viewer 卡片 v0.12.0 起也有齿轮)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,49 +1,52 @@
|
||||
# dsh-better-sidebar
|
||||
|
||||
<!-- 头部介绍区(HTML 排版) -->
|
||||
<!-- Hero -->
|
||||
<div align="center">
|
||||
<b style="font-size: 1.15em;">一个插件,一套完整工作台</b><br /><br />
|
||||
<code>文件管理</code> <code>编辑预览</code> <code>内嵌浏览器</code> <code>真实终端</code> <code>Git 面板</code> <code>后台任务页</code><br /><br />
|
||||
<b>右侧栏 + 底部面板双工作台</b>,一个插件全部搞定。<br />
|
||||
<small>支持 Tab 窗口随意拖拽,支持三方拓展注册新 Tab 页面和文件预览</small>
|
||||
<b style="font-size: 1.15em;">一个服务化的侧边栏框架,一套开箱即用的完整工作台</b><br /><br />
|
||||
<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 />
|
||||
<img alt="文件管理" src="https://img.shields.io/badge/-文件管理-4d6bfe" /> <img alt="编辑预览" src="https://img.shields.io/badge/-编辑预览-4d6bfe" /> <img alt="内嵌浏览器" src="https://img.shields.io/badge/-内嵌浏览器-4d6bfe" /> <img alt="真实终端" src="https://img.shields.io/badge/-真实终端-4d6bfe" /> <img alt="Git 面板" src="https://img.shields.io/badge/-Git%20面板-4d6bfe" /> <img alt="后台任务" src="https://img.shields.io/badge/-后台任务-4d6bfe" /> <img alt="插件接入" src="https://img.shields.io/badge/-插件接入-4d6bfe" /><br /><br />
|
||||
<b>右侧栏 + 底部面板双工作台</b>,并把 <code>ctx.betterSidebar</code> 服务开放给所有插件——<br />
|
||||
通过 <code>registerTab</code> / <code>registerFileViewer</code> 注册新的侧边栏页面与文件预览器。
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
🌏 <a href="./README.md"><b>中文</b></a> · <a href="./README_EN.md">English</a>
|
||||
</div>
|
||||
|
||||
https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86
|
||||
|
||||
<img width="2630" height="1794" alt="6c4293e1bec2e935031bf0e986d6ec65" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
|
||||
|
||||
## 🆕 最近更新
|
||||
|
||||
<small>v0.12.1</small>
|
||||
|
||||
| 功能 | 说明 | 截图 |
|
||||
|---|---|---|
|
||||
| 🔌 服务化基座 | 完整类型导出 + `version`/`features` 能力探测、状态订阅(`getSnapshot`/`subscribeState`)、tab 角标、`onOpen`/`onActivate`/`onClose` 生命周期回调、`updateTab`/`activateTab`/`openFile`、定向打开、`meta` 跨刷新持久化、插件自有设置(`pluginToggles`/`render`) | |
|
||||
| ➕ 添加插件 | 设置页「推荐插件目录」+ 一键复制安装命令;内置 Office 预览迁至推荐插件 | |
|
||||
| 🖱️ 标签页滚轮 | 标签页栏支持鼠标滚轮横向滚动 | |
|
||||
| 🐛 修复 | 远程访问 403(信任栅栏改用 `trustedHosts`)、侧边栏崩溃 [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31)、Windows 下 HTML 预览盘符路径 | |
|
||||
<div align="center">
|
||||
<video src="https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86" muted autoplay loop playsinline controls width="100%"></video>
|
||||
<img alt="dsh-better-sidebar 工作台截图" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
|
||||
</div>
|
||||
|
||||
## ✨ 功能一览
|
||||
|
||||
- **🗂️ 资源管理器**:懒加载目录树(根 = 会话 cwd),点击打开、`@文件` 引用、右键复制路径
|
||||
- **📝 编辑与预览**:CodeMirror 6 编辑(Ctrl/Cmd+S 原子保存,切 Tab 不丢草稿);图片 / Markdown / HTML / PDF / Word / Excel / PPT 内联预览(HTML 走沙箱 iframe)
|
||||
- **⚡ 客户端懒加载**:启动只拉 ~325KB 核心,终端 / 编辑器等重依赖用到才按需拉取(详见 `docs/plans/2026-08-12-lazy-chunks-design.md`)
|
||||
- **🌐 浏览器**:多开内嵌网页 tab,后退/前进/刷新;内容运行在沙箱 iframe(无法访问界面数据与本地文件,拒绝本机地址),可临时解锁(红色警示);被站点拒绝嵌入时显示原因;外链默认在侧边栏打开
|
||||
- **💻 终端**:xterm.js + node-pty 真实 shell、断线重连回放;可选为模型注入 `terminal_*` 工具;支持自定义字体(字体族 + 9–32px 字号,实时生效)
|
||||
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存/提交/还原等
|
||||
- **🧩 后台任务页**:主会话 agent 拓扑、点击直达执行记录;同页显示后台任务(类型徽标 + 退出码、实时输出 peek、可强制终止)
|
||||
- **🪟 底部面板**:第二个独立工作台,只挤占中间输出区;首次展开自动开终端(可关)
|
||||
- **📱 移动端**:窄屏(<768px)自动合并为全宽抽屉,底部面板标签页并入右侧栏;点文件/外链自动展开
|
||||
- **🔧 分栏工作台**:拖 Tab 拆分/合并(可跨面板)、分隔线调比例;右上角按钮一键折叠/展开面板
|
||||
- **🔁 会话隔离**:布局 / Tab / 面板状态按会话持久化,陈旧状态自动净化;「产出文件」在侧边栏打开
|
||||
- **⚙️ 声明式设置**:设置页「侧边卡片」按注册表渲染开关网格,逐项独立开/关;二级设置(自动展开、终端工具、沙箱等)经齿轮弹窗编辑
|
||||
- **🔌 服务化(基座)**:暴露 `ctx.betterSidebar`,其他插件可注册 tab 与文件预览器(内置 7 tab + 9 viewer 同走一服务);v0.12.1 起支持角标/生命周期回调/状态订阅/定向打开/插件自有设置(见 [AGENTS.md](./AGENTS.md) 与 [外部插件接入指南](./docs/external-plugin-guide.md))
|
||||
- **➕ 添加插件**:设置页「侧边卡片」两个网格(侧边栏内容 / 文件预览)末尾的虚线卡片分别打开 Tab / 预览插件弹窗:声明扩展点、「在 GitHub 上浏览更多插件」按钮、推荐插件目录(「跳转」直达仓库、「复制」安装命令到剪贴板);弹窗不打开终端、无失败路径,不阻塞启动与侧边栏
|
||||
- **🌏 多语言**:界面文案跟随 DSH 语言(zh/en)实时切换,无需刷新
|
||||
- **🗂️ 文件工作台**:资源管理器(懒加载目录树)+ CodeMirror 编辑器;图片 / Markdown / HTML / PDF / Office 内联预览
|
||||
- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整)
|
||||
- **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具
|
||||
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原
|
||||
- **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止)
|
||||
- **🪟 双工作台**:右侧栏 + 底部面板;拖 Tab 拆分 / 合并分栏(可跨面板),移动端自动合并全宽抽屉
|
||||
- **🔁 会话隔离**:布局 / Tab / 面板按会话持久化,陈旧状态自动净化
|
||||
- **⚙️ 声明式设置**:设置页「侧边卡片」逐项独立开关,二级设置经齿轮弹窗
|
||||
- **⚡ 按需加载**:启动只拉 ~325KB 核心,终端 / 编辑器等重依赖用到才按需拉取([设计文档](docs/plans/2026-08-12-lazy-chunks-design.md))
|
||||
- **🌏 多语言**:界面文案跟随 DSH 语言(zh / en)实时切换
|
||||
|
||||
> 🔌 **核心理念**:服务优先——内置的 7 tab + 6 viewer 与第三方插件通过同一套 `ctx.betterSidebar` API 注册,能力完全对等;官方不再内置、可由生态提供的功能,交由生态插件实现。接入文档见下方「🔌 服务化」与 [外部插件接入指南](./docs/external-plugin-guide.md)。
|
||||
|
||||
## 🆕 最近更新
|
||||
|
||||
<small>v0.12.2</small>
|
||||
|
||||
> 📝 **说明**:本版新增「位置兼容模式」——为 Windows 右上角原生标题栏预留顶部空间,侧边栏整体下移,距离可在齿轮弹窗自定义(0–120px)。随 0.12.0 起的服务化基座一同发布:能力探测、状态订阅、tab 角标、生命周期回调、外链认领(`urlTarget`)与插件自有设置均已就绪,第三方插件可深度接入;设置页新增「添加插件」推荐目录。详细条目见下表。
|
||||
|
||||
| 功能 | 说明 | 截图 |
|
||||
|---|---|---|
|
||||
| 📐 位置兼容模式 | 设置页新增「位置兼容模式」开关:为 Windows 右上角原生标题栏预留顶部空间,侧边栏按钮与内容整体下移(默认关闭);下移距离可在齿轮弹窗中自定义(0–120px) | |
|
||||
| 🔌 服务化基座 | 完整类型导出 + `version`/`features` 能力探测、状态订阅(`getSnapshot`/`subscribeState`)、tab 角标、`onOpen`/`onActivate`/`onClose` 生命周期回调、`updateTab`/`activateTab`/`openFile`、定向打开、`meta` 跨刷新持久化、插件自有设置(`pluginToggles`/`render`)、外链点击目标认领(`urlTarget`) | <a href="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0"><img width="800" alt="服务化基座截图" src="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0" /></a> |
|
||||
| ➕ 添加插件 | 设置页「推荐插件目录」+ 一键复制安装命令;内置 Office 预览迁至推荐插件 | <a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="800" alt="添加插件截图" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a> |
|
||||
| 🖱️ 标签页滚轮 | 标签页栏支持鼠标滚轮横向滚动 | |
|
||||
| 🐛 修复 | 远程访问 403(信任栅栏改用 `trustedHosts`)、侧边栏崩溃 [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31)、Windows 下 HTML 预览盘符路径 | |
|
||||
|
||||
## 🚀 安装
|
||||
|
||||
@@ -68,10 +71,10 @@ irm https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/
|
||||
|
||||
```sh
|
||||
# macOS / Linux
|
||||
curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash -s 0.12.1 --restart
|
||||
curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash -s 0.12.2 --restart
|
||||
|
||||
# Windows PowerShell
|
||||
& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.12.1 -Restart
|
||||
& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.12.2 -Restart
|
||||
```
|
||||
|
||||
不确定的话,可先加 `--dry-run`(PowerShell 用 `-DryRun`)预览步骤再执行。
|
||||
@@ -97,7 +100,7 @@ minimumReleaseAgeExclude:
|
||||
- dsh-better-sidebar
|
||||
EOF
|
||||
|
||||
# ③ 安装并自动挂载(不带 @版本 = npm 的 latest;固定版本写 dsh-better-sidebar@0.12.1)
|
||||
# ③ 安装并自动挂载(不带 @版本 = npm 的 latest;固定版本写 dsh-better-sidebar@0.12.2)
|
||||
npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar
|
||||
```
|
||||
|
||||
@@ -128,7 +131,7 @@ npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sideba
|
||||
3. 执行 `dsh plugin --profile web add dsh-better-sidebar`:登记依赖 → 识别包内 `dsh.bundle.patch` → 自动注册进 `dsh.profile.bundles` 挂载;
|
||||
4. 清理旧版残留的手动挂载行,避免「双挂载」(页面出现两个侧边栏)。
|
||||
|
||||
`curl | bash` / `irm | iex` 会执行远程代码——脚本已随仓库开源(`scripts/install.sh` / `scripts/install.ps1`),可先下载审阅。插件以 npm 包 `dsh-better-sidebar@0.12.1` 发布,通过 `dsh.bundle.patch`(随包的 `cordis.patch.yml`)由官方 CLI 自动挂载,**不修改 DSH 源码**。
|
||||
`curl | bash` / `irm | iex` 会执行远程代码——脚本已随仓库开源(`scripts/install.sh` / `scripts/install.ps1`),可先下载审阅。插件以 npm 包 `dsh-better-sidebar@0.12.2` 发布,通过 `dsh.bundle.patch`(随包的 `cordis.patch.yml`)由官方 CLI 自动挂载,**不修改 DSH 源码**。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -174,7 +177,7 @@ dsh plugin --profile web add dsh-better-sidebar
|
||||
5. 硬刷新浏览器(Cmd/Ctrl+Shift+R)即可看到效果(client 改动无需重启 DSH;host 半改动才需重启)
|
||||
```
|
||||
|
||||
更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.12.1"` 再 `pnpm install`。
|
||||
更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.12.2"` 再 `pnpm install`。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -208,7 +211,7 @@ dsh registry enable dsh-external/dsh-better-sidebar
|
||||
|
||||
## 🔌 服务化:注册 tab 与文件预览器
|
||||
|
||||
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 7 tab + 9 viewer 也走同一服务,吃自己的狗粮):
|
||||
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 7 tab + 6 viewer 亦通过同一服务注册):
|
||||
|
||||
```ts
|
||||
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
|
||||
@@ -268,3 +271,4 @@ Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元
|
||||
- [dsh-tianshu-tui](https://github.com/huiliyi37/dsh-tianshu-tui):DeepSeek Harness 交互式终端 UI 插件(渲染核心由自研 harness agent Tianshu-Tui 演进而来),在官方基础上增加 TDD 与证据门等工作流
|
||||
- [dsh-TUI](https://github.com/ccch1mneyyy/dsh-TUI):Claude Code 风格全屏交互终端插件——像素鲸鱼顶栏、实时工作状态行、思考流式展开、双击 Esc 回滚、上下文进度条 + TPS 仪表,npm 一键安装
|
||||
- [dshfind 插件超市](https://dshfind.com/zh/plugins):三方插件市场——GitHub topic `dsh-plugin` 下的公开仓库清单,每日同步 star、贡献者与增长数据
|
||||
- [DeepSeek Harness Desktop](https://github.com/anywhere-labs/deepseek-harness-desktop):为 DeepSeek Harness 生态打造的现代化桌面端——无需配置 Node.js 或执行命令即可启动和管理本地 Harness 服务;[官网](https://www.dshdesktop.cn)
|
||||
|
||||
+45
-41
@@ -1,49 +1,52 @@
|
||||
# dsh-better-sidebar
|
||||
|
||||
<!-- Hero (HTML layout) -->
|
||||
<!-- Hero -->
|
||||
<div align="center">
|
||||
<b style="font-size: 1.15em;">One plugin, one complete workbench</b><br /><br />
|
||||
<code>File management</code> <code>Edit & preview</code> <code>Embedded browser</code> <code>Real terminal</code> <code>Git panel</code> <code>Background tasks</code><br /><br />
|
||||
<b>Dual workbench: right sidebar + bottom panel</b>, all in one plugin.<br />
|
||||
<small>Tabs are freely draggable; third-party plugins can register new tab pages and file viewers</small>
|
||||
<b style="font-size: 1.15em;">A service-oriented sidebar framework, and a complete workbench out of the box</b><br /><br />
|
||||
<a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg" /></a>
|
||||
<a href="https://dshfind.com/en/plugins/omdsh-dev/DSH-better-sidebar?ref=badge"><img alt="dshfind" src="https://dshfind.com/api/badge/omdsh-dev/DSH-better-sidebar?lang=en" /></a><br /><br />
|
||||
<img alt="File management" src="https://img.shields.io/badge/-File%20management-4d6bfe" /> <img alt="Edit & preview" src="https://img.shields.io/badge/-Edit%20%26%20preview-4d6bfe" /> <img alt="Embedded browser" src="https://img.shields.io/badge/-Embedded%20browser-4d6bfe" /> <img alt="Real terminal" src="https://img.shields.io/badge/-Real%20terminal-4d6bfe" /> <img alt="Git panel" src="https://img.shields.io/badge/-Git%20panel-4d6bfe" /> <img alt="Background tasks" src="https://img.shields.io/badge/-Background%20tasks-4d6bfe" /> <img alt="Plugin integration" src="https://img.shields.io/badge/-Plugin%20integration-4d6bfe" /><br /><br />
|
||||
<b>A dual workbench (right sidebar + bottom panel)</b> that opens its <code>ctx.betterSidebar</code> service to every plugin —<br />
|
||||
register new sidebar pages and file viewers via <code>registerTab</code> / <code>registerFileViewer</code>.
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
🌏 <a href="./README.md">中文</a> · <a href="./README_EN.md"><b>English</b></a>
|
||||
</div>
|
||||
|
||||
https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86
|
||||
|
||||
<img width="2630" height="1794" alt="6c4293e1bec2e935031bf0e986d6ec65" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
|
||||
|
||||
## 🆕 Recent Updates
|
||||
|
||||
<small>v0.12.1</small>
|
||||
|
||||
| Feature | Description | Screenshot |
|
||||
|---|---|---|
|
||||
| 🔌 Service API base | Complete type exports + `version`/`features` capability detection, state subscription (`getSnapshot`/`subscribeState`), tab `badge`, `onOpen`/`onActivate`/`onClose` lifecycle callbacks, `updateTab`/`activateTab`/`openFile`, targeted open, `meta` persisted across reloads, plugin-owned settings (`pluginToggles`/`render`) | |
|
||||
| ➕ Add Plugins | Recommended plugin catalog in settings + one-click copy install command; built-in Office preview moved to the recommended plugin | |
|
||||
| 🖱️ Tab-bar scroll | Mouse-wheel horizontal scrolling on the tab bar | |
|
||||
| 🐛 Fixes | Remote access 403 (trust fence now uses `trustedHosts`), sidebar crash [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31), Windows HTML-preview drive-path | |
|
||||
<div align="center">
|
||||
<video src="https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86" muted autoplay loop playsinline controls width="100%"></video>
|
||||
<img alt="dsh-better-sidebar workbench" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
|
||||
</div>
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **🗂️ File Explorer**: lazy-loading directory tree (root = session cwd), click to open, `@file` reference, right-click to copy path
|
||||
- **📝 Edit & Preview**: CodeMirror 6 editing (Ctrl/Cmd+S atomic save, drafts survive tab switches); inline preview for images / Markdown / HTML / PDF / Word / Excel / PPT (HTML in a sandboxed iframe)
|
||||
- **⚡ Client-side Lazy Loading**: only ~325KB core at startup; heavy deps (terminal / editor) load on demand (see `docs/plans/2026-08-12-lazy-chunks-design.md`)
|
||||
- **🌐 Browser**: multiple embedded web tabs, back/forward/refresh; pages run in a sandboxed iframe (no access to UI data or local files, rejects local addresses), temporarily unlockable (red warning); sites refusing embedding show a reason panel; external links open in the sidebar by default
|
||||
- **💻 Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model; custom font (family + 9–32px size, applied live)
|
||||
- **🌿 Git Panel**: real diff + VSCode-style diff tabs, history, right-click to stage/commit/revert etc.
|
||||
- **🧩 Background Tasks**: main-session agent topology with click-through to execution records; background tasks on the same page (type badges + exit codes, live-output peek, force-kill)
|
||||
- **🪟 Bottom Panel**: an independent second workbench that only squeezes the center output area; auto-opens a terminal on first expand (toggleable)
|
||||
- **📱 Mobile**: narrow viewports (<768px) merge into a full-width drawer with the bottom panel's tabs folded into the sidebar; files/links auto-expand the panel
|
||||
- **🔧 Split-pane Workbench**: drag tabs to split/merge panes (cross-panel supported), divider to adjust ratios; one-click collapse/expand both panels from the top-right buttons
|
||||
- **🔁 Session Isolation**: layout / tabs / panel states persisted per session, stale state auto-purged; "produced files" open in the sidebar
|
||||
- **⚙️ Declarative Settings**: the "Side Cards" settings section renders a registry-driven toggle grid, each item independently switchable; secondary settings (auto-expand, terminal tools, sandbox, etc.) edited in a native dialog via the gear button
|
||||
- **🔌 Service API**: exposes `ctx.betterSidebar` — other plugins can register tabs and file viewers (the 7 built-in tabs + 9 viewers share the same service, see [AGENTS.md](./AGENTS.md) and the [external plugin guide](./docs/external-plugin-guide.md))
|
||||
- **➕ Add Plugins**: dashed cards at the end of both settings grids ("Sidebar content" / "File viewers") open tab / previewer plugin modals: the extension point declaration, a "Browse more plugins on GitHub" BUTTON, and the recommended plugin catalog ("Open" jumps to the repo, "Copy" puts the install command on the clipboard); no terminal is opened, nothing can fail — never blocking startup or the sidebar
|
||||
- **🌏 i18n**: UI text follows DSH's language setting (zh/en) with live switching, no refresh needed
|
||||
- **🗂️ File Workbench**: file explorer (lazy-loading tree) + CodeMirror editor; inline preview for images / Markdown / HTML / PDF / Office
|
||||
- **🌐 Embedded Browser**: multiple web tabs with back / forward / refresh; content runs in a sandboxed iframe; external links are routed by protocol by default — HTTP opens in the sidebar, HTTPS goes to the system browser (both adjustable in settings)
|
||||
- **💻 Real Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model
|
||||
- **🌿 Git Panel**: real diff + VSCode-style diff tabs, history, right-click to stage / commit / revert
|
||||
- **🧩 Background Tasks**: agent topology + background tasks (exit codes / live output / force-kill)
|
||||
- **🪟 Dual Workbench**: right sidebar + bottom panel; drag tabs to split / merge panes (cross-panel), mobile auto-merges into a full-width drawer
|
||||
- **🔁 Session Isolation**: layout / tabs / panels persisted per session, stale state auto-purged
|
||||
- **⚙️ Declarative Settings**: per-item toggles in the "Side Cards" settings section, secondary settings via the gear dialog
|
||||
- **⚡ On-demand Loading**: only ~325KB core at startup; heavy deps (terminal / editor) load on demand ([design](docs/plans/2026-08-12-lazy-chunks-design.md))
|
||||
- **🌏 i18n**: UI text follows DSH's language (zh / en) with live switching
|
||||
|
||||
> 🔌 **Core principle**: service-first — the 7 built-in tabs + 6 viewers register through the same `ctx.betterSidebar` API as third-party plugins, with fully equal capabilities; anything the ecosystem can provide better is delegated to ecosystem plugins. See the "🔌 Service" section below and the [external plugin guide](./docs/external-plugin-guide.md).
|
||||
|
||||
## 🆕 Recent Updates
|
||||
|
||||
<small>v0.12.2</small>
|
||||
|
||||
> 📝 **Note**: this release adds "Position compat mode" — reserves top space for the native Windows title bar (top-right), shifting the sidebar buttons and content below it (0–120px, customizable in the gear popup). It ships together with the service API base introduced since v0.12.0: capability detection, state subscription, tab badges, lifecycle callbacks, external-link claiming (`urlTarget`) and plugin-owned settings are all in place for deep third-party integration; settings gained the "Add Plugins" recommended catalog. See the table below for details.
|
||||
|
||||
| Feature | Description | Screenshot |
|
||||
|---|---|---|
|
||||
| 📐 Position compat mode | New "Position compatibility mode" setting: reserves top space for the native Windows title bar (top-right) so the sidebar buttons and content sit below it (off by default); the shift distance is customizable in the gear popup (0–120px) | |
|
||||
| 🔌 Service API base | Complete type exports + `version`/`features` capability detection, state subscription (`getSnapshot`/`subscribeState`), tab `badge`, `onOpen`/`onActivate`/`onClose` lifecycle callbacks, `updateTab`/`activateTab`/`openFile`, targeted open, `meta` persisted across reloads, plugin-owned settings (`pluginToggles`/`render`), external-link claim (`urlTarget`) | <a href="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0"><img width="480" alt="Service API base screenshot" src="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0" /></a> |
|
||||
| ➕ Add Plugins | Recommended plugin catalog in settings + one-click copy install command; built-in Office preview moved to the recommended plugin | <a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="480" alt="Add Plugins screenshot" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a> |
|
||||
| 🖱️ Tab-bar scroll | Mouse-wheel horizontal scrolling on the tab bar | |
|
||||
| 🐛 Fixes | Remote access 403 (trust fence now uses `trustedHosts`), sidebar crash [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31), Windows HTML-preview drive-path | |
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
@@ -68,10 +71,10 @@ Then **hard-refresh the browser** (Cmd/Ctrl+Shift+R) to see the sidebar (DSH hot
|
||||
|
||||
```sh
|
||||
# macOS / Linux
|
||||
curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash -s 0.12.1 --restart
|
||||
curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash -s 0.12.2 --restart
|
||||
|
||||
# Windows PowerShell
|
||||
& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.12.1 -Restart
|
||||
& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.12.2 -Restart
|
||||
```
|
||||
|
||||
Not sure? Add `--dry-run` (`-DryRun` in PowerShell) to preview before running.
|
||||
@@ -97,7 +100,7 @@ minimumReleaseAgeExclude:
|
||||
- dsh-better-sidebar
|
||||
EOF
|
||||
|
||||
# ③ Install and auto-mount (no @version = npm's latest; pin with dsh-better-sidebar@0.12.1)
|
||||
# ③ Install and auto-mount (no @version = npm's latest; pin with dsh-better-sidebar@0.12.2)
|
||||
npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar
|
||||
```
|
||||
|
||||
@@ -128,7 +131,7 @@ The one-click script does four things, all idempotent (safe to re-run):
|
||||
3. Runs `dsh plugin --profile web add dsh-better-sidebar`: registers the dependency → detects `dsh.bundle.patch` → auto-appends the plugin to `dsh.profile.bundles`;
|
||||
4. Removes any leftover hand-written mount line to avoid double-mounting (two sidebars on the page).
|
||||
|
||||
`curl | bash` / `irm | iex` executes remote code — the scripts are open source in the repo (`scripts/install.sh` / `scripts/install.ps1`); download and review them first if you prefer. The plugin ships as npm package `dsh-better-sidebar@0.12.1` and mounts via `dsh.bundle.patch` (the shipped `cordis.patch.yml`), so the DSH source is never modified.
|
||||
`curl | bash` / `irm | iex` executes remote code — the scripts are open source in the repo (`scripts/install.sh` / `scripts/install.ps1`); download and review them first if you prefer. The plugin ships as npm package `dsh-better-sidebar@0.12.2` and mounts via `dsh.bundle.patch` (the shipped `cordis.patch.yml`), so the DSH source is never modified.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -139,7 +142,7 @@ The one-click script does four things, all idempotent (safe to re-run):
|
||||
dsh plugin --profile web add dsh-better-sidebar
|
||||
```
|
||||
|
||||
or re-run the one-click script; or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.12.1"`) and run `pnpm install`. Then hard-refresh the browser (Cmd/Ctrl+Shift+R) — client changes do not need a DSH restart.
|
||||
or re-run the one-click script; or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.12.2"`) and run `pnpm install`. Then hard-refresh the browser (Cmd/Ctrl+Shift+R) — client changes do not need a DSH restart.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -174,7 +177,7 @@ To debug local changes or track the dev branch, point the dependency at a local
|
||||
5. Restart DSH and hard-refresh
|
||||
```
|
||||
|
||||
Update: `git pull && pnpm install && pnpm build` → just hard-refresh the browser (client changes hot-reload; only host-half changes need a DSH restart). To switch back to the npm channel, restore `"dsh-better-sidebar": "^0.12.1"` and re-run `pnpm install`.
|
||||
Update: `git pull && pnpm install && pnpm build` → just hard-refresh the browser (client changes hot-reload; only host-half changes need a DSH restart). To switch back to the npm channel, restore `"dsh-better-sidebar": "^0.12.2"` and re-run `pnpm install`.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -208,7 +211,7 @@ Update: `git pull && pnpm install && pnpm build` → `node scripts/package-regis
|
||||
|
||||
## 🔌 Service: register tabs & file viewers
|
||||
|
||||
Since v0.4.0 the plugin exposes the `ctx.betterSidebar` service — other plugins can register sidebar pages and file viewers (the 7 built-in tabs + 9 viewers go through the same service, eating our own dog food):
|
||||
Since v0.4.0 the plugin exposes the `ctx.betterSidebar` service — other plugins can register sidebar pages and file viewers (the 7 built-in tabs + 6 viewers register through the same service):
|
||||
|
||||
```ts
|
||||
import type {} from 'dsh-better-sidebar' // triggers the ctx.betterSidebar type merge
|
||||
@@ -268,3 +271,4 @@ Windows / Linux / macOS (macOS validated daily; the rest covered by unit tests);
|
||||
- [dsh-tianshu-tui](https://github.com/huiliyi37/dsh-tianshu-tui): an interactive terminal UI plugin for DeepSeek Harness (its rendering core evolved from the self-developed harness agent Tianshu-Tui), adding TDD and evidence-gate workflows on top of the official harness
|
||||
- [dsh-TUI](https://github.com/ccch1mneyyy/dsh-TUI): a Claude Code-style fullscreen interactive TUI plugin — pixel-whale top bar, live working-status row, streaming thought expansion, double-Esc rollback, context progress bar + TPS meter; one-command npm install
|
||||
- [dshfind Plugin Market](https://dshfind.com/zh/plugins): a third-party plugin marketplace — a listing of public repos under the GitHub topic `dsh-plugin`, with stars, contributors and growth data synced daily
|
||||
- [DeepSeek Harness Desktop](https://github.com/anywhere-labs/deepseek-harness-desktop): a modern desktop client for the DeepSeek Harness ecosystem — start and manage a local Harness service without configuring Node.js or running commands; [official site](https://www.dshdesktop.cn)
|
||||
|
||||
@@ -783,4 +783,6 @@ better-sidebar 的内置 tab 和 viewer 就是参考实现("吃狗粮"):
|
||||
- **类型处理**:未 value-import `dsh-better-sidebar`,构建零耦合;与 §2 的 `import type {}` 方案可互换;
|
||||
- **实测**:v0.3.0 起,真实 web profile 验证通过。
|
||||
|
||||
通过 `ctx.betterSidebar` 的三方插件[dsh-sidebar-qa](https://github.com/ChenRuoT/dsh-sidebar-qa) —— 基于 better-sidebar 的划选提问。tab分页: 对话划选 → 右侧面板提问 → 同工作区独立追问会话(❓追问·主题):快速无思考模型压缩主对话上下文后与引文一起注入,不打断主对话;追问可嵌套、可继续、可归档
|
||||
|
||||
更多插件接入后欢迎在此登记(一句话 + 链接)。
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# 外链点击路由到插件 tab(urlTarget)+ 浏览器 HTTP/HTTPS 二级设置
|
||||
|
||||
**日期**:2026-08-15
|
||||
**状态**:已实施(本文档含实施偏差记录)
|
||||
**目标版本**:v0.13.0(API 兼容层,版本号未随本变更 bump,见 §7)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
基于 [PR #74](https://github.com/omdsh-dev/DSH-better-sidebar/pull/74)(聊天区外链开关颗粒度讨论)向前兼容地拓展服务化 API:
|
||||
|
||||
1. **插件声明「点击打开的类型」**:`TabDescriptor` 新增可选 `urlTarget?: (url: URL) => boolean`——聊天区外链被拦截时,路由到第一个声明的、命中的、未被设置禁用的插件 tab 类型(`openTab({ type, url, title: hostname })`,URL 即全部载荷),内置浏览器 tab 退居隐式兜底。
|
||||
2. **浏览器链接设置改为二级菜单**(浏览器卡片齿轮弹窗内):
|
||||
- 侧边打开 HTTP 网页(默认开)
|
||||
- 侧边打开 HTTPS 网页(默认关)
|
||||
- `browserInterceptLinks` 保留为**总闸**(默认开,零迁移:旧文档显式 false 语义完整保留)。
|
||||
|
||||
## 2. 非目标
|
||||
|
||||
- 不实施 PR #74 的「外链开关提升到常规区」(用户确认:二级设置留在浏览器卡片齿轮弹窗,常规区不加行)。
|
||||
- 不做 per-host / per-domain 路由规则(只有 per-protocol 开关 + 插件谓词)。
|
||||
- 不改 + 菜单、不改手动打开的浏览器 tab、不改同源/修饰键点击放行规则。
|
||||
- 不改 host 半逻辑(仅 PrefsSchema 加字段);不修改 DSH 源码。
|
||||
- 不做版本号 bump(发布时决定;向前兼容机制 = `SIDEBAR_FEATURES` 追加 `'urlTarget'`)。
|
||||
|
||||
## 3. 现状回顾
|
||||
|
||||
- `link-intercept.ts` 的 `registerLinkInterception` 无参 `takeoverEnabled()` 只做布尔门;`index.tsx` 硬编码 `openTab({ type: 'browser', url, title: hostname })`。
|
||||
- `browserInterceptLinks`(默认 true)与 `browserNoSandbox` 并列于浏览器卡片的 `settings.toggles`;拦截 gating = 该 pref && `tabsEnabled['browser']`。
|
||||
- `openTab` seed 已有 `url` 字段(新建 tab 的 path 预填),插件 tab 无需新渲染管道。
|
||||
|
||||
## 4. 设计
|
||||
|
||||
### 4.1 Prefs(`prefs-shared.ts` / `config.ts` / `client/prefs.ts`)
|
||||
|
||||
```ts
|
||||
interface SidebarPrefs {
|
||||
// …
|
||||
browserInterceptLinks: boolean // 总闸,默认 true(保留,零迁移)
|
||||
browserInterceptHttp: boolean // HTTP 外链侧边栏接管,默认 true
|
||||
browserInterceptHttps: boolean // HTTPS 外链侧边栏接管,默认 false
|
||||
}
|
||||
```
|
||||
|
||||
三处同步维护(类型 + schemastery 默认 + parsePrefs 校验),旧文档无需迁移:总闸保留使显式关闭过的用户升级后仍不拦截。
|
||||
|
||||
### 4.2 服务 API(`service.ts`)
|
||||
|
||||
```ts
|
||||
interface TabDescriptor {
|
||||
// …
|
||||
urlTarget?: (url: URL) => boolean
|
||||
}
|
||||
|
||||
/** 注册顺序先到先得;谓词抛错吞掉(console.error)继续;返回 undefined 由调用方回退 browser。 */
|
||||
export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDescriptor | undefined
|
||||
```
|
||||
|
||||
- 匹配函数为**模块级纯函数**(不进 `BetterSidebarService` 接口,KISS:不扩公开面);启用态过滤由调用方(`index.tsx`)按 `tabsEnabled` 过滤后传入。
|
||||
- 内置 browser 不声明 `urlTarget`,永远不可能遮蔽插件声明;插件多 URL 并存需用 `createTab` 铸造 per-URL id(browser 同款模式),否则 id 安全网聚焦既有 tab 不覆写 path。
|
||||
- `SIDEBAR_FEATURES` 追加 `'urlTarget'`(消费者按能力 gate)。
|
||||
|
||||
### 4.3 拦截管线(`link-intercept.ts` / `index.tsx`)
|
||||
|
||||
- `shouldInterceptLink` 签名不变(协议/同源纯判定);`takeoverEnabled` 改为 `(url: URL) => boolean`,onClick 顺序:纯判定 → gate(持 URL)→ preventDefault → open。
|
||||
- `index.tsx` gate:总闸 `browserInterceptLinks` → 协议开关(https 读 `browserInterceptHttps`,否则读 `browserInterceptHttp`)→ 目标可开(插件命中即目标,否则 browser 的 `tabsEnabled`)。open 时再次解析目标并 `openTab({ type, url, title: hostname })`。
|
||||
|
||||
### 4.4 设置 UI(`builtins/tabs.tsx` / `locales.ts`)
|
||||
|
||||
浏览器卡片 `settings.toggles` 收敛为四行:`browserNoSandbox` / `browserInterceptLinks`(总闸)/ `browserInterceptHttp` / `browserInterceptHttps`;新增 zh/en 文案(HTTP 默认开、HTTPS 默认关及理由)。
|
||||
|
||||
## 5. 行为变化
|
||||
|
||||
- 默认设置下:HTTP 外链 → 侧边栏(不变);**HTTPS 外链 → 系统浏览器新窗口(变化,符合「默认关闭」)**。
|
||||
- 声明 `urlTarget` 的插件 tab 认领被拦截的链接(优先于浏览器兜底);插件被禁用/谓词抛错时跳过。
|
||||
- 总闸关闭 → 一律不拦截(旧文档语义保留)。
|
||||
|
||||
## 6. 测试
|
||||
|
||||
- `tests/builtins.spec.ts`:浏览器 toggles 断言收敛为四行 + 各行 title/desc 存在。
|
||||
- `tests/service.spec.ts`:`matchUrlTarget`(无声明 → undefined / 注册顺序 / 抛错吞掉 / browser 永不命中)。
|
||||
- `tests/link-intercept.spec.ts`(新增 jsdom):`takeoverEnabled(url)` 收到解析后的 URL、gate 决定是否接管、同源/非 http(s) 不 consult gate、修饰键绕过。
|
||||
- `tests/unit.spec.ts`:parsePrefs 新字段默认值(http true / https false / 非法回退);总闸与协议开关相互独立。
|
||||
- `tests/plugin-shape.spec.ts` / `tests/smoke.spec.ts`:全默认字面量补两个字段。
|
||||
|
||||
## 7. 实施偏差记录
|
||||
|
||||
- 计划曾考虑把 `matchUrlTarget` 作为 `BetterSidebarService` 接口方法(镜像 `matchFileViewer`);按 KISS 收敛为**模块级导出纯函数**,启用态过滤由调用方完成(匹配函数不感知 prefs),公开 API 面只剩 `urlTarget` 字段 + `'urlTarget'` feature 字符串。
|
||||
- 版本号未 bump(保持 0.12.1):`SIDEBAR_SERVICE_VERSION` 与 `package.json` 的配对断言继续成立;`'urlTarget'` feature 已入 `SIDEBAR_FEATURES`,发布时再决定版本。
|
||||
- PR #74 的 diff(常规区移动 + 其测试改动)被本方案取代,未合入。
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "dsh-external/dsh-better-sidebar",
|
||||
"version": "0.12.1",
|
||||
"version": "0.12.2",
|
||||
"main": "./lib/index.js",
|
||||
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
|
||||
"engines": {
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dsh-better-sidebar",
|
||||
"version": "0.12.1",
|
||||
"version": "0.12.2",
|
||||
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
@@ -77,6 +77,7 @@
|
||||
"watch": "tsdown --watch",
|
||||
"prepare": "tsdown",
|
||||
"test": "vitest run",
|
||||
"test:mount": "bash scripts/e2e-mount.sh",
|
||||
"check:consumer-types": "bash scripts/check-consumer-types.sh"
|
||||
},
|
||||
"license": "MIT",
|
||||
@@ -137,6 +138,7 @@
|
||||
"xterm": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
|
||||
@@ -152,11 +154,11 @@
|
||||
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.1",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"jsdom": "^29.1.1",
|
||||
"lightningcss": "^1.32.0",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Playwright config for the headless-render mount lane (tests/e2e).
|
||||
*
|
||||
* This lane does NOT spawn the server itself: `scripts/e2e-mount.sh` boots a
|
||||
* real `dsh web` instance (with the npm-packed plugin mounted through the
|
||||
* official `dsh plugin add` channel) and injects the base URL through
|
||||
* `DSH_E2E_URL`. The spec's job is to render that URL in a headless Chromium
|
||||
* and prove the plugin mounts without crashing the shell.
|
||||
*
|
||||
* Specs are named `*.e2e.ts` (not `*.spec.ts`) so vitest's default include
|
||||
* never collects them; vitest.config.ts excludes the directory as well.
|
||||
*/
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
// The lane's specs are named *.e2e.ts (so vitest's default include never
|
||||
// collects them); tell Playwright to pick exactly that shape.
|
||||
testMatch: '**/*.e2e.ts',
|
||||
// A deterministic gate: no retries, one worker, one browser. The whole lane
|
||||
// is a crash probe against one server instance, so serial execution keeps
|
||||
// the assertions meaningful (a crash would trip the very next check).
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
fullyParallel: false,
|
||||
timeout: 120_000,
|
||||
reporter: [
|
||||
['list'],
|
||||
// Local debugging artifact; CI uploads the report dir on failure.
|
||||
['html', { open: 'never' }],
|
||||
],
|
||||
use: {
|
||||
browserName: 'chromium',
|
||||
headless: true,
|
||||
viewport: { width: 1440, height: 900 },
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
})
|
||||
Generated
+38
@@ -144,6 +144,9 @@ importers:
|
||||
'@deepseek-ai/dsh-tools':
|
||||
specifier: ^0.1.0-rc.6
|
||||
version: 0.1.0-rc.6(f8724372086ccc1457fc84e7becee2e0)
|
||||
'@playwright/test':
|
||||
specifier: ^1.62.1
|
||||
version: 1.62.1
|
||||
'@types/node':
|
||||
specifier: ^24.0.0
|
||||
version: 24.13.3
|
||||
@@ -974,6 +977,11 @@ packages:
|
||||
'@oxc-project/types@0.144.0':
|
||||
resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==}
|
||||
|
||||
'@playwright/test@1.62.1':
|
||||
resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
'@quansync/fs@1.0.0':
|
||||
resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==}
|
||||
|
||||
@@ -1473,6 +1481,11 @@ packages:
|
||||
fflate@0.8.3:
|
||||
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
||||
|
||||
fsevents@2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@@ -1789,6 +1802,16 @@ packages:
|
||||
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
playwright-core@1.62.1:
|
||||
resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.62.1:
|
||||
resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
postcss@8.5.26:
|
||||
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -3184,6 +3207,10 @@ snapshots:
|
||||
|
||||
'@oxc-project/types@0.144.0': {}
|
||||
|
||||
'@playwright/test@1.62.1':
|
||||
dependencies:
|
||||
playwright: 1.62.1
|
||||
|
||||
'@quansync/fs@1.0.0':
|
||||
dependencies:
|
||||
quansync: 1.0.0
|
||||
@@ -3542,6 +3569,9 @@ snapshots:
|
||||
|
||||
fflate@0.8.3: {}
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
@@ -4051,6 +4081,14 @@ snapshots:
|
||||
|
||||
picomatch@4.0.5: {}
|
||||
|
||||
playwright-core@1.62.1: {}
|
||||
|
||||
playwright@1.62.1:
|
||||
dependencies:
|
||||
playwright-core: 1.62.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
postcss@8.5.26:
|
||||
dependencies:
|
||||
nanoid: 3.3.18
|
||||
|
||||
Executable
+159
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# dsh-better-sidebar 挂载冒烟编排(CI + 本地):
|
||||
#
|
||||
# 1. 用官方 CLI 把 npm 打包产物(tarball)真实挂载进一个全新 scratch
|
||||
# profile(`dsh plugin --profile web add file:<tarball>`,触发
|
||||
# dsh.profile.bundles 协调,与用户安装路径一致);
|
||||
# 2. 启动真实 `dsh web`(keyless,--port 0 取 OS 分配端口);
|
||||
# 3. 运行 tests/e2e 无头渲染 lane(Playwright Chromium):断言外壳与
|
||||
# 插件挂载、无崩溃标记,并驱动内置 tab 深扫。
|
||||
#
|
||||
# 用法:
|
||||
# bash scripts/e2e-mount.sh [--grep <playwright-filter>]
|
||||
#
|
||||
# 环境变量(均可省略):
|
||||
# DSH_CMD dsh 命令;缺省 PATH 上的 `dsh`,回退 npx 拉官方包
|
||||
# TARBALL 插件 tarball;缺省仓库根 dsh-better-sidebar-*.tgz(须已 pack)
|
||||
# PORT 固定端口(默认 0 = OS 分配,从日志解析 URL)
|
||||
# DSH_HOME_BASE 覆盖 scratch 根目录(默认 mktemp -d)
|
||||
# KEEP_HOME 非空时保留 scratch home(调试用)
|
||||
#
|
||||
# 退出码 = playwright 的退出码;服务器与 scratch 目录由 trap 兜底清理。
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
DSH_CMD="${DSH_CMD:-dsh}"
|
||||
PORT="${PORT:-0}"
|
||||
TARBALL="${TARBALL:-}"
|
||||
GREP_FILTER=""
|
||||
if [ "${1:-}" = "--grep" ]; then GREP_FILTER="${2:?--grep 需要参数}"; fi
|
||||
|
||||
say() { printf '\033[32m[e2e-mount]\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[33m[e2e-mount]\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[31m[e2e-mount]\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
command -v node >/dev/null 2>&1 || die "未找到 node(DSH 运行需要 Node.js >= 20)"
|
||||
command -v pnpm >/dev/null 2>&1 || die "未找到 pnpm(dsh plugin 转发给 pnpm)"
|
||||
|
||||
# dsh CLI 解析:PATH 上的 dsh 优先,否则 npx 拉官方包(同 scripts/install.sh)
|
||||
if ! command -v "$DSH_CMD" >/dev/null 2>&1; then
|
||||
if command -v npx >/dev/null 2>&1; then
|
||||
say "PATH 上无 $DSH_CMD,回退 npx -y --package @deepseek-ai/dsh"
|
||||
DSH_CMD="npx -y --package @deepseek-ai/dsh dsh"
|
||||
else
|
||||
die "未找到 $DSH_CMD 或 npx;请先安装 DSH CLI(npm i -g @deepseek-ai/dsh)或用 DSH_CMD 指定"
|
||||
fi
|
||||
fi
|
||||
|
||||
# tarball 解析
|
||||
if [ -z "$TARBALL" ]; then
|
||||
TARBALL="$(ls "$ROOT"/dsh-better-sidebar-*.tgz 2>/dev/null | head -1 || true)"
|
||||
fi
|
||||
[ -n "$TARBALL" ] && [ -f "$TARBALL" ] || die "找不到 tarball(TARBALL 或 \$ROOT/dsh-better-sidebar-*.tgz)——先运行 pnpm build && pnpm pack"
|
||||
TARBALL="$(cd "$(dirname "$TARBALL")" && pwd)/$(basename "$TARBALL")"
|
||||
say "tarball: $TARBALL"
|
||||
|
||||
# scratch home(每次全新,绝不触碰真实 ~/.dsh)
|
||||
SCRATCH="${DSH_HOME_BASE:-$(mktemp -d /tmp/dsh-e2e-mount.XXXXXX)}"
|
||||
export DSH_HOME="$SCRATCH/home"
|
||||
WORKSPACE_DIR="$SCRATCH/workspace"
|
||||
LOG_DIR="$SCRATCH"
|
||||
WEB_LOG="$LOG_DIR/web.log"
|
||||
mkdir -p "$DSH_HOME/profiles/web" "$WORKSPACE_DIR"
|
||||
say "scratch home: ${DSH_HOME}(DSH_HOME=${DSH_HOME})"
|
||||
|
||||
SERVER_PID=""
|
||||
cleanup() {
|
||||
local code=$?
|
||||
if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
fi
|
||||
if [ -z "${KEEP_HOME:-}" ]; then
|
||||
rm -rf "$SCRATCH"
|
||||
else
|
||||
warn "KEEP_HOME 已设置,保留 $SCRATCH"
|
||||
fi
|
||||
exit "$code"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# 步骤 1:引导 scratch profile(web 模板,镜像 dsh initProfile;先写
|
||||
# pnpm-workspace.yaml 的 allowBuilds / minimumReleaseAgeExclude,避免 pnpm 11
|
||||
# strict-dep-builds 拦截 node-pty/protobufjs 或拒绝 <24h 新版本——同 install.sh)
|
||||
PROFILE_DIR="$DSH_HOME/profiles/web"
|
||||
cat > "$PROFILE_DIR/package.json" <<EOF
|
||||
{
|
||||
"name": "dsh-profile-web",
|
||||
"private": true,
|
||||
"dependencies": {},
|
||||
"dsh": {
|
||||
"profile": {
|
||||
"bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"]
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
printf '[]\n' > "$PROFILE_DIR/cordis.patch.yml"
|
||||
cat > "$PROFILE_DIR/pnpm-workspace.yaml" <<'EOF'
|
||||
packages:
|
||||
- .
|
||||
|
||||
nodeLinker: hoisted
|
||||
autoInstallPeers: false
|
||||
|
||||
allowBuilds:
|
||||
node-pty: true
|
||||
protobufjs: true
|
||||
|
||||
minimumReleaseAgeExclude:
|
||||
- dsh-better-sidebar
|
||||
EOF
|
||||
|
||||
# 步骤 2:官方 CLI 安装 tarball + bundle 协调(真实挂载路径)
|
||||
say "执行 dsh plugin --profile web add file:$TARBALL ..."
|
||||
$DSH_CMD plugin --profile web add "file:$TARBALL"
|
||||
|
||||
# 步骤 3:校验挂载生效(dsh.profile.bundles 含 dsh-better-sidebar)
|
||||
if ! node -e '
|
||||
const fs = require("fs");
|
||||
const p = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
||||
const bundles = p.dsh?.profile?.bundles ?? [];
|
||||
process.exit(bundles.includes("dsh-better-sidebar") ? 0 : 1);
|
||||
' "$PROFILE_DIR/package.json"; then
|
||||
warn "dsh-better-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册"
|
||||
cat "$PROFILE_DIR/package.json"
|
||||
exit 1
|
||||
fi
|
||||
say "挂载已注册:dsh.profile.bundles 包含 dsh-better-sidebar"
|
||||
|
||||
# 步骤 4:启动 dsh web(--port 0 = OS 分配,避免端口冲突;keyless 可起)
|
||||
say "启动 dsh web(port=${PORT})..."
|
||||
$DSH_CMD web --port "$PORT" > "$WEB_LOG" 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
URL=""
|
||||
for _ in $(seq 1 120); do
|
||||
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
echo "=== dsh web 提前退出,日志尾部 ===" >&2
|
||||
tail -30 "$WEB_LOG" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
if URL="$(grep -oE 'dsh web: http://127\.0\.0\.1:[0-9]+' "$WEB_LOG" | head -1 | awk '{print $3}')" && [ -n "$URL" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
[ -n "$URL" ] || { echo "=== 120s 内未等到 dsh web 就绪,日志尾部 ===" >&2; tail -40 "$WEB_LOG" >&2 || true; exit 1; }
|
||||
say "dsh web 就绪:${URL}(pid ${SERVER_PID})"
|
||||
|
||||
# 步骤 5:运行无头渲染 lane
|
||||
say "运行 Playwright 无头渲染 lane..."
|
||||
DSH_E2E_URL="$URL" DSH_E2E_WORKSPACE="$WORKSPACE_DIR" \
|
||||
pnpm exec playwright test ${GREP_FILTER:+--grep "$GREP_FILTER"}
|
||||
|
||||
say "通过:插件挂载到真实 DSH 后无头渲染未崩溃"
|
||||
+2
-2
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import * as nodePty from 'node-pty'
|
||||
import { ensureSpawnHelper } from './pty-manager.ts'
|
||||
import { ensureSpawnHelper, shellSpawnArgs } from './pty-manager.ts'
|
||||
import { SidebarError } from './wire.ts'
|
||||
|
||||
/** Per-agent-terminal transcript bound (bytes kept for replay and reads). */
|
||||
@@ -211,7 +211,7 @@ export class AgentPtyRegistry {
|
||||
): string {
|
||||
const uuid = randomUUID()
|
||||
const dims = clampDims(cols, rows)
|
||||
const pty = nodePty.spawn(this.shell, [], {
|
||||
const pty = nodePty.spawn(this.shell, shellSpawnArgs(), {
|
||||
name: 'xterm-256color',
|
||||
cols: dims.cols,
|
||||
rows: dims.rows,
|
||||
|
||||
@@ -126,7 +126,8 @@
|
||||
}
|
||||
|
||||
.cardMain:focus-visible,
|
||||
.cardGear:focus-visible {
|
||||
.cardGear:focus-visible,
|
||||
.rowGear:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-l4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -266,6 +267,29 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* The gear icon button on a general settings ROW (the position-compat
|
||||
row's "customize the strip height" entry): the same 16px icon-button
|
||||
recipe as the card gear, sitting in the row's control area. */
|
||||
.rowGear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.rowGear:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Settings-row recipe (the DSH General section rows): title/desc left,
|
||||
control right, hairline separators (stripped on the last row). */
|
||||
.row {
|
||||
|
||||
@@ -52,6 +52,8 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
clampWidthPercent,
|
||||
TITLE_BAR_STRIP_MAX,
|
||||
TITLE_BAR_STRIP_MIN,
|
||||
WIDTH_PERCENT_MAX,
|
||||
WIDTH_PERCENT_MIN,
|
||||
type SidebarPrefs,
|
||||
@@ -380,6 +382,8 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// Which feature's secondary settings popup is open (null = closed).
|
||||
const [settingsFor, setSettingsFor] = useState<TabDescriptor | FileViewerDescriptor | null>(null)
|
||||
// Whether the position-compat strip popup (the gear on the 常规 row) is open.
|
||||
const [stripSettingsOpen, setStripSettingsOpen] = useState(false)
|
||||
// Whether the "add plugin" modal (a dashed card at the end of the
|
||||
// 侧边栏内容 / 文件预览 grids) is open, and for which extension point
|
||||
// (null = closed).
|
||||
@@ -660,6 +664,36 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
onChange={(next) => { applyPref({ interceptOpenPath: next }) }}
|
||||
/>
|
||||
</div>
|
||||
<div className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsTitleBarTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsTitleBarDesc')}</span>
|
||||
</span>
|
||||
<span className={css.control}>
|
||||
{/*
|
||||
The position-compat row's gear (same popup pattern as the
|
||||
feature cards): opens a Modal with the strip-height number row.
|
||||
Hidden while the mode is off — its related setting is dormant
|
||||
then (the feature-card convention).
|
||||
*/}
|
||||
{prefs.titleBarCompat && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.rowGear}
|
||||
aria-label={`${t('settingsTitleBarTitle')} ${t('settingsPopup')}`}
|
||||
title={t('settingsPopup')}
|
||||
onClick={() => { setStripSettingsOpen(true) }}
|
||||
>
|
||||
<IconSettingsOutline16 size={14} />
|
||||
</button>
|
||||
)}
|
||||
<Switch
|
||||
label={t('settingsTitleBarTitle')}
|
||||
checked={prefs.titleBarCompat}
|
||||
onChange={(next) => { applyPref({ titleBarCompat: next }) }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 侧边栏内容: one small card per registered tab type in a responsive
|
||||
@@ -783,6 +817,41 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* The position-compat strip popup (opened by the gear on the 常规
|
||||
row): one number row for the reserved strip height in px. Same
|
||||
Modal chrome and row machinery as the feature popups — mounted
|
||||
only while open (the Modal SSR rule above). */}
|
||||
{stripSettingsOpen && (
|
||||
<Modal
|
||||
open
|
||||
onClose={() => { setStripSettingsOpen(false) }}
|
||||
title={t('settingsTitleBarTitle')}
|
||||
description={t('settingsPopupDesc', { feature: t('settingsTitleBarTitle') })}
|
||||
closeLabel={t('close')}
|
||||
className={css.popupDialog}
|
||||
footer={(
|
||||
<button type="button" className={css.done} onClick={() => { setStripSettingsOpen(false) }}>
|
||||
{t('settingsDone')}
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<FeatureSettingsRows
|
||||
toggles={[{
|
||||
key: 'titleBarStripPx',
|
||||
type: 'number',
|
||||
title: () => t('settingsTitleBarStripTitle'),
|
||||
desc: () => t('settingsTitleBarStripDesc'),
|
||||
min: TITLE_BAR_STRIP_MIN,
|
||||
max: TITLE_BAR_STRIP_MAX,
|
||||
unit: 'px',
|
||||
}]}
|
||||
prefs={prefs}
|
||||
onToggle={onToggleSetting}
|
||||
onCommit={onCommitSetting}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* The "add plugin" modal (opened by the dashed cards above): declares
|
||||
the extension point of the clicked kind, opens the GitHub topic,
|
||||
and lists the matching recommended plugin catalog with per-entry
|
||||
|
||||
@@ -167,6 +167,33 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
return () => { document.body.removeAttribute('data-dsh-sidebar-collapsed') }
|
||||
}, [collapsed])
|
||||
|
||||
// Position compatibility mode (titleBarCompat pref): Windows frameless
|
||||
// windows draw the native title bar (minimize/maximize/close) at the
|
||||
// window's top-right corner, OVER the web content. When the user enables
|
||||
// the pref, the body attribute lets sidebar.module.css drop the toggle
|
||||
// cluster below the strip and push the right panel's content below it.
|
||||
// The strip height is user-tunable (titleBarStripPx) and rides a CSS
|
||||
// variable so the rules stay declarative. The attribute rides the
|
||||
// snapshot's prefs, so flipping the setting re-renders and re-applies
|
||||
// immediately; the cleanup removes both on unmount/boundary swap so a
|
||||
// crashed sidebar never leaves them behind.
|
||||
const titleBarCompat = snapshot.prefs.titleBarCompat
|
||||
const titleBarStrip = snapshot.prefs.titleBarStripPx
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
if (titleBarCompat) {
|
||||
document.body.setAttribute('data-dsh-title-bar-compat', '')
|
||||
root.style.setProperty('--dsh-title-bar-strip', `${titleBarStrip}px`)
|
||||
} else {
|
||||
document.body.removeAttribute('data-dsh-title-bar-compat')
|
||||
root.style.removeProperty('--dsh-title-bar-strip')
|
||||
}
|
||||
return () => {
|
||||
document.body.removeAttribute('data-dsh-title-bar-compat')
|
||||
root.style.removeProperty('--dsh-title-bar-strip')
|
||||
}
|
||||
}, [titleBarCompat, titleBarStrip])
|
||||
|
||||
/**
|
||||
* Bottom-panel merge on narrow viewports: whenever a session is current
|
||||
* while narrow (mount, session switch, or a desktop→narrow transition),
|
||||
|
||||
@@ -187,9 +187,10 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
title: () => t('browser'),
|
||||
icon: (size: number) => <IconGlobeOutline16 size={size} />,
|
||||
order: 50,
|
||||
// Declarative settings: the sandbox escape hatch and the link
|
||||
// takeover render under this tab's row in the Side card settings
|
||||
// page (the sandbox one is warned on).
|
||||
// Declarative settings: the sandbox escape hatch, the link-takeover
|
||||
// MASTER switch, and the per-protocol takeover switches (http on /
|
||||
// https off by default) render under this tab's row in the Side card
|
||||
// settings page (the sandbox one is warned on).
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'browserNoSandbox',
|
||||
@@ -199,6 +200,14 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
key: 'browserInterceptLinks',
|
||||
title: () => t('settingsBrowserLinksTitle'),
|
||||
desc: () => t('settingsBrowserLinksDesc'),
|
||||
}, {
|
||||
key: 'browserInterceptHttp',
|
||||
title: () => t('settingsBrowserHttpTitle'),
|
||||
desc: () => t('settingsBrowserHttpDesc'),
|
||||
}, {
|
||||
key: 'browserInterceptHttps',
|
||||
title: () => t('settingsBrowserHttpsTitle'),
|
||||
desc: () => t('settingsBrowserHttpsDesc'),
|
||||
}],
|
||||
},
|
||||
createTab: (state) => ({
|
||||
|
||||
+26
-7
@@ -12,7 +12,7 @@ import { createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { createSidebarStore } from './state.ts'
|
||||
import { createBetterSidebarService } from './service.ts'
|
||||
import { createBetterSidebarService, matchUrlTarget } from './service.ts'
|
||||
import { resetChunks } from './chunk-loader.ts'
|
||||
import { registerBuiltins } from './builtins/index.ts'
|
||||
import { Sidebar } from './Sidebar.tsx'
|
||||
@@ -153,16 +153,35 @@ export function apply(ctx: Context): void {
|
||||
ctx.effect(
|
||||
() => {
|
||||
try {
|
||||
// External http(s) links in the chat/GUI open the sidebar browser
|
||||
// instead of a new window (gated on the browserInterceptLinks pref
|
||||
// AND the browser tab's enable switch; Ctrl/Cmd+click bypasses).
|
||||
// External http(s) links in the chat/GUI open the sidebar instead
|
||||
// of a new window. Gated on the browserInterceptLinks MASTER pref,
|
||||
// the URL's protocol flag (browserInterceptHttp / Https — https
|
||||
// defaults OFF: most https sites refuse iframe embedding), and the
|
||||
// target tab's enable switch; Ctrl/Cmd+click always bypasses. The
|
||||
// target is the first registered tab whose `urlTarget` claims the
|
||||
// URL (enabled tabs only), else the built-in browser tab.
|
||||
const urlTargetOf = (url: URL): string | undefined => {
|
||||
const prefs = sidebarStore.getPrefs()
|
||||
const enabled = service.getTabs().filter(tab => prefs.tabsEnabled[tab.id] !== false)
|
||||
return matchUrlTarget(enabled, url)?.id
|
||||
}
|
||||
return registerLinkInterception({
|
||||
takeoverEnabled: () => sidebarStore.getPrefs().browserInterceptLinks !== false
|
||||
&& sidebarStore.getPrefs().tabsEnabled['browser'] !== false,
|
||||
takeoverEnabled: (url) => {
|
||||
const prefs = sidebarStore.getPrefs()
|
||||
if (prefs.browserInterceptLinks === false) return false
|
||||
const protocolOn = url.protocol === 'https:'
|
||||
? prefs.browserInterceptHttps !== false
|
||||
: prefs.browserInterceptHttp !== false
|
||||
if (!protocolOn) return false
|
||||
// A plugin claim is the target (already enabled-filtered);
|
||||
// otherwise the built-in browser must be enabled.
|
||||
return urlTargetOf(url) !== undefined || prefs.tabsEnabled['browser'] !== false
|
||||
},
|
||||
openInSidebar: (url) => {
|
||||
let title: string | undefined
|
||||
try { title = new URL(url).hostname } catch { /* keep the default title */ }
|
||||
ctx.betterSidebar?.openTab({ type: 'browser', url, title })
|
||||
const type = urlTargetOf(new URL(url)) ?? 'browser'
|
||||
ctx.betterSidebar?.openTab({ type, url, title })
|
||||
},
|
||||
selfOrigin: window.location.origin,
|
||||
})
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* Chat/GUI external-link interception: clicking an http(s) link that points
|
||||
* OUTSIDE the GUI (chat messages, tool rows, prose mentions) opens the
|
||||
* sidebar browser instead of a new browser tab. Gated by BOTH the
|
||||
* `browserInterceptLinks` pref and the browser tab's enable switch; a
|
||||
* Ctrl/Cmd/Shift/Alt-modified click always bypasses the takeover so the
|
||||
* user can still force a real browser tab.
|
||||
* sidebar instead of a new browser tab. Gated by the caller through
|
||||
* `takeoverEnabled(url)` — the `browserInterceptLinks` master, the URL's
|
||||
* protocol flag (`browserInterceptHttp` / `browserInterceptHttps`) and the
|
||||
* target tab's enable switch — and a Ctrl/Cmd/Shift/Alt-modified click
|
||||
* always bypasses the takeover so the user can still force a real browser
|
||||
* tab.
|
||||
*
|
||||
* Only the GUI's OWN document is watched — links inside the browser tab's
|
||||
* sandboxed iframe live in another document and never bubble here (and
|
||||
@@ -13,7 +15,10 @@
|
||||
|
||||
/** The pure decision: the URL to open in the sidebar, or null to let the
|
||||
* click fall through. Extracted so the policy is unit-testable without a
|
||||
* DOM. `anchorHref` must be the ABSOLUTE href (`<a>.href` already is). */
|
||||
* DOM. `anchorHref` must be the ABSOLUTE href (`<a>.href` already is).
|
||||
* The protocol/same-origin policy lives HERE; the prefs gates (master +
|
||||
* protocol flags + target enablement) live in the caller's
|
||||
* `takeoverEnabled(url)` callback. */
|
||||
export function shouldInterceptLink(anchorHref: string, selfOrigin: string): string | null {
|
||||
let url: URL
|
||||
try {
|
||||
@@ -23,7 +28,7 @@ export function shouldInterceptLink(anchorHref: string, selfOrigin: string): str
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
|
||||
// Same-origin links are GUI-internal navigation (settings pages, tool
|
||||
// docs) — never routed into the sidebar browser.
|
||||
// docs) — never routed into the sidebar.
|
||||
try {
|
||||
if (url.origin === new URL(selfOrigin).origin) return null
|
||||
} catch {
|
||||
@@ -39,11 +44,13 @@ export function isPlainLeftClick(event: { button: number; metaKey: boolean; ctrl
|
||||
|
||||
/**
|
||||
* Register the document-level click capture that funnels external links
|
||||
* into the sidebar browser. Returns the disposer (HMR-safe).
|
||||
* into the sidebar. Returns the disposer (HMR-safe).
|
||||
*/
|
||||
export function registerLinkInterception(opts: {
|
||||
takeoverEnabled: () => boolean
|
||||
/** Open the sidebar browser tab at `url` (the caller resolves the session). */
|
||||
/** Whether the takeover may happen for THIS url (the caller's prefs
|
||||
* gates: master switch, protocol flag, target enablement). */
|
||||
takeoverEnabled: (url: URL) => boolean
|
||||
/** Open the sidebar tab at `url` (the caller resolves the target type). */
|
||||
openInSidebar: (url: string) => void
|
||||
/** The GUI's own origin (window.location.origin at registration). */
|
||||
selfOrigin: string
|
||||
@@ -51,13 +58,13 @@ export function registerLinkInterception(opts: {
|
||||
const onClick = (event: MouseEvent): void => {
|
||||
if (!isPlainLeftClick(event)) return
|
||||
if (event.defaultPrevented) return
|
||||
if (!opts.takeoverEnabled()) return
|
||||
const target = event.target
|
||||
if (target === null || typeof (target as Element).closest !== 'function') return
|
||||
const anchor = (target as Element).closest('a[href]') as HTMLAnchorElement | null
|
||||
if (anchor === null) return
|
||||
const url = shouldInterceptLink(anchor.href, opts.selfOrigin)
|
||||
if (url === null) return
|
||||
if (!opts.takeoverEnabled(new URL(url))) return
|
||||
event.preventDefault()
|
||||
opts.openInSidebar(url)
|
||||
}
|
||||
|
||||
+20
-2
@@ -116,6 +116,10 @@ export const zh = {
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: '聊天区文件在侧边栏打开',
|
||||
settingsOpenPathDesc: '在聊天里点击文件链接(工具行、产物列表、文件提及)时,在侧边栏编辑器中打开,不再调用系统默认应用',
|
||||
settingsTitleBarTitle: '位置兼容模式',
|
||||
settingsTitleBarDesc: '为 Windows 右上角的原生标题栏预留空间:侧边栏按钮与侧边栏内容整体下移,避免被标题栏遮挡',
|
||||
settingsTitleBarStripTitle: '下移距离',
|
||||
settingsTitleBarStripDesc: '标题栏条带高度:侧边栏按钮与内容下移的像素数(0–120,默认 40)',
|
||||
settingsSaveFailed: '保存失败',
|
||||
settingsConflict: '设置已被其他窗口修改,请重试',
|
||||
binaryNoPreview: '此文件类型不支持预览',
|
||||
@@ -166,7 +170,11 @@ export const zh = {
|
||||
settingsBrowserSandboxTitle: '关闭浏览器沙箱(不安全)',
|
||||
settingsBrowserSandboxDesc: '关闭后,访问的任何网站都将与界面同源运行,可读取会话数据并冒充你的登录状态。仅对完全可信的站点开启',
|
||||
settingsBrowserLinksTitle: '聊天区外链在侧边栏打开',
|
||||
settingsBrowserLinksDesc: '开启后,点击聊天或界面中的 http/https 外链时在侧边栏浏览器中打开,不再弹出新窗口;Ctrl/Cmd 点击可临时放行',
|
||||
settingsBrowserLinksDesc: '开启后,点击聊天或界面中的外链时在侧边栏打开,不再弹出新窗口;HTTP 与 HTTPS 可分别通过下方开关控制;Ctrl/Cmd 点击可临时放行',
|
||||
settingsBrowserHttpTitle: '侧边打开HTTP网页',
|
||||
settingsBrowserHttpDesc: '开启后,点击聊天或界面中的 HTTP 外链时在侧边栏打开(声明了 urlTarget 的插件页面优先);Ctrl/Cmd 点击可临时放行',
|
||||
settingsBrowserHttpsTitle: '侧边打开HTTPS网页',
|
||||
settingsBrowserHttpsDesc: '开启后,点击聊天或界面中的 HTTPS 外链时在侧边栏打开。默认关闭:多数 HTTPS 站点拒绝被嵌入,走系统浏览器更顺畅',
|
||||
browserOpenExternal: '在浏览器中打开',
|
||||
browserEmbedBlocked: '{host} 拒绝了嵌入请求',
|
||||
browserEmbedBlockedDesc: '该站点通过 X-Frame-Options / frame-ancestors 禁止在其它页面中显示,无法在侧边栏内加载。可在浏览器中直接打开',
|
||||
@@ -219,6 +227,7 @@ export const zh = {
|
||||
copyInstall: '复制安装命令',
|
||||
pluginOfficeDesc: '为 better-sidebar 编辑器提供 Office 三件套预览(.docx / .xlsx / .pptx),把重型 Office 渲染库拆出主包、按需安装',
|
||||
pluginSentinelDesc: '条件驱动的 agent 唤醒系统:文件/进程/端口/HTTP/命令/webhook 传感器,条件达成自动唤醒休眠会话;注册「哨兵」Tab 展示服务器全局监控表',
|
||||
pluginSidebarQaDesc: '基于 better-sidebar 的划选提问tab分页: 对话划选 → 右侧面板提问 → 同工作区独立追问会话(❓追问·主题):快速无思考模型压缩主对话上下文后与引文一起注入,不打断主对话;追问可嵌套、可继续、可归档',
|
||||
}
|
||||
|
||||
/** The en dictionary (key-set-equal to zh, enforced by the type annotation). */
|
||||
@@ -328,6 +337,10 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'Open chat files in the sidebar',
|
||||
settingsOpenPathDesc: 'Open file links in the chat (tool rows, produced files, mentions) in the sidebar editor instead of the system default app',
|
||||
settingsTitleBarTitle: 'Position compatibility mode',
|
||||
settingsTitleBarDesc: 'Reserve space for the native Windows title bar at the top-right so the sidebar buttons and content sit below it instead of underneath',
|
||||
settingsTitleBarStripTitle: 'Shift distance',
|
||||
settingsTitleBarStripDesc: 'Title-bar strip height: how far the sidebar buttons and content move down in px (0–120, default 40)',
|
||||
settingsSaveFailed: 'Failed to save',
|
||||
settingsConflict: 'The setting changed in another window — please retry',
|
||||
binaryNoPreview: 'This file type cannot be previewed',
|
||||
@@ -378,7 +391,11 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
settingsBrowserSandboxTitle: 'Disable browser sandbox (unsafe)',
|
||||
settingsBrowserSandboxDesc: 'With the sandbox off, any visited site runs with the same origin as the GUI: it can read session data and act as your logged-in session. Only enable for fully trusted sites',
|
||||
settingsBrowserLinksTitle: 'Open chat external links in the sidebar',
|
||||
settingsBrowserLinksDesc: 'When on, clicking an http/https external link in the chat or GUI opens the sidebar browser instead of a new window; Ctrl/Cmd+click always bypasses',
|
||||
settingsBrowserLinksDesc: 'When on, clicking an external link in the chat or GUI opens the sidebar instead of a new window; HTTP and HTTPS are controlled separately by the switches below; Ctrl/Cmd+click always bypasses',
|
||||
settingsBrowserHttpTitle: 'Open HTTP pages in the sidebar',
|
||||
settingsBrowserHttpDesc: 'When on, clicking an HTTP external link in the chat or GUI opens the sidebar (plugin pages declaring urlTarget win); Ctrl/Cmd+click always bypasses',
|
||||
settingsBrowserHttpsTitle: 'Open HTTPS pages in the sidebar',
|
||||
settingsBrowserHttpsDesc: 'When on, clicking an HTTPS external link in the chat or GUI opens the sidebar. Off by default: most HTTPS sites refuse to be embedded, so the system browser is the smoother default',
|
||||
browserOpenExternal: 'Open in browser',
|
||||
browserEmbedBlocked: '{host} refused to be embedded',
|
||||
browserEmbedBlockedDesc: 'The site forbids being displayed inside other pages (X-Frame-Options / frame-ancestors), so it cannot load in the sidebar. Open it directly in your browser instead.',
|
||||
@@ -431,6 +448,7 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
copyInstall: 'Copy install command',
|
||||
pluginOfficeDesc: 'Office-suite preview (.docx / .xlsx / .pptx) for the better-sidebar editor, keeping the heavy Office render libraries out of the core bundle',
|
||||
pluginSentinelDesc: 'Condition-driven agent wakeup: file/process/port/http/command/webhook sensors wake dormant sessions when conditions fire; registers a "Sentinel" tab with the server-wide watch table',
|
||||
pluginSidebarQaDesc: 'Select-and-ask: Select conversation text → ask in the right-side panel → a dedicated follow-up session (❓追问) in the same workspace; a fast no-thinking model compresses the main context and injects it with the quote, without interrupting the main conversation. Follow-ups nest, continue, and archive',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,4 +24,13 @@ export const builtinTabPlugins: readonly PluginEntry[] = [
|
||||
// context consistent with the other entries.
|
||||
install: 'cd ~/.dsh && dsh plugin --profile web add "github:fuhefei/dsh-sentinel#v0.7.0"',
|
||||
},
|
||||
{
|
||||
id: 'dsh-sidebar-qa',
|
||||
name: 'dsh-sidebar-qa 划选追问',
|
||||
url: 'https://github.com/ChenRuoT/dsh-sidebar-qa',
|
||||
description: () => t('pluginSidebarQaDesc'),
|
||||
// dsh-sidebar-qa hard-depends on dsh-better-sidebar (required peer), so
|
||||
// the install line installs the prerequisite first, then the plugin.
|
||||
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-better-sidebar && dsh plugin --profile web add git+https://github.com/ChenRuoT/dsh-sidebar-qa.git',
|
||||
},
|
||||
]
|
||||
|
||||
+14
-1
@@ -11,12 +11,13 @@
|
||||
import type { api } from './api.ts'
|
||||
import {
|
||||
clampTerminalFontSize,
|
||||
clampTitleBarStrip,
|
||||
clampWidthPercent,
|
||||
SIDEBAR_PREFS_DEFAULTS,
|
||||
type SidebarPrefs,
|
||||
} from '../prefs-shared.ts'
|
||||
|
||||
export { SIDEBAR_PREFS_DEFAULTS, clampTerminalFontSize, clampWidthPercent }
|
||||
export { SIDEBAR_PREFS_DEFAULTS, clampTerminalFontSize, clampTitleBarStrip, clampWidthPercent }
|
||||
export type { SidebarPrefs }
|
||||
|
||||
/** The settings wire face the preferences need (a subset of the plugin api). */
|
||||
@@ -59,6 +60,12 @@ export function parsePrefs(value: unknown): SidebarPrefs {
|
||||
interceptOpenPath: typeof record.interceptOpenPath === 'boolean'
|
||||
? record.interceptOpenPath
|
||||
: SIDEBAR_PREFS_DEFAULTS.interceptOpenPath,
|
||||
titleBarCompat: typeof record.titleBarCompat === 'boolean'
|
||||
? record.titleBarCompat
|
||||
: SIDEBAR_PREFS_DEFAULTS.titleBarCompat,
|
||||
titleBarStripPx: typeof record.titleBarStripPx === 'number' && Number.isFinite(record.titleBarStripPx)
|
||||
? clampTitleBarStrip(record.titleBarStripPx)
|
||||
: SIDEBAR_PREFS_DEFAULTS.titleBarStripPx,
|
||||
htmlViewerNoSandbox: typeof record.htmlViewerNoSandbox === 'boolean'
|
||||
? record.htmlViewerNoSandbox
|
||||
: SIDEBAR_PREFS_DEFAULTS.htmlViewerNoSandbox,
|
||||
@@ -71,6 +78,12 @@ export function parsePrefs(value: unknown): SidebarPrefs {
|
||||
browserInterceptLinks: typeof record.browserInterceptLinks === 'boolean'
|
||||
? record.browserInterceptLinks
|
||||
: SIDEBAR_PREFS_DEFAULTS.browserInterceptLinks,
|
||||
browserInterceptHttp: typeof record.browserInterceptHttp === 'boolean'
|
||||
? record.browserInterceptHttp
|
||||
: SIDEBAR_PREFS_DEFAULTS.browserInterceptHttp,
|
||||
browserInterceptHttps: typeof record.browserInterceptHttps === 'boolean'
|
||||
? record.browserInterceptHttps
|
||||
: SIDEBAR_PREFS_DEFAULTS.browserInterceptHttps,
|
||||
tabsEnabled: booleanMapOf(record.tabsEnabled),
|
||||
viewersEnabled: booleanMapOf(record.viewersEnabled),
|
||||
pluginSettings: pluginSettingsMapOf(record.pluginSettings),
|
||||
|
||||
+46
-1
@@ -169,6 +169,22 @@ export interface TabDescriptor {
|
||||
* When omitted, a default `{ id, type, title }` tab is created.
|
||||
*/
|
||||
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
|
||||
/**
|
||||
* External-link target claim (v0.13.0+): when a GUI external-link click
|
||||
* is taken over (the `browserInterceptLinks` master AND the URL's
|
||||
* protocol flag — `browserInterceptHttp` / `browserInterceptHttps` —
|
||||
* are on), the first registered tab whose `urlTarget(url)` returns true
|
||||
* is opened with `openTab({ type, url, title: hostname })` — the URL is
|
||||
* the whole payload (the tab reads it from `tab.path`). Registration
|
||||
* order wins (first claim first served); a disabled tab type is skipped;
|
||||
* a throwing predicate is swallowed (console.error, the type is skipped).
|
||||
* The built-in browser tab declares NO urlTarget — it stays the implicit
|
||||
* fallback target, so plugins can never be shadowed by it. To host more
|
||||
* than one URL at a time, mint per-URL ids through `createTab` (the
|
||||
* browser builtin's pattern); otherwise the id safety net focuses the
|
||||
* existing tab of the same type and the new URL is not applied.
|
||||
*/
|
||||
urlTarget?: (url: URL) => boolean
|
||||
/**
|
||||
* Declarative settings shown in the Side card settings page: every
|
||||
* registered tab gets an enable/disable switch (icon + title + id), and
|
||||
@@ -373,11 +389,38 @@ function baseNameOf(path: string): string {
|
||||
return at === -1 ? path : path.slice(at + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the tab type that claims an intercepted external-link URL (v0.13.0+).
|
||||
* Walks the descriptors in REGISTRATION order and returns the first one
|
||||
* that declares `urlTarget` and matches `url`; a throwing predicate is
|
||||
* swallowed (console.error, type skipped) so one broken plugin can never
|
||||
* break the whole link pipeline. The caller passes the ENABLED tab
|
||||
* descriptors (enablement is the caller's prefs domain — filter
|
||||
* `service.getTabs()` through `tabsEnabled` before matching) and falls
|
||||
* back to the built-in browser tab when nothing claims the URL (the
|
||||
* browser never declares `urlTarget` itself, so it can never shadow a
|
||||
* plugin claim).
|
||||
*/
|
||||
export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDescriptor | undefined {
|
||||
for (const tab of tabs) {
|
||||
if (tab.urlTarget === undefined) continue
|
||||
let claimed = false
|
||||
try {
|
||||
claimed = tab.urlTarget(url) === true
|
||||
} catch (error) {
|
||||
console.error('[dsh-better-sidebar] urlTarget error:', error)
|
||||
continue
|
||||
}
|
||||
if (claimed) return tab
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin version this service instance reports. Keep in lockstep with
|
||||
* `package.json`'s version — `tests/service.spec.ts` asserts the pair.
|
||||
*/
|
||||
export const SIDEBAR_SERVICE_VERSION = '0.12.1'
|
||||
export const SIDEBAR_SERVICE_VERSION = '0.12.2'
|
||||
|
||||
/**
|
||||
* Monotonic capability list consumers use to gate new API usage (features
|
||||
@@ -390,6 +433,7 @@ export const SIDEBAR_SERVICE_VERSION = '0.12.1'
|
||||
* - 'stateSubscription': getSnapshot/subscribeState
|
||||
* - 'tabMeta': SidebarTab.meta (seeds, createTab, updateTab, persistence)
|
||||
* - 'pluginSettings': SidebarSettingsDeclaration.pluginToggles/render
|
||||
* - 'urlTarget' (v0.13.0): TabDescriptor.urlTarget (external-link claims)
|
||||
*/
|
||||
export const SIDEBAR_FEATURES = [
|
||||
'badge',
|
||||
@@ -400,6 +444,7 @@ export const SIDEBAR_FEATURES = [
|
||||
'stateSubscription',
|
||||
'tabMeta',
|
||||
'pluginSettings',
|
||||
'urlTarget',
|
||||
] as const
|
||||
|
||||
/** Run one plugin callback; a throw is logged and never breaks the caller. */
|
||||
|
||||
@@ -209,6 +209,29 @@
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
/* ── Position compatibility mode (Windows native title bar) ────────────── */
|
||||
|
||||
/* Windows frameless / hidden-title-bar windows draw the native caption
|
||||
strip (minimize / maximize / close) at the window's top-right corner,
|
||||
OVER the web content. When the user enables the "position compatibility"
|
||||
pref, the sidebar yields the strip: the toggle cluster drops below it and
|
||||
the right panel's content starts below it — the panel's surface still
|
||||
spans the full height, so the caption buttons float over empty sidebar
|
||||
fill (the VSCode hidden-title-bar look). The strip height is user-tunable
|
||||
(`--dsh-title-bar-strip`, written by the Sidebar shell from
|
||||
`titleBarStripPx`; 40px fallback). The bottom panel is unaffected: it
|
||||
sits at the window's bottom, never under the top strip. */
|
||||
:global(body[data-dsh-title-bar-compat]) .toggleCluster {
|
||||
top: calc(var(--dsh-title-bar-strip, 40px) + 3px);
|
||||
}
|
||||
|
||||
:global(body[data-dsh-title-bar-compat]) .panel {
|
||||
/* The caption strip is drawn over the panel's own top edge: keep the
|
||||
surface full-height and push the content (the tab strip) below the
|
||||
strip instead. .panelResize is absolutely positioned and unaffected. */
|
||||
padding-top: var(--dsh-title-bar-strip, 40px);
|
||||
}
|
||||
|
||||
/* The shared corner (both panels open): the intersection of the right
|
||||
panel's left edge and the bottom panel's top edge. Horizontal drags
|
||||
resize the width, vertical drags the height — the two panels drag
|
||||
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
TERMINAL_FONT_SIZE_DEFAULT,
|
||||
TERMINAL_FONT_SIZE_MAX,
|
||||
TERMINAL_FONT_SIZE_MIN,
|
||||
TITLE_BAR_STRIP_DEFAULT,
|
||||
TITLE_BAR_STRIP_MAX,
|
||||
TITLE_BAR_STRIP_MIN,
|
||||
WIDTH_PERCENT_DEFAULT,
|
||||
WIDTH_PERCENT_MAX,
|
||||
WIDTH_PERCENT_MIN,
|
||||
@@ -24,6 +27,9 @@ export {
|
||||
TERMINAL_FONT_SIZE_DEFAULT,
|
||||
TERMINAL_FONT_SIZE_MAX,
|
||||
TERMINAL_FONT_SIZE_MIN,
|
||||
TITLE_BAR_STRIP_DEFAULT,
|
||||
TITLE_BAR_STRIP_MAX,
|
||||
TITLE_BAR_STRIP_MIN,
|
||||
WIDTH_PERCENT_DEFAULT,
|
||||
WIDTH_PERCENT_MAX,
|
||||
WIDTH_PERCENT_MIN,
|
||||
@@ -91,10 +97,14 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
|
||||
terminalFontFamily: z.string().default(''),
|
||||
terminalFontSize: z.number().step(1).min(TERMINAL_FONT_SIZE_MIN).max(TERMINAL_FONT_SIZE_MAX).default(TERMINAL_FONT_SIZE_DEFAULT),
|
||||
interceptOpenPath: z.boolean().default(true),
|
||||
titleBarCompat: z.boolean().default(false),
|
||||
titleBarStripPx: z.number().step(1).min(TITLE_BAR_STRIP_MIN).max(TITLE_BAR_STRIP_MAX).default(TITLE_BAR_STRIP_DEFAULT),
|
||||
htmlViewerNoSandbox: z.boolean().default(false),
|
||||
htmlViewerDefaultUnsafe: z.boolean().default(false),
|
||||
browserNoSandbox: z.boolean().default(false),
|
||||
browserInterceptLinks: z.boolean().default(true),
|
||||
browserInterceptHttp: z.boolean().default(true),
|
||||
browserInterceptHttps: z.boolean().default(false),
|
||||
// Per-feature enable switches are OPEN maps (any tab/viewer id, built-in or
|
||||
// external): an absent key means enabled, so old documents resolve to {}
|
||||
// (everything on) with no migration. Non-boolean values fail validation.
|
||||
|
||||
+52
-5
@@ -60,6 +60,21 @@ export interface SidebarPrefs {
|
||||
* own enable switch gates it too (both must be on for the takeover).
|
||||
*/
|
||||
interceptOpenPath: boolean
|
||||
/**
|
||||
* Position compatibility mode: reserves space at the top for the native
|
||||
* Windows title bar (drawn at the window's top-right corner over the web
|
||||
* content in frameless/hidden-title-bar windows). When on, the toggle
|
||||
* cluster drops below the strip and the right panel's content starts
|
||||
* below it. Off by default — the sidebar layout is untouched.
|
||||
*/
|
||||
titleBarCompat: boolean
|
||||
/**
|
||||
* The reserved top strip height in px when `titleBarCompat` is on
|
||||
* (0–120, default 40). Drives the `--dsh-title-bar-strip` CSS variable:
|
||||
* the toggle cluster drops `strip + 3px` and the right panel's content
|
||||
* starts `strip` px below its top edge.
|
||||
*/
|
||||
titleBarStripPx: number
|
||||
/**
|
||||
* Whether the HTML previewer drops its sandboxed iframe. Sandbox ON (the
|
||||
* default) renders previewed HTML in an opaque-origin iframe that cannot
|
||||
@@ -85,13 +100,31 @@ export interface SidebarPrefs {
|
||||
*/
|
||||
browserNoSandbox: boolean
|
||||
/**
|
||||
* Whether clicking an http(s) EXTERNAL link in the GUI (chat messages,
|
||||
* tool rows, prose mentions) opens the sidebar browser instead of a new
|
||||
* browser tab. On by default; gated on the browser tab's own enable
|
||||
* switch (both must be on for the takeover). Ctrl/Cmd+click always
|
||||
* bypasses the takeover.
|
||||
* MASTER switch: whether clicking an EXTERNAL link in the GUI (chat
|
||||
* messages, tool rows, prose mentions) is taken over into the sidebar at
|
||||
* all. On by default; the per-protocol granularity lives in
|
||||
* `browserInterceptHttp` / `browserInterceptHttps` (the protocol flag
|
||||
* must also be on), and the target tab's own enable switch gates it too.
|
||||
* Ctrl/Cmd+click always bypasses the takeover. Kept as the master so old
|
||||
* documents keep their meaning with no migration (an explicit `false`
|
||||
* stays "never take over").
|
||||
*/
|
||||
browserInterceptLinks: boolean
|
||||
/**
|
||||
* Whether clicking an http EXTERNAL link in the GUI opens the sidebar
|
||||
* (the built-in browser tab, or a plugin tab that declares `urlTarget`)
|
||||
* instead of a new browser tab. On by default; gated on the
|
||||
* `browserInterceptLinks` master and the target tab's own enable switch.
|
||||
*/
|
||||
browserInterceptHttp: boolean
|
||||
/**
|
||||
* Whether clicking an https EXTERNAL link in the GUI opens the sidebar
|
||||
* instead of a new browser tab. OFF by default — most https sites (e.g.
|
||||
* GitHub) refuse iframe embedding, so the system browser is the smoother
|
||||
* default; gated on the `browserInterceptLinks` master and the target
|
||||
* tab's own enable switch.
|
||||
*/
|
||||
browserInterceptHttps: boolean
|
||||
/**
|
||||
* Per-tab enable switches, keyed by tab descriptor id (`'explorer'`,
|
||||
* `'my-plugin:db'`). An ABSENT key means enabled — only an explicit
|
||||
@@ -130,6 +163,11 @@ export const TERMINAL_FONT_SIZE_MIN = 9
|
||||
export const TERMINAL_FONT_SIZE_MAX = 32
|
||||
export const TERMINAL_FONT_SIZE_DEFAULT = 13
|
||||
|
||||
/** Range contract of {@link SidebarPrefs.titleBarStripPx}. */
|
||||
export const TITLE_BAR_STRIP_MIN = 0
|
||||
export const TITLE_BAR_STRIP_MAX = 120
|
||||
export const TITLE_BAR_STRIP_DEFAULT = 40
|
||||
|
||||
/** Fallback prefs used whenever the settings document is unreachable or malformed. */
|
||||
export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
|
||||
openByDefault: true,
|
||||
@@ -141,10 +179,14 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT,
|
||||
interceptOpenPath: true,
|
||||
titleBarCompat: false,
|
||||
titleBarStripPx: TITLE_BAR_STRIP_DEFAULT,
|
||||
htmlViewerNoSandbox: false,
|
||||
htmlViewerDefaultUnsafe: false,
|
||||
browserNoSandbox: false,
|
||||
browserInterceptLinks: true,
|
||||
browserInterceptHttp: true,
|
||||
browserInterceptHttps: false,
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
pluginSettings: {},
|
||||
@@ -159,3 +201,8 @@ export function clampWidthPercent(value: number): number {
|
||||
export function clampTerminalFontSize(value: number): number {
|
||||
return Math.min(TERMINAL_FONT_SIZE_MAX, Math.max(TERMINAL_FONT_SIZE_MIN, Math.round(value)))
|
||||
}
|
||||
|
||||
/** Clamp one title-bar strip height into the contract range (shared by schema and client reads). */
|
||||
export function clampTitleBarStrip(value: number): number {
|
||||
return Math.min(TITLE_BAR_STRIP_MAX, Math.max(TITLE_BAR_STRIP_MIN, Math.round(value)))
|
||||
}
|
||||
|
||||
+31
-4
@@ -9,6 +9,7 @@
|
||||
import { chmodSync, existsSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import { userInfo } from 'node:os'
|
||||
import * as nodePty from 'node-pty'
|
||||
import { SidebarError } from './wire.ts'
|
||||
|
||||
@@ -118,7 +119,7 @@ export class PtyManager {
|
||||
sessionId,
|
||||
tabId,
|
||||
cwd,
|
||||
pty: nodePty.spawn(this.shell, [], {
|
||||
pty: nodePty.spawn(this.shell, shellSpawnArgs(), {
|
||||
name: 'xterm-256color',
|
||||
cols: Math.max(2, Math.floor(cols)),
|
||||
rows: Math.max(2, Math.floor(rows)),
|
||||
@@ -191,9 +192,35 @@ export class PtyManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** The interactive shell for this platform (empty SHELL falls back). */
|
||||
/**
|
||||
* The interactive shell for this platform, resolved like a terminal
|
||||
* emulator: an explicit `$SHELL` on the dsh process wins (deployment
|
||||
* override), then the account's login shell from passwd, then `/bin/bash`.
|
||||
* The passwd step matters because service managers and container inits
|
||||
* often start dsh without `SHELL`, and the tab should still open the
|
||||
* user's login shell (e.g. zsh) instead of silently degrading to bash.
|
||||
* Windows short-circuits to `powershell.exe` before any resolution.
|
||||
*/
|
||||
export function defaultShell(): string {
|
||||
if (process.platform === 'win32') return 'powershell.exe'
|
||||
const shell = process.env.SHELL
|
||||
return shell !== undefined && shell.trim() !== '' ? shell : '/bin/bash'
|
||||
const envShell = process.env.SHELL
|
||||
if (envShell !== undefined && envShell.trim() !== '') return envShell
|
||||
// userInfo() throws when the uid has no passwd entry (rare chroots);
|
||||
// without a login shell there is nothing better than the bash default.
|
||||
try {
|
||||
const loginShell = userInfo().shell
|
||||
if (typeof loginShell === 'string' && loginShell.trim() !== '') return loginShell
|
||||
} catch {
|
||||
// no passwd entry: fall through to /bin/bash
|
||||
}
|
||||
return '/bin/bash'
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn arguments that make the shell behave like a terminal-emulator tab:
|
||||
* POSIX shells start as login shells (`-l`) so they read the profile files
|
||||
* (`~/.profile`, `~/.zprofile`); Windows PowerShell takes no login flag.
|
||||
*/
|
||||
export function shellSpawnArgs(): string[] {
|
||||
return process.platform === 'win32' ? [] : ['-l']
|
||||
}
|
||||
|
||||
+11
-6
@@ -67,14 +67,19 @@ describe('built-in tab registrations', () => {
|
||||
expect(toggles[1]?.type ?? 'switch').toBe('switch')
|
||||
})
|
||||
|
||||
it('the browser tab declares its sandbox and link-takeover related settings', () => {
|
||||
it('the browser tab declares its sandbox, link-takeover master and per-protocol settings', () => {
|
||||
const { service } = setup()
|
||||
const toggles = service.getTab('browser')?.settings?.toggles ?? []
|
||||
expect(toggles.map(t => t.key)).toEqual(['browserNoSandbox', 'browserInterceptLinks'])
|
||||
expect(toggles[0]?.title).toBeDefined()
|
||||
expect(toggles[0]?.desc).toBeDefined()
|
||||
expect(toggles[1]?.title).toBeDefined()
|
||||
expect(toggles[1]?.desc).toBeDefined()
|
||||
expect(toggles.map(t => t.key)).toEqual([
|
||||
'browserNoSandbox',
|
||||
'browserInterceptLinks',
|
||||
'browserInterceptHttp',
|
||||
'browserInterceptHttps',
|
||||
])
|
||||
for (const toggle of toggles) {
|
||||
expect(toggle.title).toBeDefined()
|
||||
expect(toggle.desc).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('the browser createTab mints browser:<n> ids and bumps nextBrowser', () => {
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Headless-render mount lane: prove the npm-packed plugin mounts into a real
|
||||
* `dsh web` instance and renders without crashing the shell.
|
||||
*
|
||||
* The server is NOT started here — `scripts/e2e-mount.sh` boots `dsh web`
|
||||
* (with the plugin mounted through the official `dsh plugin add` channel) and
|
||||
* injects the base URL via `DSH_E2E_URL`. This spec:
|
||||
*
|
||||
* 1. seeds one workspace + one session through the host's own RPC surface
|
||||
* (the same `workspace.create` / `session.create` calls the UI makes),
|
||||
* so the sidebar has a real session to render;
|
||||
* 2. loads the page in headless Chromium and asserts the shell and the
|
||||
* plugin's `[data-dsh-better-sidebar]` host mount;
|
||||
* 3. asserts the plugin's crash markers never appear (no RenderBoundary /
|
||||
* fail() strips, no `pageerror`, no plugin-prefixed console errors);
|
||||
* 4. sweeps every built-in tab (Explorer / Source Control / Tasks /
|
||||
* Terminal / Browser) — including the lazily-fetched terminal chunk —
|
||||
* and then opens a seeded file through the Explorer to force the
|
||||
* lazily-fetched editor chunk (client-editor.js) to load as well.
|
||||
*
|
||||
* Deterministic by construction: every wait is on a DOM/network marker, the
|
||||
* suite is serial (one server instance), and any crash trips the very next
|
||||
* assertion.
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { test, expect, request, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const BASE_URL = process.env.DSH_E2E_URL
|
||||
if (!BASE_URL) {
|
||||
throw new Error('DSH_E2E_URL is not set — boot a DSH web instance with the plugin mounted and point this lane at it (see scripts/e2e-mount.sh)')
|
||||
}
|
||||
|
||||
/** Workspace the sidebar renders against (created by the lane's seeding). */
|
||||
const WORKSPACE_PATH = process.env.DSH_E2E_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-workspace')
|
||||
|
||||
/** A file seeded into the workspace, opened through the Explorer to force the
|
||||
* lazily-packed editor chunk (client-editor.js) to load. */
|
||||
const SEEDED_FILE = 'hello.txt'
|
||||
|
||||
/**
|
||||
* The plugin's crash markers. The client mounts inside an error boundary that
|
||||
* renders a strip whose text starts with these prefixes instead of crashing
|
||||
* (see src/client/index.tsx `fail()` and src/client/RenderBoundary.tsx).
|
||||
*/
|
||||
const CRASH_STRIP_PATTERNS = [/^dsh-better-sidebar:/, /^\[dsh-better-sidebar\]/]
|
||||
|
||||
/** Built-in tab titles the sweep drives (en-US copy; follows DSH locale). */
|
||||
const BUILTIN_TABS = ['Explorer', 'Source Control', 'Tasks', 'Terminal', 'Browser']
|
||||
|
||||
let api: APIRequestContext
|
||||
|
||||
/** Seed one workspace + one session (plus a file for the editor-chunk probe)
|
||||
* through the host's unary RPC surface. */
|
||||
async function seedSession(): Promise<void> {
|
||||
mkdirSync(WORKSPACE_PATH, { recursive: true })
|
||||
writeFileSync(join(WORKSPACE_PATH, SEEDED_FILE), 'hello from the mount lane\n')
|
||||
const workspace = await api.post(`${BASE_URL}/api/workspace.create`, {
|
||||
data: { type: 'client-request', rpcId: 'e2e-workspace', method: 'workspace.create', payload: { path: WORKSPACE_PATH } },
|
||||
})
|
||||
expect(workspace.ok(), `workspace.create: ${workspace.status()} ${await workspace.text()}`).toBe(true)
|
||||
const workspaceBody = (await workspace.json()) as {
|
||||
result: { ok: true; value: { workspace: { workspaceId: string } } } | { ok: false; error: unknown }
|
||||
}
|
||||
expect(workspaceBody.result.ok).toBe(true)
|
||||
const workspaceId = (workspaceBody.result as { value: { workspace: { workspaceId: string } } }).value.workspace.workspaceId
|
||||
|
||||
const session = await api.post(`${BASE_URL}/api/session.create`, {
|
||||
data: { type: 'client-request', rpcId: 'e2e-session', method: 'session.create', payload: { workspaceId } },
|
||||
})
|
||||
expect(session.ok(), `session.create: ${session.status()} ${await session.text()}`).toBe(true)
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
api = await request.newContext({ baseURL: BASE_URL })
|
||||
await seedSession()
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await api?.dispose()
|
||||
})
|
||||
|
||||
test('plugin mounts into the DSH shell and survives a built-in tab sweep', async ({ page }) => {
|
||||
const pageErrors: string[] = []
|
||||
const consoleErrors: string[] = []
|
||||
page.on('pageerror', (error) => pageErrors.push(String(error)))
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') consoleErrors.push(message.text())
|
||||
})
|
||||
|
||||
// Load the shell. The app renders into #root; the plugin appends its own
|
||||
// [data-dsh-better-sidebar] host once its client half activates.
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
|
||||
const sidebar = page.locator('[data-dsh-better-sidebar]')
|
||||
await expect(sidebar).toBeAttached({ timeout: 90_000 })
|
||||
|
||||
// A keyless boot stacks onboarding takeovers that mask the whole shell: a
|
||||
// versioned welcome notice ("Continue", persists its acknowledgement to
|
||||
// settings) and, once that is acknowledged, a provider-config dialog
|
||||
// ("Configure later", session-only — always present while no credential is
|
||||
// configured). Both mount only after the settings join resolves. Wait
|
||||
// (bounded) for one to appear; a DSH build without onboarding proceeds
|
||||
// straight to the sweep.
|
||||
try {
|
||||
await expect
|
||||
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
|
||||
.toBeGreaterThan(0)
|
||||
} catch {
|
||||
console.warn('[e2e] no onboarding takeover appeared; proceeding without dismissal')
|
||||
}
|
||||
// Dismiss whatever takeover is present, in any stacking order, until none
|
||||
// remain — a masked click is retried next round instead of failing.
|
||||
for (let round = 0; round < 8; round++) {
|
||||
let dismissed = false
|
||||
for (const name of ['Continue', 'Configure later']) {
|
||||
const button = page.getByRole('button', { name, exact: true }).first()
|
||||
if ((await button.count()) === 0) continue
|
||||
try {
|
||||
await button.click({ timeout: 4_000 })
|
||||
dismissed = true
|
||||
await page.waitForTimeout(1_000)
|
||||
} catch {
|
||||
// Masked by the takeover stacked above it; the next round tries the
|
||||
// other button first.
|
||||
}
|
||||
}
|
||||
if (!dismissed) break
|
||||
}
|
||||
|
||||
// The seeded session must give the sidebar a session scope: without it the
|
||||
// shell renders a disabled toggle cluster and the tab sweep is impossible.
|
||||
const tabBar = sidebar.locator('[title]')
|
||||
await expect(tabBar.first()).toBeAttached({ timeout: 90_000 })
|
||||
|
||||
// Crash-marker assertions shared by every step.
|
||||
const assertNoCrash = async (): Promise<void> => {
|
||||
await expect
|
||||
.poll(async () => pageErrors, { timeout: 5_000 })
|
||||
.toEqual([])
|
||||
const strips = await sidebar.locator('div').evaluateAll(
|
||||
(nodes, patterns) => nodes.filter((node) => {
|
||||
const text = (node.textContent ?? '').trim()
|
||||
return patterns.some((pattern) => pattern.test(text))
|
||||
}).length,
|
||||
CRASH_STRIP_PATTERNS,
|
||||
)
|
||||
expect(strips, 'a dsh-better-sidebar error strip is present in the sidebar').toBe(0)
|
||||
}
|
||||
|
||||
// Sweep every built-in tab through the "+" menu (the sidebar's own open-tab
|
||||
// affordance, reachable from any pane state). Each open may fetch a lazy
|
||||
// chunk (/sidebar/bundle/client-terminal.js / client-editor.js) and mount a
|
||||
// real viewer — the highest-risk crash surfaces. The pinned plugin must
|
||||
// offer every listed built-in: a missing or renamed descriptor is a real
|
||||
// regression and fails the lane loudly instead of silently narrowing the
|
||||
// sweep. A failure anywhere surfaces as a pageerror or a console error,
|
||||
// both of which the next assertion sees.
|
||||
const newTabButton = sidebar.getByRole('button', { name: 'New tab' }).first()
|
||||
for (const title of BUILTIN_TABS) {
|
||||
await newTabButton.click()
|
||||
const item = page.getByRole('menuitem', { name: title }).first()
|
||||
await expect(item, `built-in tab "${title}" is not offered by the + menu — descriptor removed or its label changed`).toHaveCount(1)
|
||||
await item.click()
|
||||
// Let the activation commit (including any lazy-chunk fetch) before the
|
||||
// crash assertions run.
|
||||
await page.waitForTimeout(1_500)
|
||||
await assertNoCrash()
|
||||
}
|
||||
|
||||
// The editor tab is hidden from the + menu — its CodeMirror chunk
|
||||
// (client-editor.js) only loads when a file is opened. Exercise that path
|
||||
// explicitly: reopen Explorer, open the seeded file, and require the chunk
|
||||
// round-trip, so a missing/corrupt editor chunk fails the lane.
|
||||
await newTabButton.click()
|
||||
const explorerItem = page.getByRole('menuitem', { name: 'Explorer' }).first()
|
||||
await expect(explorerItem, 'Explorer must be re-openable for the editor-chunk probe').toHaveCount(1)
|
||||
await explorerItem.click()
|
||||
const editorChunk = page.waitForResponse(
|
||||
(response) => response.url().includes('/sidebar/bundle/editor.js'),
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
const fileRow = sidebar.locator(`[role="button"][title$="${SEEDED_FILE}"]`)
|
||||
await expect(fileRow, `the seeded "${SEEDED_FILE}" file must appear in the Explorer tree`).toHaveCount(1, { timeout: 30_000 })
|
||||
await fileRow.click()
|
||||
await editorChunk
|
||||
await page.waitForTimeout(1_500)
|
||||
await assertNoCrash()
|
||||
|
||||
// The plugin's own console prefix must never appear in errors, and no
|
||||
// unhandled rejection may escape the sweep.
|
||||
const pluginErrors = consoleErrors.filter((text) => /dsh-better-sidebar|Unhandled/.test(text))
|
||||
expect(pluginErrors, 'plugin-prefixed or unhandled console errors during the sweep').toEqual([])
|
||||
expect(pageErrors, 'pageerrors during the sweep').toEqual([])
|
||||
|
||||
// Final screenshot: the rendered panel with a session is the lane's proof.
|
||||
await page.screenshot({ path: 'test-results/mount-final.png' })
|
||||
})
|
||||
@@ -1,10 +1,12 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* External-link interception policy tests: the pure decision (which http(s)
|
||||
* external links are taken over into the sidebar browser) and the click
|
||||
* shape (plain left clicks only — modified clicks always bypass).
|
||||
* external links are taken over into the sidebar browser), the click shape
|
||||
* (plain left clicks only — modified clicks always bypass), and the
|
||||
* registered click capture (the caller's `takeoverEnabled(url)` gate).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isPlainLeftClick, shouldInterceptLink } from '../src/client/link-intercept.ts'
|
||||
import { isPlainLeftClick, registerLinkInterception, shouldInterceptLink } from '../src/client/link-intercept.ts'
|
||||
|
||||
const SELF = 'http://127.0.0.1:3080'
|
||||
|
||||
@@ -55,3 +57,66 @@ describe('isPlainLeftClick', () => {
|
||||
expect(isPlainLeftClick({ ...plain, button: 2 })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerLinkInterception', () => {
|
||||
/** Dispatch one click on an anchor with the given absolute href. */
|
||||
const clickAnchor = (href: string, init: MouseEventInit = {}): void => {
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = href
|
||||
document.body.appendChild(anchor)
|
||||
anchor.dispatchEvent(new MouseEvent('click', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
...init,
|
||||
}))
|
||||
anchor.remove()
|
||||
}
|
||||
|
||||
it('passes the parsed URL to takeoverEnabled and only takes over when it returns true', () => {
|
||||
const seen: string[] = []
|
||||
const opened: string[] = []
|
||||
const dispose = registerLinkInterception({
|
||||
takeoverEnabled: (url) => {
|
||||
seen.push(url.protocol)
|
||||
return url.protocol === 'http:'
|
||||
},
|
||||
openInSidebar: (url) => { opened.push(url) },
|
||||
selfOrigin: SELF,
|
||||
})
|
||||
// https → the gate refuses (the sidebar-takeover defaults: https off).
|
||||
clickAnchor('https://example.com/page')
|
||||
// http → the gate passes, the click is taken over with the absolute href.
|
||||
clickAnchor('http://example.com/page')
|
||||
expect(seen).toEqual(['https:', 'http:'])
|
||||
expect(opened).toEqual(['http://example.com/page'])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('never consults the gate for same-origin or non-http(s) links', () => {
|
||||
let gateCalls = 0
|
||||
const dispose = registerLinkInterception({
|
||||
takeoverEnabled: () => { gateCalls++; return true },
|
||||
openInSidebar: () => { throw new Error('must not open') },
|
||||
selfOrigin: SELF,
|
||||
})
|
||||
clickAnchor(`${SELF}/settings`)
|
||||
clickAnchor('mailto:a@b.c')
|
||||
expect(gateCalls).toBe(0)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('bypasses the takeover on modified clicks even when the gate passes', () => {
|
||||
let gateCalls = 0
|
||||
const dispose = registerLinkInterception({
|
||||
takeoverEnabled: () => { gateCalls++; return true },
|
||||
openInSidebar: () => { throw new Error('must not open') },
|
||||
selfOrigin: SELF,
|
||||
})
|
||||
clickAnchor('http://example.com/', { ctrlKey: true })
|
||||
clickAnchor('http://example.com/', { metaKey: true })
|
||||
clickAnchor('http://example.com/', { button: 1 })
|
||||
expect(gateCalls).toBe(0)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -54,6 +54,10 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
// and 13px.
|
||||
expect(resolved.terminalFontFamily).toBe('')
|
||||
expect(resolved.terminalFontSize).toBe(13)
|
||||
// The position-compat mode defaults OFF (the normal layout is default),
|
||||
// with the strip defaulting to 40px.
|
||||
expect(resolved.titleBarCompat).toBe(false)
|
||||
expect(resolved.titleBarStripPx).toBe(40)
|
||||
// The enable-switch maps resolve to {} (everything on) for old documents.
|
||||
expect(resolved.tabsEnabled).toEqual({})
|
||||
expect(resolved.viewersEnabled).toEqual({})
|
||||
@@ -63,6 +67,6 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
const overridden = (PrefsSchema as unknown as {
|
||||
(input: Record<string, unknown> | undefined): Record<string, unknown>
|
||||
})({ openByDefault: false, defaultWidthPercent: 45 })
|
||||
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
})
|
||||
})
|
||||
|
||||
+50
-1
@@ -20,7 +20,7 @@ if (g.localStorage === undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
import { createBetterSidebarService, SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from '../src/client/service.ts'
|
||||
import { createBetterSidebarService, matchUrlTarget, SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from '../src/client/service.ts'
|
||||
import { createSidebarStore, allLeaves, makeDefaultState, openDiffTab, sanitizeState } from '../src/client/state.ts'
|
||||
|
||||
describe('BetterSidebar service', () => {
|
||||
@@ -223,6 +223,55 @@ describe('matchFileViewer', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchUrlTarget (v0.13.0)', () => {
|
||||
/** A tab descriptor that claims a URL host. */
|
||||
const claimingTab = (id: string, host: string) => ({
|
||||
id,
|
||||
title: id,
|
||||
urlTarget: (url: URL): boolean => url.hostname === host,
|
||||
component: () => null,
|
||||
})
|
||||
|
||||
it('returns undefined when no tab declares urlTarget', () => {
|
||||
const tabs = [
|
||||
{ id: 'explorer', title: 'E', component: () => null },
|
||||
{ id: 'git', title: 'G', component: () => null },
|
||||
]
|
||||
expect(matchUrlTarget(tabs, new URL('http://example.com/'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns the first REGISTRATION-ORDER claim (first match wins)', () => {
|
||||
const tabs = [
|
||||
claimingTab('my:one', 'example.com'),
|
||||
claimingTab('my:two', 'example.com'),
|
||||
]
|
||||
expect(matchUrlTarget(tabs, new URL('https://example.com/x'))?.id).toBe('my:one')
|
||||
// A non-matching earlier tab yields to the next match.
|
||||
const tabs2 = [
|
||||
claimingTab('my:one', 'other.com'),
|
||||
claimingTab('my:two', 'example.com'),
|
||||
]
|
||||
expect(matchUrlTarget(tabs2, new URL('https://example.com/x'))?.id).toBe('my:two')
|
||||
})
|
||||
|
||||
it('skips a throwing predicate (the next claim still wins)', () => {
|
||||
const tabs = [
|
||||
{ id: 'my:broken', title: 'B', urlTarget: () => { throw new Error('boom') }, component: () => null },
|
||||
claimingTab('my:ok', 'example.com'),
|
||||
]
|
||||
expect(matchUrlTarget(tabs, new URL('https://example.com/x'))?.id).toBe('my:ok')
|
||||
})
|
||||
|
||||
it('never matches the built-in browser (no urlTarget declared — the caller falls back)', () => {
|
||||
const tabs = [
|
||||
{ id: 'browser', title: 'Browser', component: () => null },
|
||||
{ id: 'my:ok', title: 'OK', urlTarget: () => true, component: () => null },
|
||||
]
|
||||
expect(matchUrlTarget(tabs, new URL('http://example.com/'))?.id).toBe('my:ok')
|
||||
expect(matchUrlTarget([{ id: 'browser', title: 'Browser', component: () => null }], new URL('http://example.com/'))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('service.openTab dedupe', () => {
|
||||
it('dedupeKey focuses existing tab instead of duplicating', () => {
|
||||
const store = createSidebarStore()
|
||||
|
||||
@@ -151,6 +151,28 @@ describe('SideCardSection declarative inventory', () => {
|
||||
const html = renderSection(store, service)
|
||||
expect(html).not.toContain('Feature settings')
|
||||
})
|
||||
|
||||
it('renders the position-compat mode general row: off by default, checked when the pref is on', () => {
|
||||
const { store, service } = mount()
|
||||
let html = renderSection(store, service)
|
||||
// The general row renders its title and description.
|
||||
expect(html).toContain('Position compatibility mode')
|
||||
expect(html).toContain('Reserve space for the native Windows title bar')
|
||||
// Three general rows now: openByDefault + interceptOpenPath checked,
|
||||
// the new titleBarCompat row UNCHECKED (default off) — the checked
|
||||
// checkbox count stays at 2 while the total checkbox count is 3.
|
||||
expect(html.match(/type="checkbox"/g)?.length).toBe(3)
|
||||
expect(html.match(/checked=""/g)?.length).toBe(2)
|
||||
// The row's gear (customize the shift distance) is dormant while the
|
||||
// mode is off — the feature-card convention.
|
||||
expect(html).not.toContain('Position compatibility mode Feature settings')
|
||||
|
||||
// When the pref is on, the new switch is checked and the gear appears.
|
||||
store.setPrefs({ ...store.getPrefs(), titleBarCompat: true })
|
||||
html = renderSection(store, service)
|
||||
expect(html.match(/checked=""/g)?.length).toBe(3)
|
||||
expect(html).toContain('aria-label="Position compatibility mode Feature settings"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('FeatureSettingsRows (the secondary settings popup body)', () => {
|
||||
@@ -229,6 +251,31 @@ describe('FeatureSettingsRows (the secondary settings popup body)', () => {
|
||||
expect(html).toContain('px')
|
||||
expect(html).not.toContain('type="checkbox"')
|
||||
})
|
||||
|
||||
it('renders the title-bar strip row: the pref value, the 0–120 bounds and the px suffix', () => {
|
||||
const html = renderToString(createElement(FeatureSettingsRows, {
|
||||
toggles: [{
|
||||
key: 'titleBarStripPx',
|
||||
type: 'number',
|
||||
title: () => 'Shift distance',
|
||||
desc: () => 'Title-bar strip height in px',
|
||||
min: 0,
|
||||
max: 120,
|
||||
unit: 'px',
|
||||
}],
|
||||
prefs: { ...prefs, titleBarStripPx: 64 },
|
||||
onToggle: () => {},
|
||||
onCommit: () => '64',
|
||||
}))
|
||||
expect(html).toContain('Shift distance')
|
||||
expect(html).toContain('Title-bar strip height in px')
|
||||
expect(html).toContain('type="number"')
|
||||
expect(html).toContain('value="64"')
|
||||
expect(html).toContain('min="0"')
|
||||
expect(html).toContain('max="120"')
|
||||
expect(html).toContain('px')
|
||||
expect(html).not.toContain('type="checkbox"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergePluginSetting (v0.12.0, codex review fix)', () => {
|
||||
|
||||
@@ -188,6 +188,34 @@ describe('host plugin smoke', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('spawns the shell as a login shell (loads ~/.profile)', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-sidebar-login-'))
|
||||
const previousHome = process.env.HOME
|
||||
try {
|
||||
// A login bash reads ~/.profile (a non-login interactive bash reads
|
||||
// ~/.bashrc instead), so this marker proves the spawn used a login
|
||||
// argv — the terminal-emulator behavior the tab should match.
|
||||
writeFileSync(join(home, '.profile'), 'export DSH_LOGIN_MARKER=loaded-from-profile\n')
|
||||
process.env.HOME = home
|
||||
const manager = new PtyManager('/bin/bash', 3)
|
||||
try {
|
||||
const handle = manager.open('s5', 't1', process.cwd(), 80, 24)
|
||||
handle.pty.write('echo $DSH_LOGIN_MARKER\r')
|
||||
const deadline = Date.now() + 5000
|
||||
while (!handle.transcript.includes('loaded-from-profile') && Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
}
|
||||
expect(handle.transcript).toContain('loaded-from-profile')
|
||||
} finally {
|
||||
manager.disposeAll()
|
||||
}
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME
|
||||
else process.env.HOME = previousHome
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('lists the repository root level', async () => {
|
||||
const listing = await listDirectory(process.cwd(), 1000)
|
||||
expect(listing.entries.some(entry => entry.name === 'src' && entry.isDir)).toBe(true)
|
||||
@@ -520,10 +548,14 @@ describe('side card settings routes', () => {
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: 13,
|
||||
interceptOpenPath: true,
|
||||
titleBarCompat: false,
|
||||
titleBarStripPx: 40,
|
||||
htmlViewerNoSandbox: false,
|
||||
htmlViewerDefaultUnsafe: false,
|
||||
browserNoSandbox: false,
|
||||
browserInterceptLinks: true,
|
||||
browserInterceptHttp: true,
|
||||
browserInterceptHttps: false,
|
||||
// The enable-switch maps default to {} (everything on).
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
|
||||
+81
-9
@@ -1,4 +1,17 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Pin the passwd login shell to a non-bash value for the pty helpers tests:
|
||||
// the CI runner's own login shell is /bin/bash, which the old hardcoded
|
||||
// fallback would have satisfied by coincidence, so the fallback chain needs
|
||||
// a mock passwd to be genuinely discriminating. No other test in this file
|
||||
// reads os.userInfo.
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const os = await importOriginal<typeof import('node:os')>()
|
||||
return {
|
||||
...os,
|
||||
userInfo: () => ({ ...os.userInfo(), shell: '/usr/bin/zsh' }),
|
||||
}
|
||||
})
|
||||
import { compareEntries, isWithin, parentOf, rootLabel, requireAbsolute } from '../src/fs-tree.ts'
|
||||
import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
|
||||
import { parseUnifiedDiff } from '../src/client/DiffView.tsx'
|
||||
@@ -846,13 +859,21 @@ describe('agent terminal reconciliation', () => {
|
||||
})
|
||||
|
||||
describe('pty helpers', () => {
|
||||
it('falls back from an empty SHELL to a usable shell', () => {
|
||||
it('prefers an explicit SHELL, then falls back to the account login shell', () => {
|
||||
if (process.platform === 'win32') {
|
||||
// defaultShell() short-circuits to powershell.exe before env/passwd
|
||||
// resolution on Windows; the POSIX fallback chain is asserted below.
|
||||
expect(defaultShell()).toBe('powershell.exe')
|
||||
return
|
||||
}
|
||||
const previous = process.env.SHELL
|
||||
try {
|
||||
process.env.SHELL = ''
|
||||
expect(defaultShell()).toBe('/bin/bash')
|
||||
process.env.SHELL = '/explicit/zsh'
|
||||
expect(defaultShell()).toBe('/explicit/zsh')
|
||||
process.env.SHELL = ' '
|
||||
expect(defaultShell()).toBe('/usr/bin/zsh')
|
||||
delete process.env.SHELL
|
||||
expect(defaultShell()).toBe('/bin/bash')
|
||||
expect(defaultShell()).toBe('/usr/bin/zsh')
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.SHELL
|
||||
else process.env.SHELL = previous
|
||||
@@ -1140,10 +1161,14 @@ describe('side card preferences', () => {
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: 13,
|
||||
interceptOpenPath: true,
|
||||
titleBarCompat: false,
|
||||
titleBarStripPx: 40,
|
||||
htmlViewerNoSandbox: false,
|
||||
htmlViewerDefaultUnsafe: false,
|
||||
browserNoSandbox: false,
|
||||
browserInterceptLinks: true,
|
||||
browserInterceptHttp: true,
|
||||
browserInterceptHttps: false,
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
pluginSettings: {},
|
||||
@@ -1162,10 +1187,14 @@ describe('side card preferences', () => {
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: 13,
|
||||
interceptOpenPath: true,
|
||||
titleBarCompat: false,
|
||||
titleBarStripPx: 40,
|
||||
htmlViewerNoSandbox: false,
|
||||
htmlViewerDefaultUnsafe: false,
|
||||
browserNoSandbox: false,
|
||||
browserInterceptLinks: true,
|
||||
browserInterceptHttp: true,
|
||||
browserInterceptHttps: false,
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
pluginSettings: {},
|
||||
@@ -1184,10 +1213,14 @@ describe('side card preferences', () => {
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: 13,
|
||||
interceptOpenPath: true,
|
||||
titleBarCompat: false,
|
||||
titleBarStripPx: 40,
|
||||
htmlViewerNoSandbox: false,
|
||||
htmlViewerDefaultUnsafe: false,
|
||||
browserNoSandbox: false,
|
||||
browserInterceptLinks: true,
|
||||
browserInterceptHttp: true,
|
||||
browserInterceptHttps: false,
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
pluginSettings: {},
|
||||
@@ -1218,6 +1251,45 @@ describe('side card preferences', () => {
|
||||
expect((await loadPrefs(wire({ interceptOpenPath: true }))).interceptOpenPath).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults titleBarCompat to false; only an explicit true turns the position-compat mode on', async () => {
|
||||
// Absent or malformed → off (the normal layout is the default).
|
||||
expect((await loadPrefs(wire({}))).titleBarCompat).toBe(false)
|
||||
expect((await loadPrefs(wire({ titleBarCompat: 'yes' }))).titleBarCompat).toBe(false)
|
||||
expect((await loadPrefs(wire({ titleBarCompat: 1 }))).titleBarCompat).toBe(false)
|
||||
// Explicit booleans survive verbatim.
|
||||
expect((await loadPrefs(wire({ titleBarCompat: false }))).titleBarCompat).toBe(false)
|
||||
expect((await loadPrefs(wire({ titleBarCompat: true }))).titleBarCompat).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults titleBarStripPx to 40 and clamps stored values into the contract range', async () => {
|
||||
// Absent or malformed → 40 (the strip default).
|
||||
expect((await loadPrefs(wire({}))).titleBarStripPx).toBe(40)
|
||||
expect((await loadPrefs(wire({ titleBarStripPx: 'yes' }))).titleBarStripPx).toBe(40)
|
||||
// Out-of-range numbers clamp into 0–120.
|
||||
expect((await loadPrefs(wire({ titleBarStripPx: -5 }))).titleBarStripPx).toBe(0)
|
||||
expect((await loadPrefs(wire({ titleBarStripPx: 200 }))).titleBarStripPx).toBe(120)
|
||||
expect((await loadPrefs(wire({ titleBarStripPx: 47.6 }))).titleBarStripPx).toBe(48)
|
||||
// In-range values survive verbatim.
|
||||
expect((await loadPrefs(wire({ titleBarStripPx: 0 }))).titleBarStripPx).toBe(0)
|
||||
expect((await loadPrefs(wire({ titleBarStripPx: 64 }))).titleBarStripPx).toBe(64)
|
||||
})
|
||||
|
||||
it('defaults the link-takeover protocol flags: http on, https off, master on', async () => {
|
||||
// Absent or malformed → the per-protocol defaults.
|
||||
expect((await loadPrefs(wire({}))).browserInterceptLinks).toBe(true)
|
||||
expect((await loadPrefs(wire({}))).browserInterceptHttp).toBe(true)
|
||||
expect((await loadPrefs(wire({}))).browserInterceptHttps).toBe(false)
|
||||
expect((await loadPrefs(wire({ browserInterceptHttp: 'yes' }))).browserInterceptHttp).toBe(true)
|
||||
expect((await loadPrefs(wire({ browserInterceptHttps: 0 }))).browserInterceptHttps).toBe(false)
|
||||
// Explicit booleans survive verbatim.
|
||||
expect((await loadPrefs(wire({ browserInterceptHttp: false }))).browserInterceptHttp).toBe(false)
|
||||
expect((await loadPrefs(wire({ browserInterceptHttps: true }))).browserInterceptHttps).toBe(true)
|
||||
// The master is independent of the protocol flags (an explicit master
|
||||
// false stays "never take over" regardless of the flags).
|
||||
expect((await loadPrefs(wire({ browserInterceptLinks: false, browserInterceptHttp: true, browserInterceptHttps: true }))))
|
||||
.toMatchObject({ browserInterceptLinks: false, browserInterceptHttp: true, browserInterceptHttps: true })
|
||||
})
|
||||
|
||||
it('resolves the terminal font prefs (family passthrough, size clamp)', async () => {
|
||||
// Absent → theme default (empty family) + default size.
|
||||
expect((await loadPrefs(wire({}))).terminalFontFamily).toBe('')
|
||||
@@ -1251,9 +1323,9 @@ describe('side card preferences', () => {
|
||||
const store = createSidebarStore()
|
||||
// Node environment: no window → the width falls back to PANEL_DEFAULT,
|
||||
// while the open flag still follows the preference.
|
||||
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setSession('fresh-session')
|
||||
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
const snapshot = store.getSnapshot()
|
||||
expect(snapshot.sessionId).toBe('fresh-session')
|
||||
expect(snapshot.state?.panelOpen).toBe(false)
|
||||
@@ -1289,7 +1361,7 @@ describe('side card preferences', () => {
|
||||
|
||||
it('skips the default explorer tab when the explorer type is disabled', () => {
|
||||
const store = createSidebarStore()
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: { explorer: false }, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setSession('no-explorer')
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
|
||||
@@ -1297,7 +1369,7 @@ describe('side card preferences', () => {
|
||||
expect(state.splits.kind).toBe('leaf')
|
||||
// Re-enabling seeds the explorer tab again.
|
||||
const openStore = createSidebarStore()
|
||||
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
openStore.setSession('with-explorer')
|
||||
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
expect(openTabs.map(tab => tab.type)).toEqual(['explorer'])
|
||||
|
||||
@@ -19,5 +19,18 @@ export default defineConfig({
|
||||
inline: [/@deepseek-ai\/dsh-client-ui-primitives/],
|
||||
},
|
||||
},
|
||||
// The Playwright headless-render lane lives in tests/e2e (specs named
|
||||
// *.e2e.ts). Keep vitest from ever collecting it, both by naming (the
|
||||
// default include only matches *.test.* / *.spec.*) and by an explicit
|
||||
// exclude. NOTE: `exclude` REPLACES vitest's defaults, so the standard
|
||||
// node_modules/dist/etc. excludes must be restated here.
|
||||
exclude: [
|
||||
'tests/e2e/**',
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/cypress/**',
|
||||
'**/.{idea,git,cache,output,temp}/**',
|
||||
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*',
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user