Compare commits

..
23 Commits
Author SHA1 Message Date
Menghuan1918 b6ef4d544a docs: README 标题下新增 MIT/dshfind 徽章,功能小标统一为徽章样式;最近更新补充 v0.12.2 文本说明(双语同步) 2026-08-15 21:35:49 +08:00
Atlas Zeng 5d2d6e5801 Merge pull request #88 from omdsh-dev/feat/title-bar-compat
feat: 侧边栏新增位置兼容模式(v0.12.2)
2026-08-15 15:53:35 +08:00
Atlas Zeng 30d0b103f6 Merge pull request #72 from NolanHo/feat/dev
fix: 终端 Tab 使用账户 login shell 并以 login 模式启动
2026-08-15 15:53:26 +08:00
Menghuan1918 8ad1766390 feat: 位置兼容模式支持自定义下移距离(齿轮弹窗数字输入)
titleBarStripPx 新偏好(0–120px,默认 40):侧边栏按钮与内容的下移量改为
CSS 变量 --dsh-title-bar-strip 驱动,Sidebar shell 随 prefs 写入。设置页
「常规」的位置兼容模式行在开启时显示齿轮小图标,弹窗内用数字行设置下移
距离(复用 FeatureSettingsRows 行控件与提交钳制)。
2026-08-15 15:46:56 +08:00
Menghuan1918 648ff27b5f chore: 版本号升至 v0.12.2(位置兼容模式随本次发布) 2026-08-15 15:37:31 +08:00
Menghuan1918 1e7c72ccbe feat: 侧边栏新增位置兼容模式,为 Windows 原生标题栏预留顶部空间
新增 SidebarPrefs.titleBarCompat 设置项(默认关闭):开启后 toggle cluster
下移 40px、右侧面板内容从标题栏条带下方开始,避免被 Windows 无边框窗口
右上角的原生标题栏遮挡。底部面板不受影响。设置页「常规」新增一行开关,
zh/en 双语,读写下穿既有 settings 路由。测试覆盖 parsePrefs 回退、schema
默认值、设置页渲染与开关状态。
2026-08-15 15:37:31 +08:00
Menghuan1918 5c27882995 docs: 仓库硬约束新增「代码改动必须走 PR,仅文档类改动可直推 main」 2026-08-15 15:36:12 +08:00
Menghuan1918 ad787ab22d Revert "feat: 侧边栏新增位置兼容模式,为 Windows 原生标题栏预留顶部空间"
This reverts commit ae71aecb83.
2026-08-15 15:36:12 +08:00
Menghuan1918 ae71aecb83 feat: 侧边栏新增位置兼容模式,为 Windows 原生标题栏预留顶部空间
新增 SidebarPrefs.titleBarCompat 设置项(默认关闭):开启后 toggle cluster
下移 40px、右侧面板内容从标题栏条带下方开始,避免被 Windows 无边框窗口
右上角的原生标题栏遮挡。底部面板不受影响。设置页「常规」新增一行开关,
zh/en 双语,读写下穿既有 settings 路由。测试覆盖 parsePrefs 回退、schema
默认值、设置页渲染与开关状态。
2026-08-15 15:24:46 +08:00
Atlas Zeng 1a0d530711 Merge pull request #74 from NolanHo/feat/link-interception-toggle
feat: 服务化 API 新增 urlTarget 外链认领 + 浏览器 HTTP/HTTPS 二级设置开关
2026-08-15 15:20:23 +08:00
Menghuan1918 29f162dac0 feat: 服务化 API 新增 urlTarget 外链认领 + 浏览器 HTTP/HTTPS 二级设置开关
基于 PR #74 的颗粒度讨论向前兼容拓展服务化 API:
- TabDescriptor 新增可选 urlTarget(url) 谓词:聊天/界面外链被拦截时按注册
  顺序路由到第一个命中的插件 tab 类型(openTab({ type, url, title })),
  内置 browser 退居隐式兜底;SIDEBAR_FEATURES 追加 'urlTarget'
- 浏览器链接设置改为二级菜单(齿轮弹窗):新增 browserInterceptHttp
  (默认开)+ browserInterceptHttps(默认关);browserInterceptLinks
  保留为总闸(默认开,旧文档零迁移)
- 拦截管线:registerLinkInterception 的 takeoverEnabled 改收解析后的
  URL,协议/同源纯判定(shouldInterceptLink)签名不变
- 测试:matchUrlTarget(注册顺序/抛错吞掉/browser 永不命中)、jsdom
  点击接管门控、parsePrefs 新字段默认值;全默认字面量同步
- 文档:设计文档 + AGENTS.md(字段/示例/设置键)+ README(外链分流)
2026-08-15 15:05:06 +08:00
Atlas Zeng 5bd961f7f1 Merge pull request #76 from ChenRuoT/add-dsh-sidebar-qa
feat: 推荐插件目录收录 dsh-sidebar-qa (Tab 注册)
2026-08-15 11:23:20 +08:00
Menghuan1918 f842fa0029 docs: 友情链接收录 DeepSeek Harness Desktop(issue #78) 2026-08-15 10:47:30 +08:00
ChenRuoT 0b5405117d feat: 推荐插件目录收录 dsh-sidebar-qa (Tab 注册)
plugins-tabs.ts 收录 dsh-sidebar-qa (划选提问tab)
install 拼接官方一键命令
2026-08-15 02:28:57 +08:00
Atlas Zeng 2bace68af8 Merge pull request #71 from omdsh-dev/ci/mount-smoke
ci: 新增插件挂载冒烟门禁(npm 打包 → 真实挂载 DSH → 无头渲染防 crash)
2026-08-15 01:55:39 +08:00
Menghuan1918 f903ac77d6 test(e2e): 落实 Codex 审查意见 — 强制覆盖 editor 懒加载 chunk + 缺失内置 tab 即失败
- seed hello.txt,经 Explorer 打开文件并等待 /sidebar/bundle/editor.js 响应,
  缺失/损坏的 editor chunk 会令门禁变红(已实测:删 chunk → 错误条被检出)
- + 菜单缺失内置 tab 由跳过改为 toHaveCount(1) 硬失败,防深扫悄悄缩水
- AGENTS.md CI 冒烟说明同步
2026-08-15 01:52:54 +08:00
Atlas Zeng 33ee02e747 Update README.md (#70) 2026-08-15 01:26:56 +08:00
Menghuan1918 7ee0b457a3 feat: CI 挂载冒烟门禁 — npm 打包 → 真实挂载 DSH → 无头渲染防 crash
新增 plugin-mount job(pnpm test:mount):
- scripts/e2e-mount.sh:scratch profile 引导(allowBuilds/minimumReleaseAgeExclude 预写)→ 官方
  dsh plugin --profile web add file:<tarball> 挂载(dsh.profile.bundles 协调校验)→ keyless
  dsh web --port 0 → Playwright 无头渲染 → trap 清理
- tests/e2e/mount.e2e.ts:seed 工作区+会话(宿主 RPC)→ 断言 #root 与 [data-dsh-better-sidebar]
  挂载、无崩溃标记(错误条/pageerror/插件 console 错误)→ + 菜单逐个打开内置 tab 深扫
- playwright.config.ts:chromium headless、单 worker、retries 0、失败留 trace/截图/HTML 报告
- vitest exclude 与 *.e2e.ts 命名双保险隔离;exclude 整体替换默认值,须保留默认排除项
- CI 钉住 @deepseek-ai/dsh@0.1.0-rc.6;DSH_TELEMETRY_DISABLED=1;workflow_dispatch + PR 并发取消

已验证:本地端到端全绿;人为注入 tab 组件 crash → 门禁变红(错误条检出),还原后变绿;
typecheck/vitest 全绿;CI 同款 npm 安装 CLI 路径实测通过
2026-08-15 01:21:21 +08:00
Menghuan1918 d478780e43 docs: README 语气专业化并放大最近更新截图
- Hero 特性标签新增 <code>插件接入</code>(Plugin integration),以 code 标签承载插件注册接入能力
- 移除口语化表达(吃自己的狗粮 / 把自己当用户),改用中性措辞
- 「我们的态度」改为「核心理念」,表述更正式
- 最近更新截图放大(320 → 480)
- 同步英文 README
2026-08-15 01:18:58 +08:00
NolanHo 76fa7df264 fix: resolve the account login shell and spawn terminals as login shells
Terminal tabs resolved the shell from the dsh process environment only,
falling back to a hardcoded /bin/bash — service-managed launches without
SHELL therefore ignored the account's login shell (e.g. zsh). They also
spawned with an empty argv, so even a resolved zsh ran as a non-login
shell and skipped ~/.zprofile.

defaultShell() now falls back to the account's login shell (passwd) after
$SHELL, and both registries spawn POSIX shells with -l so the tab behaves
like a terminal emulator (loads ~/.profile / ~/.zprofile). Windows keeps
powershell.exe with no login flag.

Fixes omdsh-dev/DSH-better-sidebar#68
2026-08-14 10:15:34 -07:00
Atlas Zeng acd4a8313c Update README.md 2026-08-15 01:14:29 +08:00
Menghuan1918 cb6f2cdc8f docs: README 服务化定位前置、精简功能一览并修正内置 viewer 计数
- 开头(Hero)声明服务化框架定位:ctx.betterSidebar 服务 + registerTab/registerFileViewer,内置 tab/viewer 与外部插件走同一套服务
- Hero 附 GitHub topic dsh-better-sidebar,说明生态位插件
- 修复展示视频(裸 URL → <video> 内嵌)与截图排版
- 功能一览 15 条精简为 10 条,突出重点;新增「我们的态度」引言
- 最近更新保留截图列,整理为可点击缩略图
- 修正内置 viewer 计数 9 → 6(Office 预览已迁至推荐插件)
- 同步英文 README
2026-08-15 01:09:42 +08:00
Atlas Zeng 3825fced25 Update README.md 2026-08-15 00:49:59 +08:00
36 changed files with 1419 additions and 143 deletions
+62
View File
@@ -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
+5
View File
@@ -149,3 +149,8 @@ registry/
# stray npm cache scratch dir
.npm-cache-tmp/
# Playwright headless-render lane (tests/e2e) artifacts
playwright-report/
test-results/
+42 -3
View File
@@ -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 起也有齿轮)。
---
+44 -40
View File
@@ -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
View File
@@ -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 &amp; 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 &amp; 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)
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+39
View File
@@ -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',
},
})
+38
View File
@@ -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
+159
View File
@@ -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
View File
@@ -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,
+25 -1
View File
@@ -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 {
+69
View File
@@ -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
+27
View File
@@ -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),
+12 -3
View File
@@ -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
View File
@@ -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,
})
+17 -10
View File
@@ -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
View File
@@ -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',
}
/**
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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. */
+23
View File
@@ -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
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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', () => {
+199
View File
@@ -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' })
})
+68 -3
View File
@@ -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()
})
})
+5 -1
View File
@@ -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
View File
@@ -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()
+47
View File
@@ -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)', () => {
+32
View File
@@ -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
View File
@@ -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'])
+13
View File
@@ -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.*',
],
},
})