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: push:
branches: [main] branches: [main]
pull_request: 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: jobs:
ci: ci:
@@ -41,3 +49,57 @@ jobs:
# shipped lib/types. Runs AFTER build (it reads the built output). # shipped lib/types. Runs AFTER build (it reads the built output).
- name: Check consumer type surface - name: Check consumer type surface
run: pnpm check:consumer-types 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 # stray npm cache scratch dir
.npm-cache-tmp/ .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. 仓库硬约束(所有本仓库插件必须遵守) ## 0. 仓库硬约束(所有本仓库插件必须遵守)
- **禁止修改 DeepSeek Harness (DSH) 源码**:对官方源码 checkout(`~/.dsh/source/current`)零写入——不得改 harness 包、不得把 harness 改动提交到它的分支。 - **禁止修改 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。 - **挂载只走 `cordis.patch.yml` + profile 机制**(`~/.dsh/profiles/<profile>/`),插件永远作为独立包被 profile 引用,不反向侵入 DSH。
- 需要 harness 没有的能力时,用 DSH **现成的只读/公开 API** 或插件自有路由实现(参考 §7 的 `jobs.output` 事件回放:读会话事件日志而非动注册表);如果确实做不到,先向用户说明取舍,而不是直接改 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. 服务定位 ## 1. 服务定位
@@ -134,6 +145,16 @@ interface TabDescriptor {
* 省略时用默认 { id, type, title } + seed 里的 path/diff。 * 省略时用默认 { id, type, title } + seed 里的 path/diff。
*/ */
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null 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 设置页获得一行 * 声明式设置(v0.4.1+):每个注册的 tab 都会在 Side card 设置页获得一行
* 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套设置行, * 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套设置行,
@@ -146,7 +167,7 @@ interface TabDescriptor {
*/ */
settings?: { settings?: {
toggles?: readonly { toggles?: readonly {
/** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'terminalFontFamily' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks') */ /** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'terminalFontFamily' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks' / 'browserInterceptHttp' / 'browserInterceptHttps') */
key: string key: string
title: string | (() => string) title: string | (() => string)
desc?: 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(不可重复注册) ### 3.4 内置 tab(不可重复注册)
| id | order | single | hidden | 用途 | | id | order | single | hidden | 用途 |
@@ -462,7 +501,7 @@ interface BetterSidebarService {
readonly version: string readonly version: string
/** 单调能力清单(只增不删):'badge' | 'tabLifecycle' | 'updateTab' | /** 单调能力清单(只增不删):'badge' | 'tabLifecycle' | 'updateTab' |
* 'openFile' | 'targetedOpen' | 'stateSubscription' | 'tabMeta' | * 'openFile' | 'targetedOpen' | 'stateSubscription' | 'tabMeta' |
* 'pluginSettings'——消费插件用 `features.includes('xxx')` 按能力 gate。 */ * 'pluginSettings' | 'urlTarget'——消费插件用 `features.includes('xxx')` 按能力 gate。 */
readonly features: readonly string[] readonly features: readonly string[]
/** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。 /** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。
* session 未激活时 state/sessionId 为 undefined。 */ * 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 # dsh-better-sidebar
<!-- 头部介绍区(HTML 排版) --> <!-- Hero -->
<div align="center"> <div align="center">
<b style="font-size: 1.15em;">一个插件,一套完整工作台</b><br /><br /> <b style="font-size: 1.15em;">一个服务化的侧边栏框架,一套开箱即用的完整工作台</b><br /><br />
<code>文件管理</code> <code>编辑预览</code> <code>内嵌浏览器</code> <code>真实终端</code> <code>Git 面板</code> <code>后台任务页</code><br /><br /> <a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg" /></a>
<b>右侧栏 + 底部面板双工作台</b>,一个插件全部搞定。<br /> <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 />
<small>支持 Tab 窗口随意拖拽,支持三方拓展注册新 Tab 页面和文件预览</small> <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>
<div align="center"> <div align="center">
🌏 <a href="./README.md"><b>中文</b></a> · <a href="./README_EN.md">English</a> 🌏 <a href="./README.md"><b>中文</b></a> · <a href="./README_EN.md">English</a>
</div> </div>
https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86 <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 width="2630" height="1794" alt="6c4293e1bec2e935031bf0e986d6ec65" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" /> <img alt="dsh-better-sidebar 工作台截图" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
</div>
## 🆕 最近更新
<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 预览盘符路径 | |
## ✨ 功能一览 ## ✨ 功能一览
- **🗂️ 资源管理器**:懒加载目录树(根 = 会话 cwd),点击打开、`@文件` 引用、右键复制路径 - **🗂️ 文件工作台**:资源管理器(懒加载目录树)+ CodeMirror 编辑器;图片 / Markdown / HTML / PDF / Office 内联预览
- **📝 编辑与预览**:CodeMirror 6 编辑(Ctrl/Cmd+S 原子保存,切 Tab 不丢草稿);图片 / Markdown / HTML / PDF / Word / Excel / PPT 内联预览(HTML 走沙箱 iframe) - **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整)
- **⚡ 客户端懒加载**:启动只拉 ~325KB 核心,终端 / 编辑器等重依赖用到才按需拉取(详见 `docs/plans/2026-08-12-lazy-chunks-design.md`) - **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具
- **🌐 浏览器**:多开内嵌网页 tab,后退/前进/刷新;内容运行在沙箱 iframe(无法访问界面数据与本地文件,拒绝本机地址),可临时解锁(红色警示);被站点拒绝嵌入时显示原因;外链默认在侧边栏打开 - **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原
- **💻 终端**:xterm.js + node-pty 真实 shell、断线重连回放;可选为模型注入 `terminal_*` 工具;支持自定义字体(字体族 + 9–32px 字号,实时生效) - **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止)
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存/提交/还原等 - **🪟 双工作台**:右侧栏 + 底部面板;拖 Tab 拆分 / 合并分栏(可跨面板),移动端自动合并全宽抽屉
- **🧩 后台任务页**:主会话 agent 拓扑、点击直达执行记录;同页显示后台任务(类型徽标 + 退出码、实时输出 peek、可强制终止) - **🔁 会话隔离**:布局 / Tab / 面板按会话持久化,陈旧状态自动净化
- **🪟 底部面板**:第二个独立工作台,只挤占中间输出区;首次展开自动开终端(可关) - **⚙️ 声明式设置**:设置页「侧边卡片」逐项独立开关,二级设置经齿轮弹窗
- **📱 移动端**:窄屏(<768px)自动合并为全宽抽屉,底部面板标签页并入右侧栏;点文件/外链自动展开 - **⚡ 按需加载**:启动只拉 ~325KB 核心,终端 / 编辑器等重依赖用到才按需拉取([设计文档](docs/plans/2026-08-12-lazy-chunks-design.md))
- **🔧 分栏工作台**:拖 Tab 拆分/合并(可跨面板)、分隔线调比例;右上角按钮一键折叠/展开面板 - **🌏 多语言**:界面文案跟随 DSH 语言(zh / en)实时切换
- **🔁 会话隔离**:布局 / Tab / 面板状态按会话持久化,陈旧状态自动净化;「产出文件」在侧边栏打开
- **⚙️ 声明式设置**:设置页「侧边卡片」按注册表渲染开关网格,逐项独立开/关;二级设置(自动展开、终端工具、沙箱等)经齿轮弹窗编辑 > 🔌 **核心理念**:服务优先——内置的 7 tab + 6 viewer 与第三方插件通过同一套 `ctx.betterSidebar` API 注册,能力完全对等;官方不再内置、可由生态提供的功能,交由生态插件实现。接入文档见下方「🔌 服务化」与 [外部插件接入指南](./docs/external-plugin-guide.md)。
- **🔌 服务化(基座)**:暴露 `ctx.betterSidebar`,其他插件可注册 tab 与文件预览器(内置 7 tab + 9 viewer 同走一服务);v0.12.1 起支持角标/生命周期回调/状态订阅/定向打开/插件自有设置(见 [AGENTS.md](./AGENTS.md) 与 [外部插件接入指南](./docs/external-plugin-guide.md))
- **➕ 添加插件**:设置页「侧边卡片」两个网格(侧边栏内容 / 文件预览)末尾的虚线卡片分别打开 Tab / 预览插件弹窗:声明扩展点、「在 GitHub 上浏览更多插件」按钮、推荐插件目录(「跳转」直达仓库、「复制」安装命令到剪贴板);弹窗不打开终端、无失败路径,不阻塞启动与侧边栏 ## 🆕 最近更新
- **🌏 多语言**:界面文案跟随 DSH 语言(zh/en)实时切换,无需刷新
<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 ```sh
# macOS / Linux # 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 # 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`)预览步骤再执行。 不确定的话,可先加 `--dry-run`(PowerShell 用 `-DryRun`)预览步骤再执行。
@@ -97,7 +100,7 @@ minimumReleaseAgeExclude:
- dsh-better-sidebar - dsh-better-sidebar
EOF 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 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` 挂载; 3. 执行 `dsh plugin --profile web add dsh-better-sidebar`:登记依赖 → 识别包内 `dsh.bundle.patch` → 自动注册进 `dsh.profile.bundles` 挂载;
4. 清理旧版残留的手动挂载行,避免「双挂载」(页面出现两个侧边栏)。 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> </details>
@@ -174,7 +177,7 @@ dsh plugin --profile web add dsh-better-sidebar
5. 硬刷新浏览器(Cmd/Ctrl+Shift+R)即可看到效果(client 改动无需重启 DSH;host 半改动才需重启) 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> </details>
@@ -208,7 +211,7 @@ dsh registry enable dsh-external/dsh-better-sidebar
## 🔌 服务化:注册 tab 与文件预览器 ## 🔌 服务化:注册 tab 与文件预览器
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 7 tab + 9 viewer 也走同一服务,吃自己的狗粮): 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 7 tab + 6 viewer 亦通过同一服务注册):
```ts ```ts
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并 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-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 一键安装 - [dsh-TUI](https://github.com/ccch1mneyyy/dsh-TUI):Claude Code 风格全屏交互终端插件——像素鲸鱼顶栏、实时工作状态行、思考流式展开、双击 Esc 回滚、上下文进度条 + TPS 仪表,npm 一键安装
- [dshfind 插件超市](https://dshfind.com/zh/plugins):三方插件市场——GitHub topic `dsh-plugin` 下的公开仓库清单,每日同步 star、贡献者与增长数据 - [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 # dsh-better-sidebar
<!-- Hero (HTML layout) --> <!-- Hero -->
<div align="center"> <div align="center">
<b style="font-size: 1.15em;">One plugin, one complete workbench</b><br /><br /> <b style="font-size: 1.15em;">A service-oriented sidebar framework, and a complete workbench out of the box</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 /> <a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg" /></a>
<b>Dual workbench: right sidebar + bottom panel</b>, all in one plugin.<br /> <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 />
<small>Tabs are freely draggable; third-party plugins can register new tab pages and file viewers</small> <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>
<div align="center"> <div align="center">
🌏 <a href="./README.md">中文</a> · <a href="./README_EN.md"><b>English</b></a> 🌏 <a href="./README.md">中文</a> · <a href="./README_EN.md"><b>English</b></a>
</div> </div>
https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86 <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 width="2630" height="1794" alt="6c4293e1bec2e935031bf0e986d6ec65" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" /> <img alt="dsh-better-sidebar workbench" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
</div>
## 🆕 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 | |
## ✨ Features ## ✨ Features
- **🗂️ File Explorer**: lazy-loading directory tree (root = session cwd), click to open, `@file` reference, right-click to copy path - **🗂️ File Workbench**: file explorer (lazy-loading tree) + CodeMirror editor; inline preview for images / Markdown / HTML / PDF / Office
- **📝 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) - **🌐 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)
- **⚡ 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`) - **💻 Real Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model
- **🌐 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 - **🌿 Git Panel**: real diff + VSCode-style diff tabs, history, right-click to stage / commit / revert
- **💻 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) - **🧩 Background Tasks**: agent topology + background tasks (exit codes / live output / force-kill)
- **🌿 Git Panel**: real diff + VSCode-style diff tabs, history, right-click to stage/commit/revert etc. - **🪟 Dual Workbench**: right sidebar + bottom panel; drag tabs to split / merge panes (cross-panel), mobile auto-merges into a full-width drawer
- **🧩 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) - **🔁 Session Isolation**: layout / tabs / panels persisted per session, stale state auto-purged
- **🪟 Bottom Panel**: an independent second workbench that only squeezes the center output area; auto-opens a terminal on first expand (toggleable) - **⚙️ Declarative Settings**: per-item toggles in the "Side Cards" settings section, secondary settings via the gear dialog
- **📱 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 - **⚡ 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))
- **🔧 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 - **🌏 i18n**: UI text follows DSH's language (zh / en) with live switching
- **🔁 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 > 🔌 **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).
- **🔌 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 ## 🆕 Recent Updates
- **🌏 i18n**: UI text follows DSH's language setting (zh/en) with live switching, no refresh needed
<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 ## 🚀 Installation
@@ -68,10 +71,10 @@ Then **hard-refresh the browser** (Cmd/Ctrl+Shift+R) to see the sidebar (DSH hot
```sh ```sh
# macOS / Linux # 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 # 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. Not sure? Add `--dry-run` (`-DryRun` in PowerShell) to preview before running.
@@ -97,7 +100,7 @@ minimumReleaseAgeExclude:
- dsh-better-sidebar - dsh-better-sidebar
EOF 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 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`; 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). 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> </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 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> </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 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> </details>
@@ -208,7 +211,7 @@ Update: `git pull && pnpm install && pnpm build` → `node scripts/package-regis
## 🔌 Service: register tabs & file viewers ## 🔌 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 ```ts
import type {} from 'dsh-better-sidebar' // triggers the ctx.betterSidebar type merge 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-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 - [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 - [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 {}` 方案可互换; - **类型处理**:未 value-import `dsh-better-sidebar`,构建零耦合;与 §2 的 `import type {}` 方案可互换;
- **实测**:v0.3.0 起,真实 web profile 验证通过。 - **实测**: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", "id": "dsh-external/dsh-better-sidebar",
"version": "0.12.1", "version": "0.12.2",
"main": "./lib/index.js", "main": "./lib/index.js",
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器", "description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
"engines": { "engines": {
+4 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "dsh-better-sidebar", "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.", "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", "type": "module",
"repository": { "repository": {
@@ -77,6 +77,7 @@
"watch": "tsdown --watch", "watch": "tsdown --watch",
"prepare": "tsdown", "prepare": "tsdown",
"test": "vitest run", "test": "vitest run",
"test:mount": "bash scripts/e2e-mount.sh",
"check:consumer-types": "bash scripts/check-consumer-types.sh" "check:consumer-types": "bash scripts/check-consumer-types.sh"
}, },
"license": "MIT", "license": "MIT",
@@ -137,6 +138,7 @@
"xterm": "^5.3.0" "xterm": "^5.3.0"
}, },
"devDependencies": { "devDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6", "@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-locale": "^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", "@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-session": "^0.1.0-rc.6",
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6", "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
"@deepseek-ai/dsh-tools": "^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/node": "^24.0.0",
"@types/react": "~18.3.1", "@types/react": "~18.3.1",
"@types/react-dom": "~18.3.1", "@types/react-dom": "~18.3.1",
"@types/ws": "^8.5.10", "@types/ws": "^8.5.10",
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"cordis": "^4.0.0-rc.7", "cordis": "^4.0.0-rc.7",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"lightningcss": "^1.32.0", "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': '@deepseek-ai/dsh-tools':
specifier: ^0.1.0-rc.6 specifier: ^0.1.0-rc.6
version: 0.1.0-rc.6(f8724372086ccc1457fc84e7becee2e0) version: 0.1.0-rc.6(f8724372086ccc1457fc84e7becee2e0)
'@playwright/test':
specifier: ^1.62.1
version: 1.62.1
'@types/node': '@types/node':
specifier: ^24.0.0 specifier: ^24.0.0
version: 24.13.3 version: 24.13.3
@@ -974,6 +977,11 @@ packages:
'@oxc-project/types@0.144.0': '@oxc-project/types@0.144.0':
resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==} 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': '@quansync/fs@1.0.0':
resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==}
@@ -1473,6 +1481,11 @@ packages:
fflate@0.8.3: fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} 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: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1789,6 +1802,16 @@ packages:
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
engines: {node: '>=12'} 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: postcss@8.5.26:
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
@@ -3184,6 +3207,10 @@ snapshots:
'@oxc-project/types@0.144.0': {} '@oxc-project/types@0.144.0': {}
'@playwright/test@1.62.1':
dependencies:
playwright: 1.62.1
'@quansync/fs@1.0.0': '@quansync/fs@1.0.0':
dependencies: dependencies:
quansync: 1.0.0 quansync: 1.0.0
@@ -3542,6 +3569,9 @@ snapshots:
fflate@0.8.3: {} fflate@0.8.3: {}
fsevents@2.3.2:
optional: true
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
@@ -4051,6 +4081,14 @@ snapshots:
picomatch@4.0.5: {} 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: postcss@8.5.26:
dependencies: dependencies:
nanoid: 3.3.18 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 { randomUUID } from 'node:crypto'
import * as nodePty from 'node-pty' import * as nodePty from 'node-pty'
import { ensureSpawnHelper } from './pty-manager.ts' import { ensureSpawnHelper, shellSpawnArgs } from './pty-manager.ts'
import { SidebarError } from './wire.ts' import { SidebarError } from './wire.ts'
/** Per-agent-terminal transcript bound (bytes kept for replay and reads). */ /** Per-agent-terminal transcript bound (bytes kept for replay and reads). */
@@ -211,7 +211,7 @@ export class AgentPtyRegistry {
): string { ): string {
const uuid = randomUUID() const uuid = randomUUID()
const dims = clampDims(cols, rows) const dims = clampDims(cols, rows)
const pty = nodePty.spawn(this.shell, [], { const pty = nodePty.spawn(this.shell, shellSpawnArgs(), {
name: 'xterm-256color', name: 'xterm-256color',
cols: dims.cols, cols: dims.cols,
rows: dims.rows, rows: dims.rows,
+25 -1
View File
@@ -126,7 +126,8 @@
} }
.cardMain:focus-visible, .cardMain:focus-visible,
.cardGear:focus-visible { .cardGear:focus-visible,
.rowGear:focus-visible {
outline: 2px solid var(--dsw-alias-border-l4); outline: 2px solid var(--dsw-alias-border-l4);
outline-offset: 2px; outline-offset: 2px;
} }
@@ -266,6 +267,29 @@
background: var(--dsw-alias-interactive-bg-hover); 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, /* Settings-row recipe (the DSH General section rows): title/desc left,
control right, hairline separators (stripped on the last row). */ control right, hairline separators (stripped on the last row). */
.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 type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { import {
clampWidthPercent, clampWidthPercent,
TITLE_BAR_STRIP_MAX,
TITLE_BAR_STRIP_MIN,
WIDTH_PERCENT_MAX, WIDTH_PERCENT_MAX,
WIDTH_PERCENT_MIN, WIDTH_PERCENT_MIN,
type SidebarPrefs, type SidebarPrefs,
@@ -380,6 +382,8 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// Which feature's secondary settings popup is open (null = closed). // Which feature's secondary settings popup is open (null = closed).
const [settingsFor, setSettingsFor] = useState<TabDescriptor | FileViewerDescriptor | null>(null) 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 // Whether the "add plugin" modal (a dashed card at the end of the
// 侧边栏内容 / 文件预览 grids) is open, and for which extension point // 侧边栏内容 / 文件预览 grids) is open, and for which extension point
// (null = closed). // (null = closed).
@@ -660,6 +664,36 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
onChange={(next) => { applyPref({ interceptOpenPath: next }) }} onChange={(next) => { applyPref({ interceptOpenPath: next }) }}
/> />
</div> </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> </div>
{/* 侧边栏内容: one small card per registered tab type in a responsive {/* 侧边栏内容: one small card per registered tab type in a responsive
@@ -783,6 +817,41 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
</Modal> </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 "add plugin" modal (opened by the dashed cards above): declares
the extension point of the clicked kind, opens the GitHub topic, the extension point of the clicked kind, opens the GitHub topic,
and lists the matching recommended plugin catalog with per-entry 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') } return () => { document.body.removeAttribute('data-dsh-sidebar-collapsed') }
}, [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 * Bottom-panel merge on narrow viewports: whenever a session is current
* while narrow (mount, session switch, or a desktop→narrow transition), * 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'), title: () => t('browser'),
icon: (size: number) => <IconGlobeOutline16 size={size} />, icon: (size: number) => <IconGlobeOutline16 size={size} />,
order: 50, order: 50,
// Declarative settings: the sandbox escape hatch and the link // Declarative settings: the sandbox escape hatch, the link-takeover
// takeover render under this tab's row in the Side card settings // MASTER switch, and the per-protocol takeover switches (http on /
// page (the sandbox one is warned on). // https off by default) render under this tab's row in the Side card
// settings page (the sandbox one is warned on).
settings: { settings: {
toggles: [{ toggles: [{
key: 'browserNoSandbox', key: 'browserNoSandbox',
@@ -199,6 +200,14 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
key: 'browserInterceptLinks', key: 'browserInterceptLinks',
title: () => t('settingsBrowserLinksTitle'), title: () => t('settingsBrowserLinksTitle'),
desc: () => t('settingsBrowserLinksDesc'), desc: () => t('settingsBrowserLinksDesc'),
}, {
key: 'browserInterceptHttp',
title: () => t('settingsBrowserHttpTitle'),
desc: () => t('settingsBrowserHttpDesc'),
}, {
key: 'browserInterceptHttps',
title: () => t('settingsBrowserHttpsTitle'),
desc: () => t('settingsBrowserHttpsDesc'),
}], }],
}, },
createTab: (state) => ({ createTab: (state) => ({
+26 -7
View File
@@ -12,7 +12,7 @@ import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client' import { createRoot, type Root } from 'react-dom/client'
import type { Context } from '../context-types.ts' import type { Context } from '../context-types.ts'
import { createSidebarStore } from './state.ts' import { createSidebarStore } from './state.ts'
import { createBetterSidebarService } from './service.ts' import { createBetterSidebarService, matchUrlTarget } from './service.ts'
import { resetChunks } from './chunk-loader.ts' import { resetChunks } from './chunk-loader.ts'
import { registerBuiltins } from './builtins/index.ts' import { registerBuiltins } from './builtins/index.ts'
import { Sidebar } from './Sidebar.tsx' import { Sidebar } from './Sidebar.tsx'
@@ -153,16 +153,35 @@ export function apply(ctx: Context): void {
ctx.effect( ctx.effect(
() => { () => {
try { try {
// External http(s) links in the chat/GUI open the sidebar browser // External http(s) links in the chat/GUI open the sidebar instead
// instead of a new window (gated on the browserInterceptLinks pref // of a new window. Gated on the browserInterceptLinks MASTER pref,
// AND the browser tab's enable switch; Ctrl/Cmd+click bypasses). // 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({ return registerLinkInterception({
takeoverEnabled: () => sidebarStore.getPrefs().browserInterceptLinks !== false takeoverEnabled: (url) => {
&& sidebarStore.getPrefs().tabsEnabled['browser'] !== false, 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) => { openInSidebar: (url) => {
let title: string | undefined let title: string | undefined
try { title = new URL(url).hostname } catch { /* keep the default title */ } 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, selfOrigin: window.location.origin,
}) })
+17 -10
View File
@@ -1,10 +1,12 @@
/** /**
* Chat/GUI external-link interception: clicking an http(s) link that points * Chat/GUI external-link interception: clicking an http(s) link that points
* OUTSIDE the GUI (chat messages, tool rows, prose mentions) opens the * OUTSIDE the GUI (chat messages, tool rows, prose mentions) opens the
* sidebar browser instead of a new browser tab. Gated by BOTH the * sidebar instead of a new browser tab. Gated by the caller through
* `browserInterceptLinks` pref and the browser tab's enable switch; a * `takeoverEnabled(url)` — the `browserInterceptLinks` master, the URL's
* Ctrl/Cmd/Shift/Alt-modified click always bypasses the takeover so the * protocol flag (`browserInterceptHttp` / `browserInterceptHttps`) and the
* user can still force a real browser tab. * 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 * 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 * 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 /** 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 * 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 { export function shouldInterceptLink(anchorHref: string, selfOrigin: string): string | null {
let url: URL let url: URL
try { try {
@@ -23,7 +28,7 @@ export function shouldInterceptLink(anchorHref: string, selfOrigin: string): str
} }
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
// Same-origin links are GUI-internal navigation (settings pages, tool // Same-origin links are GUI-internal navigation (settings pages, tool
// docs) — never routed into the sidebar browser. // docs) — never routed into the sidebar.
try { try {
if (url.origin === new URL(selfOrigin).origin) return null if (url.origin === new URL(selfOrigin).origin) return null
} catch { } catch {
@@ -39,11 +44,13 @@ export function isPlainLeftClick(event: { button: number; metaKey: boolean; ctrl
/** /**
* Register the document-level click capture that funnels external links * 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: { export function registerLinkInterception(opts: {
takeoverEnabled: () => boolean /** Whether the takeover may happen for THIS url (the caller's prefs
/** Open the sidebar browser tab at `url` (the caller resolves the session). */ * 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 openInSidebar: (url: string) => void
/** The GUI's own origin (window.location.origin at registration). */ /** The GUI's own origin (window.location.origin at registration). */
selfOrigin: string selfOrigin: string
@@ -51,13 +58,13 @@ export function registerLinkInterception(opts: {
const onClick = (event: MouseEvent): void => { const onClick = (event: MouseEvent): void => {
if (!isPlainLeftClick(event)) return if (!isPlainLeftClick(event)) return
if (event.defaultPrevented) return if (event.defaultPrevented) return
if (!opts.takeoverEnabled()) return
const target = event.target const target = event.target
if (target === null || typeof (target as Element).closest !== 'function') return if (target === null || typeof (target as Element).closest !== 'function') return
const anchor = (target as Element).closest('a[href]') as HTMLAnchorElement | null const anchor = (target as Element).closest('a[href]') as HTMLAnchorElement | null
if (anchor === null) return if (anchor === null) return
const url = shouldInterceptLink(anchor.href, opts.selfOrigin) const url = shouldInterceptLink(anchor.href, opts.selfOrigin)
if (url === null) return if (url === null) return
if (!opts.takeoverEnabled(new URL(url))) return
event.preventDefault() event.preventDefault()
opts.openInSidebar(url) opts.openInSidebar(url)
} }
+20 -2
View File
@@ -116,6 +116,10 @@ export const zh = {
settingsWidthSuffix: '%', settingsWidthSuffix: '%',
settingsOpenPathTitle: '聊天区文件在侧边栏打开', settingsOpenPathTitle: '聊天区文件在侧边栏打开',
settingsOpenPathDesc: '在聊天里点击文件链接(工具行、产物列表、文件提及)时,在侧边栏编辑器中打开,不再调用系统默认应用', settingsOpenPathDesc: '在聊天里点击文件链接(工具行、产物列表、文件提及)时,在侧边栏编辑器中打开,不再调用系统默认应用',
settingsTitleBarTitle: '位置兼容模式',
settingsTitleBarDesc: '为 Windows 右上角的原生标题栏预留空间:侧边栏按钮与侧边栏内容整体下移,避免被标题栏遮挡',
settingsTitleBarStripTitle: '下移距离',
settingsTitleBarStripDesc: '标题栏条带高度:侧边栏按钮与内容下移的像素数(0–120,默认 40)',
settingsSaveFailed: '保存失败', settingsSaveFailed: '保存失败',
settingsConflict: '设置已被其他窗口修改,请重试', settingsConflict: '设置已被其他窗口修改,请重试',
binaryNoPreview: '此文件类型不支持预览', binaryNoPreview: '此文件类型不支持预览',
@@ -166,7 +170,11 @@ export const zh = {
settingsBrowserSandboxTitle: '关闭浏览器沙箱(不安全)', settingsBrowserSandboxTitle: '关闭浏览器沙箱(不安全)',
settingsBrowserSandboxDesc: '关闭后,访问的任何网站都将与界面同源运行,可读取会话数据并冒充你的登录状态。仅对完全可信的站点开启', settingsBrowserSandboxDesc: '关闭后,访问的任何网站都将与界面同源运行,可读取会话数据并冒充你的登录状态。仅对完全可信的站点开启',
settingsBrowserLinksTitle: '聊天区外链在侧边栏打开', settingsBrowserLinksTitle: '聊天区外链在侧边栏打开',
settingsBrowserLinksDesc: '开启后,点击聊天或界面中的 http/https 外链时在侧边栏浏览器中打开,不再弹出新窗口;Ctrl/Cmd 点击可临时放行', settingsBrowserLinksDesc: '开启后,点击聊天或界面中的外链时在侧边栏打开,不再弹出新窗口;HTTP 与 HTTPS 可分别通过下方开关控制;Ctrl/Cmd 点击可临时放行',
settingsBrowserHttpTitle: '侧边打开HTTP网页',
settingsBrowserHttpDesc: '开启后,点击聊天或界面中的 HTTP 外链时在侧边栏打开(声明了 urlTarget 的插件页面优先);Ctrl/Cmd 点击可临时放行',
settingsBrowserHttpsTitle: '侧边打开HTTPS网页',
settingsBrowserHttpsDesc: '开启后,点击聊天或界面中的 HTTPS 外链时在侧边栏打开。默认关闭:多数 HTTPS 站点拒绝被嵌入,走系统浏览器更顺畅',
browserOpenExternal: '在浏览器中打开', browserOpenExternal: '在浏览器中打开',
browserEmbedBlocked: '{host} 拒绝了嵌入请求', browserEmbedBlocked: '{host} 拒绝了嵌入请求',
browserEmbedBlockedDesc: '该站点通过 X-Frame-Options / frame-ancestors 禁止在其它页面中显示,无法在侧边栏内加载。可在浏览器中直接打开', browserEmbedBlockedDesc: '该站点通过 X-Frame-Options / frame-ancestors 禁止在其它页面中显示,无法在侧边栏内加载。可在浏览器中直接打开',
@@ -219,6 +227,7 @@ export const zh = {
copyInstall: '复制安装命令', copyInstall: '复制安装命令',
pluginOfficeDesc: '为 better-sidebar 编辑器提供 Office 三件套预览(.docx / .xlsx / .pptx),把重型 Office 渲染库拆出主包、按需安装', pluginOfficeDesc: '为 better-sidebar 编辑器提供 Office 三件套预览(.docx / .xlsx / .pptx),把重型 Office 渲染库拆出主包、按需安装',
pluginSentinelDesc: '条件驱动的 agent 唤醒系统:文件/进程/端口/HTTP/命令/webhook 传感器,条件达成自动唤醒休眠会话;注册「哨兵」Tab 展示服务器全局监控表', pluginSentinelDesc: '条件驱动的 agent 唤醒系统:文件/进程/端口/HTTP/命令/webhook 传感器,条件达成自动唤醒休眠会话;注册「哨兵」Tab 展示服务器全局监控表',
pluginSidebarQaDesc: '基于 better-sidebar 的划选提问tab分页: 对话划选 → 右侧面板提问 → 同工作区独立追问会话(❓追问·主题):快速无思考模型压缩主对话上下文后与引文一起注入,不打断主对话;追问可嵌套、可继续、可归档',
} }
/** The en dictionary (key-set-equal to zh, enforced by the type annotation). */ /** 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: '%', settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Open chat files in the sidebar', 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', 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', settingsSaveFailed: 'Failed to save',
settingsConflict: 'The setting changed in another window — please retry', settingsConflict: 'The setting changed in another window — please retry',
binaryNoPreview: 'This file type cannot be previewed', binaryNoPreview: 'This file type cannot be previewed',
@@ -378,7 +391,11 @@ export const en: Record<keyof typeof zh, string> = {
settingsBrowserSandboxTitle: 'Disable browser sandbox (unsafe)', 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', 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', 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', browserOpenExternal: 'Open in browser',
browserEmbedBlocked: '{host} refused to be embedded', 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.', 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', 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', 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', 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. // context consistent with the other entries.
install: 'cd ~/.dsh && dsh plugin --profile web add "github:fuhefei/dsh-sentinel#v0.7.0"', 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 type { api } from './api.ts'
import { import {
clampTerminalFontSize, clampTerminalFontSize,
clampTitleBarStrip,
clampWidthPercent, clampWidthPercent,
SIDEBAR_PREFS_DEFAULTS, SIDEBAR_PREFS_DEFAULTS,
type SidebarPrefs, type SidebarPrefs,
} from '../prefs-shared.ts' } from '../prefs-shared.ts'
export { SIDEBAR_PREFS_DEFAULTS, clampTerminalFontSize, clampWidthPercent } export { SIDEBAR_PREFS_DEFAULTS, clampTerminalFontSize, clampTitleBarStrip, clampWidthPercent }
export type { SidebarPrefs } export type { SidebarPrefs }
/** The settings wire face the preferences need (a subset of the plugin api). */ /** 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' interceptOpenPath: typeof record.interceptOpenPath === 'boolean'
? record.interceptOpenPath ? record.interceptOpenPath
: SIDEBAR_PREFS_DEFAULTS.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' htmlViewerNoSandbox: typeof record.htmlViewerNoSandbox === 'boolean'
? record.htmlViewerNoSandbox ? record.htmlViewerNoSandbox
: SIDEBAR_PREFS_DEFAULTS.htmlViewerNoSandbox, : SIDEBAR_PREFS_DEFAULTS.htmlViewerNoSandbox,
@@ -71,6 +78,12 @@ export function parsePrefs(value: unknown): SidebarPrefs {
browserInterceptLinks: typeof record.browserInterceptLinks === 'boolean' browserInterceptLinks: typeof record.browserInterceptLinks === 'boolean'
? record.browserInterceptLinks ? record.browserInterceptLinks
: SIDEBAR_PREFS_DEFAULTS.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), tabsEnabled: booleanMapOf(record.tabsEnabled),
viewersEnabled: booleanMapOf(record.viewersEnabled), viewersEnabled: booleanMapOf(record.viewersEnabled),
pluginSettings: pluginSettingsMapOf(record.pluginSettings), 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. * When omitted, a default `{ id, type, title }` tab is created.
*/ */
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null 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 * Declarative settings shown in the Side card settings page: every
* registered tab gets an enable/disable switch (icon + title + id), and * 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) 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 * The plugin version this service instance reports. Keep in lockstep with
* `package.json`'s version — `tests/service.spec.ts` asserts the pair. * `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 * 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 * - 'stateSubscription': getSnapshot/subscribeState
* - 'tabMeta': SidebarTab.meta (seeds, createTab, updateTab, persistence) * - 'tabMeta': SidebarTab.meta (seeds, createTab, updateTab, persistence)
* - 'pluginSettings': SidebarSettingsDeclaration.pluginToggles/render * - 'pluginSettings': SidebarSettingsDeclaration.pluginToggles/render
* - 'urlTarget' (v0.13.0): TabDescriptor.urlTarget (external-link claims)
*/ */
export const SIDEBAR_FEATURES = [ export const SIDEBAR_FEATURES = [
'badge', 'badge',
@@ -400,6 +444,7 @@ export const SIDEBAR_FEATURES = [
'stateSubscription', 'stateSubscription',
'tabMeta', 'tabMeta',
'pluginSettings', 'pluginSettings',
'urlTarget',
] as const ] as const
/** Run one plugin callback; a throw is logged and never breaks the caller. */ /** Run one plugin callback; a throw is logged and never breaks the caller. */
+23
View File
@@ -209,6 +209,29 @@
padding-right: 40px; 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 /* The shared corner (both panels open): the intersection of the right
panel's left edge and the bottom panel's top edge. Horizontal drags panel's left edge and the bottom panel's top edge. Horizontal drags
resize the width, vertical drags the height — the two panels drag 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_DEFAULT,
TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MAX,
TERMINAL_FONT_SIZE_MIN, TERMINAL_FONT_SIZE_MIN,
TITLE_BAR_STRIP_DEFAULT,
TITLE_BAR_STRIP_MAX,
TITLE_BAR_STRIP_MIN,
WIDTH_PERCENT_DEFAULT, WIDTH_PERCENT_DEFAULT,
WIDTH_PERCENT_MAX, WIDTH_PERCENT_MAX,
WIDTH_PERCENT_MIN, WIDTH_PERCENT_MIN,
@@ -24,6 +27,9 @@ export {
TERMINAL_FONT_SIZE_DEFAULT, TERMINAL_FONT_SIZE_DEFAULT,
TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MAX,
TERMINAL_FONT_SIZE_MIN, TERMINAL_FONT_SIZE_MIN,
TITLE_BAR_STRIP_DEFAULT,
TITLE_BAR_STRIP_MAX,
TITLE_BAR_STRIP_MIN,
WIDTH_PERCENT_DEFAULT, WIDTH_PERCENT_DEFAULT,
WIDTH_PERCENT_MAX, WIDTH_PERCENT_MAX,
WIDTH_PERCENT_MIN, WIDTH_PERCENT_MIN,
@@ -91,10 +97,14 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
terminalFontFamily: z.string().default(''), terminalFontFamily: z.string().default(''),
terminalFontSize: z.number().step(1).min(TERMINAL_FONT_SIZE_MIN).max(TERMINAL_FONT_SIZE_MAX).default(TERMINAL_FONT_SIZE_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), 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), htmlViewerNoSandbox: z.boolean().default(false),
htmlViewerDefaultUnsafe: z.boolean().default(false), htmlViewerDefaultUnsafe: z.boolean().default(false),
browserNoSandbox: z.boolean().default(false), browserNoSandbox: z.boolean().default(false),
browserInterceptLinks: z.boolean().default(true), 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 // 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 {} // external): an absent key means enabled, so old documents resolve to {}
// (everything on) with no migration. Non-boolean values fail validation. // (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). * own enable switch gates it too (both must be on for the takeover).
*/ */
interceptOpenPath: boolean 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 * Whether the HTML previewer drops its sandboxed iframe. Sandbox ON (the
* default) renders previewed HTML in an opaque-origin iframe that cannot * default) renders previewed HTML in an opaque-origin iframe that cannot
@@ -85,13 +100,31 @@ export interface SidebarPrefs {
*/ */
browserNoSandbox: boolean browserNoSandbox: boolean
/** /**
* Whether clicking an http(s) EXTERNAL link in the GUI (chat messages, * MASTER switch: whether clicking an EXTERNAL link in the GUI (chat
* tool rows, prose mentions) opens the sidebar browser instead of a new * messages, tool rows, prose mentions) is taken over into the sidebar at
* browser tab. On by default; gated on the browser tab's own enable * all. On by default; the per-protocol granularity lives in
* switch (both must be on for the takeover). Ctrl/Cmd+click always * `browserInterceptHttp` / `browserInterceptHttps` (the protocol flag
* bypasses the takeover. * 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 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'`, * Per-tab enable switches, keyed by tab descriptor id (`'explorer'`,
* `'my-plugin:db'`). An ABSENT key means enabled — only an explicit * `'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_MAX = 32
export const TERMINAL_FONT_SIZE_DEFAULT = 13 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. */ /** Fallback prefs used whenever the settings document is unreachable or malformed. */
export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = { export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
openByDefault: true, openByDefault: true,
@@ -141,10 +179,14 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
terminalFontFamily: '', terminalFontFamily: '',
terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT, terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT,
interceptOpenPath: true, interceptOpenPath: true,
titleBarCompat: false,
titleBarStripPx: TITLE_BAR_STRIP_DEFAULT,
htmlViewerNoSandbox: false, htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false, htmlViewerDefaultUnsafe: false,
browserNoSandbox: false, browserNoSandbox: false,
browserInterceptLinks: true, browserInterceptLinks: true,
browserInterceptHttp: true,
browserInterceptHttps: false,
tabsEnabled: {}, tabsEnabled: {},
viewersEnabled: {}, viewersEnabled: {},
pluginSettings: {}, pluginSettings: {},
@@ -159,3 +201,8 @@ export function clampWidthPercent(value: number): number {
export function clampTerminalFontSize(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))) 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 { chmodSync, existsSync } from 'node:fs'
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path'
import { createRequire } from 'node:module' import { createRequire } from 'node:module'
import { userInfo } from 'node:os'
import * as nodePty from 'node-pty' import * as nodePty from 'node-pty'
import { SidebarError } from './wire.ts' import { SidebarError } from './wire.ts'
@@ -118,7 +119,7 @@ export class PtyManager {
sessionId, sessionId,
tabId, tabId,
cwd, cwd,
pty: nodePty.spawn(this.shell, [], { pty: nodePty.spawn(this.shell, shellSpawnArgs(), {
name: 'xterm-256color', name: 'xterm-256color',
cols: Math.max(2, Math.floor(cols)), cols: Math.max(2, Math.floor(cols)),
rows: Math.max(2, Math.floor(rows)), 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 { export function defaultShell(): string {
if (process.platform === 'win32') return 'powershell.exe' if (process.platform === 'win32') return 'powershell.exe'
const shell = process.env.SHELL const envShell = process.env.SHELL
return shell !== undefined && shell.trim() !== '' ? shell : '/bin/bash' 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') 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 { service } = setup()
const toggles = service.getTab('browser')?.settings?.toggles ?? [] const toggles = service.getTab('browser')?.settings?.toggles ?? []
expect(toggles.map(t => t.key)).toEqual(['browserNoSandbox', 'browserInterceptLinks']) expect(toggles.map(t => t.key)).toEqual([
expect(toggles[0]?.title).toBeDefined() 'browserNoSandbox',
expect(toggles[0]?.desc).toBeDefined() 'browserInterceptLinks',
expect(toggles[1]?.title).toBeDefined() 'browserInterceptHttp',
expect(toggles[1]?.desc).toBeDefined() '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', () => { 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-link interception policy tests: the pure decision (which http(s)
* external links are taken over into the sidebar browser) and the click * external links are taken over into the sidebar browser), the click shape
* shape (plain left clicks only — modified clicks always bypass). * (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 { 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' const SELF = 'http://127.0.0.1:3080'
@@ -55,3 +57,66 @@ describe('isPlainLeftClick', () => {
expect(isPlainLeftClick({ ...plain, button: 2 })).toBe(false) 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. // and 13px.
expect(resolved.terminalFontFamily).toBe('') expect(resolved.terminalFontFamily).toBe('')
expect(resolved.terminalFontSize).toBe(13) 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. // The enable-switch maps resolve to {} (everything on) for old documents.
expect(resolved.tabsEnabled).toEqual({}) expect(resolved.tabsEnabled).toEqual({})
expect(resolved.viewersEnabled).toEqual({}) expect(resolved.viewersEnabled).toEqual({})
@@ -63,6 +67,6 @@ describe('dsh-better-sidebar plugin export shape', () => {
const overridden = (PrefsSchema as unknown as { const overridden = (PrefsSchema as unknown as {
(input: Record<string, unknown> | undefined): Record<string, unknown> (input: Record<string, unknown> | undefined): Record<string, unknown>
})({ openByDefault: false, defaultWidthPercent: 45 }) })({ 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' import { createSidebarStore, allLeaves, makeDefaultState, openDiffTab, sanitizeState } from '../src/client/state.ts'
describe('BetterSidebar service', () => { 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', () => { describe('service.openTab dedupe', () => {
it('dedupeKey focuses existing tab instead of duplicating', () => { it('dedupeKey focuses existing tab instead of duplicating', () => {
const store = createSidebarStore() const store = createSidebarStore()
+47
View File
@@ -151,6 +151,28 @@ describe('SideCardSection declarative inventory', () => {
const html = renderSection(store, service) const html = renderSection(store, service)
expect(html).not.toContain('Feature settings') 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)', () => { describe('FeatureSettingsRows (the secondary settings popup body)', () => {
@@ -229,6 +251,31 @@ describe('FeatureSettingsRows (the secondary settings popup body)', () => {
expect(html).toContain('px') expect(html).toContain('px')
expect(html).not.toContain('type="checkbox"') 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)', () => { 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 () => { it('lists the repository root level', async () => {
const listing = await listDirectory(process.cwd(), 1000) const listing = await listDirectory(process.cwd(), 1000)
expect(listing.entries.some(entry => entry.name === 'src' && entry.isDir)).toBe(true) expect(listing.entries.some(entry => entry.name === 'src' && entry.isDir)).toBe(true)
@@ -520,10 +548,14 @@ describe('side card settings routes', () => {
terminalFontFamily: '', terminalFontFamily: '',
terminalFontSize: 13, terminalFontSize: 13,
interceptOpenPath: true, interceptOpenPath: true,
titleBarCompat: false,
titleBarStripPx: 40,
htmlViewerNoSandbox: false, htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false, htmlViewerDefaultUnsafe: false,
browserNoSandbox: false, browserNoSandbox: false,
browserInterceptLinks: true, browserInterceptLinks: true,
browserInterceptHttp: true,
browserInterceptHttps: false,
// The enable-switch maps default to {} (everything on). // The enable-switch maps default to {} (everything on).
tabsEnabled: {}, tabsEnabled: {},
viewersEnabled: {}, 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 { compareEntries, isWithin, parentOf, rootLabel, requireAbsolute } from '../src/fs-tree.ts'
import { parseLogLines, parsePorcelainZ } from '../src/git.ts' import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
import { parseUnifiedDiff } from '../src/client/DiffView.tsx' import { parseUnifiedDiff } from '../src/client/DiffView.tsx'
@@ -846,13 +859,21 @@ describe('agent terminal reconciliation', () => {
}) })
describe('pty helpers', () => { 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 const previous = process.env.SHELL
try { try {
process.env.SHELL = '' process.env.SHELL = '/explicit/zsh'
expect(defaultShell()).toBe('/bin/bash') expect(defaultShell()).toBe('/explicit/zsh')
process.env.SHELL = ' '
expect(defaultShell()).toBe('/usr/bin/zsh')
delete process.env.SHELL delete process.env.SHELL
expect(defaultShell()).toBe('/bin/bash') expect(defaultShell()).toBe('/usr/bin/zsh')
} finally { } finally {
if (previous === undefined) delete process.env.SHELL if (previous === undefined) delete process.env.SHELL
else process.env.SHELL = previous else process.env.SHELL = previous
@@ -1140,10 +1161,14 @@ describe('side card preferences', () => {
terminalFontFamily: '', terminalFontFamily: '',
terminalFontSize: 13, terminalFontSize: 13,
interceptOpenPath: true, interceptOpenPath: true,
titleBarCompat: false,
titleBarStripPx: 40,
htmlViewerNoSandbox: false, htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false, htmlViewerDefaultUnsafe: false,
browserNoSandbox: false, browserNoSandbox: false,
browserInterceptLinks: true, browserInterceptLinks: true,
browserInterceptHttp: true,
browserInterceptHttps: false,
tabsEnabled: {}, tabsEnabled: {},
viewersEnabled: {}, viewersEnabled: {},
pluginSettings: {}, pluginSettings: {},
@@ -1162,10 +1187,14 @@ describe('side card preferences', () => {
terminalFontFamily: '', terminalFontFamily: '',
terminalFontSize: 13, terminalFontSize: 13,
interceptOpenPath: true, interceptOpenPath: true,
titleBarCompat: false,
titleBarStripPx: 40,
htmlViewerNoSandbox: false, htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false, htmlViewerDefaultUnsafe: false,
browserNoSandbox: false, browserNoSandbox: false,
browserInterceptLinks: true, browserInterceptLinks: true,
browserInterceptHttp: true,
browserInterceptHttps: false,
tabsEnabled: {}, tabsEnabled: {},
viewersEnabled: {}, viewersEnabled: {},
pluginSettings: {}, pluginSettings: {},
@@ -1184,10 +1213,14 @@ describe('side card preferences', () => {
terminalFontFamily: '', terminalFontFamily: '',
terminalFontSize: 13, terminalFontSize: 13,
interceptOpenPath: true, interceptOpenPath: true,
titleBarCompat: false,
titleBarStripPx: 40,
htmlViewerNoSandbox: false, htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false, htmlViewerDefaultUnsafe: false,
browserNoSandbox: false, browserNoSandbox: false,
browserInterceptLinks: true, browserInterceptLinks: true,
browserInterceptHttp: true,
browserInterceptHttps: false,
tabsEnabled: {}, tabsEnabled: {},
viewersEnabled: {}, viewersEnabled: {},
pluginSettings: {}, pluginSettings: {},
@@ -1218,6 +1251,45 @@ describe('side card preferences', () => {
expect((await loadPrefs(wire({ interceptOpenPath: true }))).interceptOpenPath).toBe(true) 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 () => { it('resolves the terminal font prefs (family passthrough, size clamp)', async () => {
// Absent → theme default (empty family) + default size. // Absent → theme default (empty family) + default size.
expect((await loadPrefs(wire({}))).terminalFontFamily).toBe('') expect((await loadPrefs(wire({}))).terminalFontFamily).toBe('')
@@ -1251,9 +1323,9 @@ describe('side card preferences', () => {
const store = createSidebarStore() const store = createSidebarStore()
// Node environment: no window → the width falls back to PANEL_DEFAULT, // Node environment: no window → the width falls back to PANEL_DEFAULT,
// while the open flag still follows the preference. // 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') 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() const snapshot = store.getSnapshot()
expect(snapshot.sessionId).toBe('fresh-session') expect(snapshot.sessionId).toBe('fresh-session')
expect(snapshot.state?.panelOpen).toBe(false) 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', () => { it('skips the default explorer tab when the explorer type is disabled', () => {
const store = createSidebarStore() 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') store.setSession('no-explorer')
const state = store.getSnapshot().state! const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs) const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
@@ -1297,7 +1369,7 @@ describe('side card preferences', () => {
expect(state.splits.kind).toBe('leaf') expect(state.splits.kind).toBe('leaf')
// Re-enabling seeds the explorer tab again. // Re-enabling seeds the explorer tab again.
const openStore = createSidebarStore() 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') openStore.setSession('with-explorer')
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs) const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
expect(openTabs.map(tab => tab.type)).toEqual(['explorer']) 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/], 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.*',
],
}, },
}) })