Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ad0a49b8a | ||
|
|
6ee1ba11d0 | ||
|
|
c923fc57b1 | ||
|
|
f39156607e | ||
|
|
ab785d71fb | ||
|
|
333961be71 | ||
|
|
618858b5b1 | ||
|
|
565e66d30e | ||
|
|
de5234a3d7 | ||
|
|
a673f2399f | ||
|
|
6f52aa79d2 | ||
|
|
8d9ab1cd2f | ||
|
|
a0918496f3 | ||
|
|
aea9fcd3a0 | ||
|
|
be95787c00 | ||
|
|
9652bbda59 | ||
|
|
0052061a72 | ||
|
|
db189e3852 | ||
|
|
bb3b40ff15 | ||
|
|
5c138ca851 | ||
|
|
fc4031e65f | ||
|
|
3c196fffc3 | ||
|
|
fd9544f16b | ||
|
|
835d1538e0 | ||
|
|
9b50f5fb64 | ||
|
|
d33d6764fc | ||
|
|
515ed0abd0 | ||
|
|
d7dc609553 | ||
|
|
c9372de69b | ||
|
|
ecebc97800 | ||
|
|
1f9af78c3c |
@@ -0,0 +1,97 @@
|
||||
name: Release
|
||||
|
||||
# Publishes `dsh-better-sidebar` to npm via npm Trusted Publishing (OIDC) —
|
||||
# no NPM_TOKEN secret required, provenance is attached to the tarball.
|
||||
#
|
||||
# Trigger: a GitHub Release is published (tag vX.Y.Z). The tag MUST match
|
||||
# package.json `version`; the workflow verifies this before publishing.
|
||||
#
|
||||
# One-time npm-side setup (manual, on npmjs.com):
|
||||
# 1. package `dsh-better-sidebar` -> Settings -> Publishing access
|
||||
# -> Trusted Publishers -> "Add Trusted Publisher".
|
||||
# 2. Provider: GitHub Actions
|
||||
# Organization or user: omdsh-dev
|
||||
# Repository: DSH-better-sidebar
|
||||
# Workflow filename: release.yml
|
||||
# Environment: (blank)
|
||||
# 3. Save. Do NOT add an NPM_TOKEN secret — Trusted Publishing replaces it.
|
||||
# Canonical reference: https://docs.npmjs.com/trusted-publishers
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Pack and verify everything, but do not publish
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # mint the OIDC token npm Trusted Publishing exchanges
|
||||
|
||||
concurrency:
|
||||
group: npm-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: build · verify · publish
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
# Tests run git log against this repository and need full history.
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 11
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: pnpm
|
||||
|
||||
# The plugin's git revert/cherry-pick spawn git without an identity
|
||||
# (see tests/smoke.spec.ts); the runner has none by default.
|
||||
- name: Configure git identity for tests
|
||||
run: |
|
||||
git config --global user.name "dsh-better-sidebar-ci"
|
||||
git config --global user.email "ci@dsh.invalid"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Verify release tag matches package.json version
|
||||
if: github.event_name == 'release'
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
expected="v$(node -p "require('./package.json').version")"
|
||||
echo "package.json version: ${expected#v}"
|
||||
echo "release tag: ${RELEASE_TAG}"
|
||||
if [ "${RELEASE_TAG}" != "${expected}" ]; then
|
||||
echo "::error::Release tag ${RELEASE_TAG} does not match package.json version ${expected#v} (expected ${expected})."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
- name: Dry-run publish (validation only)
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true'
|
||||
run: pnpm publish --dry-run --no-git-checks
|
||||
|
||||
- name: Publish to npm
|
||||
if: github.event_name == 'release' || github.event.inputs.dry_run != 'true'
|
||||
run: pnpm publish --provenance --access public --no-git-checks
|
||||
@@ -19,10 +19,19 @@ better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context
|
||||
|
||||
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 都会使门禁变红。
|
||||
3. `tests/e2e/mount.e2e.ts`(Playwright Chromium)加载页面,断言外壳与 `[data-dsh-better-sidebar]` 挂载、无 `dsh-better-sidebar:` 错误条、无 pageerror/插件 console 错误,显式展开面板(`openByDefault` 默认关)后通过「+ 菜单」逐个打开内置 tab(含终端懒加载 chunk)深扫,再经 Files 文件窗口的内嵌树打开 seed 文件强制加载 editor 懒加载 chunk(`client-editor.js`,原地模式:seed 的 home tab 原地切换到文件)——缺失的内置 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` 会整体替换默认值)。
|
||||
|
||||
### npm 发版(GitHub Release → npm publish)
|
||||
|
||||
`.github/workflows/release.yml` 在 GitHub Release 发布(tag `vX.Y.Z`)时自动发版到 npm:
|
||||
|
||||
1. **发版前置**:`package.json` 版本号 bump 到 `X.Y.Z`(manifest 一致性守卫会校验其它副本),CI 全绿后打 tag `vX.Y.Z` 并发布 GitHub Release。tag 必须与 package.json 版本一致——workflow 在发布前校验,不匹配直接失败、不发版。
|
||||
2. **流程**:workflow 依次执行 `pnpm build` / `pnpm typecheck` / `pnpm test`,校验 tag,然后 `pnpm publish --provenance --access public`(`prepublishOnly` 会重建产物;产物带 provenance 签名)。
|
||||
3. **认证**:走 npm **Trusted Publishing(OIDC)**,不需要也不应配置 `NPM_TOKEN` secret。一次性手动配置(在 npmjs.com 完成):package `dsh-better-sidebar` → Settings → Publishing access → Trusted Publishers → Add Trusted Publisher,字段为 Provider `GitHub Actions`、Organization `omdsh-dev`、Repository `DSH-better-sidebar`、**Workflow filename `release.yml`**、Environment 留空。未配置前发布会失败(OIDC 交换报错),配置后无需改 workflow。
|
||||
4. **调试**:`workflow_dispatch` 手动触发 + `dry_run=true` 只打包验证、不发版。
|
||||
|
||||
---
|
||||
|
||||
## 1. 服务定位
|
||||
@@ -122,7 +131,7 @@ interface TabDescriptor {
|
||||
title: string | (() => string)
|
||||
/** 图标:ReactNode 或 (size: number) => ReactNode */
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
/** + 菜单排序(升序);默认 100。内置:explorer=10, git=20, subagent=30, terminal=40 */
|
||||
/** + 菜单排序(升序);默认 100。内置:editor=10, git=20, subagent=30, terminal=40, browser=50 */
|
||||
order?: number
|
||||
/** 从 + 菜单隐藏(editor/diff 用:由其他流程触发打开,不在菜单里) */
|
||||
hidden?: boolean
|
||||
@@ -136,7 +145,7 @@ interface TabDescriptor {
|
||||
/**
|
||||
* 去重键:openTab 时若已存在 dedupeKey 相同的 tab,则聚焦而非新开。
|
||||
* 返回 undefined 表示不去重(每次都新开,但同 id 会被 id 安全网聚焦)。
|
||||
* 内置策略:explorer/git/subagent 用 single: true;editor 用 tab => tab.path;diff 用 tab => tab.id。
|
||||
* 内置策略:git/subagent 用 single: true;editor 用 tab => tab.path;diff 用 tab => tab.id。
|
||||
*/
|
||||
dedupeKey?: (tab: SidebarTab) => string | undefined // 必须保持纯函数:每次 open 会求值两次,抛错会向外传播
|
||||
/**
|
||||
@@ -159,20 +168,24 @@ interface TabDescriptor {
|
||||
* 声明式设置(v0.4.1+):每个注册的 tab 都会在 Side card 设置页获得一行
|
||||
* 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套设置行,
|
||||
* 绑定 SidebarPrefs 字段。嵌套设置仅父级启用时显示(v0.11.0 起行控件不限于
|
||||
* 布尔开关:`type: 'switch' | 'text' | 'number'`,缺省 'switch';text/number
|
||||
* 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀)。
|
||||
* 布尔开关:`type: 'switch' | 'text' | 'number' | 'select'`,缺省 'switch';
|
||||
* text/number 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀;
|
||||
* select 行是下拉选择:options 声明 `{ value, title, desc?, icon? }`,任一
|
||||
* 选项带 icon 时下拉渲染大图标选项卡、收起态同样显示图标,否则单行文本;
|
||||
* `multi: true`(缺省 false)多选,存储值为选中 value 的数组,按 options
|
||||
* 顺序提交)。
|
||||
* v0.12.0 起增加两个插件自有扩展(详见 §5 声明式设置):
|
||||
* `pluginToggles`(插件自有 key,持久化在 pluginSettings[id],无需宿主 schema 字段)
|
||||
* 与 `render`(自定义设置面板,替代行列表)。
|
||||
*/
|
||||
settings?: {
|
||||
toggles?: readonly {
|
||||
/** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'terminalFontFamily' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks' / 'browserInterceptHttp' / 'browserInterceptHttps') */
|
||||
/** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'terminalFontFamily' / 'editorExplorer' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks' / 'browserInterceptHttp' / 'browserInterceptHttps') */
|
||||
key: string
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
/** 行控件类型;缺省 'switch'(向后兼容)。 */
|
||||
type?: 'switch' | 'text' | 'number'
|
||||
type?: 'switch' | 'text' | 'number' | 'select'
|
||||
/** number 行的提交钳制下限。 */
|
||||
min?: number
|
||||
/** number 行的提交钳制上限。 */
|
||||
@@ -181,6 +194,15 @@ interface TabDescriptor {
|
||||
placeholder?: string
|
||||
/** 输入框后的单位后缀(如 'px')。 */
|
||||
unit?: string
|
||||
/** select 行的选项(value 为提交值:string | number | boolean)。 */
|
||||
options?: readonly {
|
||||
value: string | number | boolean
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
}[]
|
||||
/** select 行是否多选(缺省 false;存储值为 value 数组)。 */
|
||||
multi?: boolean
|
||||
}[]
|
||||
/** 插件自有设置行(v0.12.0+):形状同 toggles,但 key 是插件局部的,
|
||||
* 持久化在 `pluginSettings[<descriptor id>]`——不需要宿主 PrefsSchema 字段。 */
|
||||
@@ -188,11 +210,18 @@ interface TabDescriptor {
|
||||
key: string
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
type?: 'switch' | 'text' | 'number'
|
||||
type?: 'switch' | 'text' | 'number' | 'select'
|
||||
min?: number
|
||||
max?: number
|
||||
placeholder?: string
|
||||
unit?: string
|
||||
options?: readonly {
|
||||
value: string | number | boolean
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
}[]
|
||||
multi?: boolean
|
||||
}[]
|
||||
/** 自定义设置面板(v0.12.0+):给出时齿轮弹窗渲染它而非行列表。
|
||||
* props 含 store/service/prefs、本 descriptor 的 pluginSettings blob、
|
||||
@@ -242,7 +271,7 @@ interface TabComponentProps {
|
||||
tab: SidebarTab // 当前 tab 实例(含 id/type/title/path?/diff?)
|
||||
visible: boolean // 是否是当前激活 tab 且面板打开(不可见时暂停轮询等)
|
||||
// 以下由内置 tab 使用,外部 tab 可忽略:
|
||||
expanded?: string[] // explorer 的展开目录集
|
||||
expanded?: string[] // 文件树的展开目录集
|
||||
onToggleDir?: (path: string) => void
|
||||
onReferenceFile?: (path: string) => void
|
||||
onOpenFile?: (path: string) => void
|
||||
@@ -321,8 +350,7 @@ ctx.effect(() => {
|
||||
|
||||
| id | order | single | hidden | 用途 |
|
||||
|---|---|---|---|---|
|
||||
| `editor` | -1 | 否(按 path 去重) | 是 | 文件编辑/预览(由 openSidebarFile 触发) |
|
||||
| `explorer` | 10 | 是 | 否 | 文件资源管理器 |
|
||||
| `editor` | 10 | 否(按 path 去重) | 否 | 唯一的「文件窗口」(文件编辑/预览 + 文件资源管理):文件 tab(有 path)在两种 `editorExplorer` 模式下 chrome 恒为合并形态——头部路径输入框 + 文本编辑器预览/编辑/保存控件 + 可开关的内嵌文件树面板(含全局文件名搜索,走 host `fs.search` 路由;左缘拖拽调宽),状态存 `tab.meta.treeOpen` / `tab.meta.treeWidth`;pref 控制**打开行为与无路径窗口形态**:开(默认,合并)= 树点击/输入框 Enter 原地切换当前 tab(`updateTab` 重写 path/title,id 与 meta 不变),无路径窗口 = 带 chrome 的空文件窗口(树默认展开);关(独立)= 走 `openSidebarFile` 按 path 新开,**无路径窗口即独立资源管理器——只渲染文件树面板**(搜索 + FileTree 撑满全窗,无编辑器 chrome)。树右键菜单提供「在新 Tab 中打开」「在侧边打开」(后者在当前 pane 右侧 split 出新 editor tab)。新会话在两种模式下都默认 seed 空文件窗口(`title: 'Files'`,无 path,树面板展开);持久化的旧 `explorer` tab 经 `sanitizeState` 迁移为该 home tab |
|
||||
| `git` | 20 | 是 | 否 | Git 面板 |
|
||||
| `subagent` | 30 | 是 | 否 | 子代理拓扑 |
|
||||
| `terminal` | 40 | 否 | 否 | 终端(nextTerminal 自增) |
|
||||
@@ -377,6 +405,11 @@ interface FileViewerProps {
|
||||
truncated?: boolean // fetchStrategy='fsRead' 时
|
||||
mediaUrl?: string // fetchStrategy='mediaUrl' 时
|
||||
customData?: unknown // fetchStrategy='custom' 时(load() 的返回值)
|
||||
// 以下三个为内置文本编辑器与 EditorHost 的内部协作字段(合并模式把
|
||||
// 预览/编辑/保存工具栏上移到路径输入框行);外部 viewer 忽略即可:
|
||||
toolbar?: 'self' | 'host'
|
||||
onToolbarState?: (state: EditorToolbarState) => void
|
||||
onToolbarControls?: (controls: EditorToolbarControls | null) => void
|
||||
}
|
||||
```
|
||||
|
||||
@@ -501,7 +534,7 @@ interface BetterSidebarService {
|
||||
readonly version: string
|
||||
/** 单调能力清单(只增不删):'badge' | 'tabLifecycle' | 'updateTab' |
|
||||
* 'openFile' | 'targetedOpen' | 'stateSubscription' | 'tabMeta' |
|
||||
* 'pluginSettings' | 'urlTarget'——消费插件用 `features.includes('xxx')` 按能力 gate。 */
|
||||
* 'pluginSettings' | 'urlTarget' | 'settingSelect'——消费插件用 `features.includes('xxx')` 按能力 gate。 */
|
||||
readonly features: readonly string[]
|
||||
/** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。
|
||||
* session 未激活时 state/sessionId 为 undefined。 */
|
||||
@@ -514,7 +547,10 @@ interface BetterSidebarService {
|
||||
* 未知 id 严格 no-op);scope(v0.12.0+)随回调传递,同 closeTab */
|
||||
activateTab(tabId: string, scope?: SessionScope): void
|
||||
/** 在 scope.sessionId 的侧边栏编辑器打开一个文件(title 缺省为文件名;
|
||||
* id 按路径派生,与内置 open-path 拦截一致,不同文件可并排打开) */
|
||||
* id 按路径派生(`editor:` + path),与内置 open-path 拦截一致,不同文件
|
||||
* 可并排打开。注意:path 派生 id 只对 openFile/openSidebarFile 的打开成立——
|
||||
* 文件窗口的原地切换(editorExplorer 开)经 updateTab 重写 path/title,
|
||||
* tab id 保持稳定、不再与 path 对应) */
|
||||
openFile(scope: SessionScope, path: string, title?: string): void
|
||||
}
|
||||
|
||||
@@ -532,7 +568,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` / `browserInterceptHttp` / `browserInterceptHttps`)。**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+),`type: 'select'` 行是下拉选择(options 带 icon 时为大图标选项卡,`multi: true` 多选存 value 数组)——父级卡片关闭时齿轮隐藏;`settings.toggles` 的 **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `editorExplorer` / `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 起也有齿轮)。
|
||||
|
||||
---
|
||||
|
||||
@@ -555,12 +591,32 @@ interface OpenTabSeed {
|
||||
| **host 半无此服务** | `ctx.betterSidebar` 只在 client 侧存在;host 半需要 better-sidebar 数据走 `/sidebar/api/*` HTTP 路由 |
|
||||
| **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换 |
|
||||
| **id 冲突** | `registerTab` / `registerFileViewer` 对重复 id 抛错;建议用包前缀(`my-plugin:xxx`) |
|
||||
| **家族右面板互斥(v0.13.0+)** | 读取 `aionui-panel` 设置命名空间的 `rightPanel`:解析为 `'aionui-panel'` 时整个侧边栏不挂载(`settings.get` 路由返回 `externalDisable: true`,客户端挂载门 + 各类接管一并停用;`settings/document-updated` 推送实时生效,无 `remote` 服务的部署回退为启动时判定)。未安装 aionui 或提供方为其他值时不受影响 |
|
||||
| **i18n 跟随** | 侧边栏界面文案跟随 DSH 的 `ctx.locale`(`@deepseek-ai/dsh-client-locale`):词典注册在 `betterSidebar` 命名空间,语言偏好(Host-backed `locale.preference`)与浏览器语言不一致时以 DSH 为准并实时切换;locale 服务缺失时回退浏览器语言。插件自身的 `t()`(`src/client/locales.ts`)由 `apply()` 挂接服务;消费插件**不要**依赖此内部函数——标题等字段传字符串或 `() => string` 即可(i18n 友好)。⚠️ 渲染 DSH 的 `MarkdownText` 时必须传 `codeLabels={{ copyLabel: t('copy'), copiedLabel: t('copied') }}`——该组件 cordis-free,漏传则代码块复制按钮回退硬编码中文 |
|
||||
| **懒加载 chunk** | 内置重依赖(xterm/CodeMirror)在独立 bundle(`lib/client-<name>.js`)中,经 `/sidebar/bundle` 路由按需下发;每个脚本把 factory 赋到插件自有全局注册表 `globalThis.__dshChunks__[<name>]`,由 `src/client/chunk-loader.ts` 用自定义 require(externals 经 `__DSH_MODULES__` seed 分支解析)物化——**不经过** `__ModuleLoader__` 注册;**核心 bundle 禁止静态 import `src/client/chunks/*`**(会把库拖回启动路径);对消费插件透明——懒加载只作用于内置 descriptor,`component` 契约(`(props) => ReactNode` 纯渲染函数)不变 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 完整最小示例
|
||||
## 8. 皮肤兼容(令牌驱动)
|
||||
|
||||
> 侧边栏的皮肤兼容是**令牌驱动**的:所有视觉值都消费 DSH 的 `--dsw-alias-*` / `--dsw-font-*` / `--ds-*` 令牌(无硬编码颜色),皮肤系统覆盖什么令牌我们就跟随什么——**不做任何每皮肤适配**。当前已与 dsh-web-ui 皮肤中心兼容:其 10 款皮肤全部覆盖 `--dsw-alias-*` 层,换肤后面板自动跟随(测试:`tests/e2e/mount.e2e.ts` 断言布局变量随面板挂载生效;`tests/theme.spec.ts` 守护令牌读取)。
|
||||
|
||||
### 8.1 规则
|
||||
|
||||
- **面板表面**:右/底面板背景 = `var(--dsw-alias-bg-layer-1)`(通用卡片表面)。**绝不消费 `--dsw-specific-sidebar-fill`**——那是宿主左侧导航列专属令牌,皮肤系统按左导航语义覆盖它(dsh-web-ui 皮肤把它做成半透明玻璃或主题色,Aqua 设成 `transparent`),面板消费它会失去填充或与标签令牌冲突。皮肤要整体换面板表面:覆写 `--dsw-alias-bg-layer-1` 即可(dsh-web-ui 10 款皮肤都已覆盖,无需任何额外工作)。
|
||||
- **终端/编辑器表面**:经 `effectiveTokenValue` 读取 `--dsw-alias-bg-base`——`transparent` 与 alpha < 0.9 的半透明玻璃值(dsh-web-ui 皮肤用 rgba 0.16–0.7)一律回退不透明底色,文字永不叠在皮肤背景画上滚动(issue #90);≥ 0.9 的近不透明值(如皮肤作用域内 0.96 的瓷器玻璃)放行,皮肤仍能控制终端表面。
|
||||
- **根锚点**:宿主 div 带 `data-dsh-better-sidebar` 属性(append 到 `document.body`),面板是其 fixed 直接子级。皮肤若要做作用域覆盖(deep-whale 的做法),限定在 `[data-dsh-better-sidebar]` 内即可,避免全局改写影响宿主。
|
||||
- **布局变量**(写在 `<html>` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`(面板几何;拖拽期间逐帧更新)。
|
||||
- **z-index**:面板 40、折叠按钮簇 45(角手柄在面板内层叠,z-index 2 仅面板内有效)——全部低于 DSH 浮层栈(100/1000+),任何浮层天然盖住侧边栏。
|
||||
|
||||
### 8.2 注意事项
|
||||
|
||||
- 类名是 CSS Modules 哈希(`[hash]_[local]`),**不是契约**——皮肤不要依赖类名寻址;需要精确命中单表面时,用 `[data-dsh-better-sidebar]` 属性选择器配合子串类名(如 `[class*='panel']`)或 DOM 结构。
|
||||
- 改动本契约(面板表面令牌、透明度阈值、z-index 层级)必须同步更新本文档、设计文档与 `tests/theme.spec.ts`。
|
||||
|
||||
---
|
||||
|
||||
## 9. 完整最小示例
|
||||
|
||||
> 假设插件 `my-plugin` 要加一个"Database 浏览器" tab + `.csv` 文件预览器。
|
||||
|
||||
@@ -636,16 +692,17 @@ function parseCsv(text: string): string[][] { /* ... */ }
|
||||
|
||||
---
|
||||
|
||||
## 9. 参考实现
|
||||
## 10. 参考实现
|
||||
|
||||
better-sidebar 自己的内置 tab 和 viewer 就是参考实现("吃狗粮"):
|
||||
|
||||
- **`src/client/builtins/`**:7 个内置 tab(explorer/git/subagent/terminal/browser/editor/diff)+ 6 个内置 viewer(image/pdf/markdown/html/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.ts;Office 预览已迁至推荐插件,见 plugins-viewers.ts)
|
||||
- **`src/client/builtins/`**:6 个内置 tab(editor/git/subagent/terminal/browser/diff)+ 6 个内置 viewer(image/pdf/markdown/html/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.ts;Office 预览已迁至推荐插件,见 plugins-viewers.ts)
|
||||
- **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现
|
||||
- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套设置行:switch/text/number + 持久化)
|
||||
- **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating 测试
|
||||
- **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 6 viewer + 声明式元数据)
|
||||
- **`src/client/plugins-tabs.ts`** / **`src/client/plugins-viewers.ts`**:推荐插件目录(名字/url/简介/安装脚本,分别对应 Tab 注册与文件预览注册),在设置页两个「添加插件」弹窗展示(共享类型在 `plugins-shared.ts`);插件作者可按扩展点加一条数据(弹窗内「跳转」直达仓库、「复制」把安装命令写入剪贴板,粘贴到 DSH 所在环境的终端执行)——数据完整性由 `tests/plugin-list.spec.ts` 守护
|
||||
- **`src/client/FileTree.tsx`** / **`src/client/TreePanel.tsx`** / **`src/fs-search.ts`**:受控文件树组件(纯树体,文件行右键菜单含「在新 Tab 中打开」「在侧边打开」,仅宿主编排提供回调时渲染)/ 树面板(搜索框 + 刷新 + FileTree,文件窗口的内嵌 dock 使用)与 host 侧递归文件名搜索(`fs.search` 路由,预算兜底 + 跳过 `.git`/symlink 目录;测试 `tests/fs-search.spec.ts`、组件测试 `tests/editor-host.spec.tsx`)
|
||||
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`** / **`docs/plans/2026-08-14-add-plugins-modal-design.md`**:设计文档(含实施偏差记录)
|
||||
|
||||
调试时直接读这些文件即可看到所有 API 的真实用法。
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
## ✨ 功能一览
|
||||
|
||||
- **🗂️ 文件工作台**:资源管理器(懒加载目录树)+ CodeMirror 编辑器;图片 / Markdown / HTML / PDF / Office 内联预览
|
||||
- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown / HTML / PDF / Office 内联预览
|
||||
- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整)
|
||||
- **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具
|
||||
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原
|
||||
@@ -36,113 +36,81 @@
|
||||
|
||||
## 🆕 最近更新
|
||||
|
||||
<small>v0.12.2</small>
|
||||
<div align="center">
|
||||
<a href="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0"><img width="45%" alt="服务化基座截图" src="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0" /></a>
|
||||
<a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="45%" alt="添加插件截图" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a>
|
||||
</div>
|
||||
|
||||
> 📝 **说明**:本版新增「位置兼容模式」——为 Windows 右上角原生标题栏预留顶部空间,侧边栏整体下移,距离可在齿轮弹窗自定义(0–120px)。随 0.12.0 起的服务化基座一同发布:能力探测、状态订阅、tab 角标、生命周期回调、外链认领(`urlTarget`)与插件自有设置均已就绪,第三方插件可深度接入;设置页新增「添加插件」推荐目录。详细条目见下表。
|
||||
### v0.13.0
|
||||
|
||||
| 功能 | 说明 | 截图 |
|
||||
|---|---|---|
|
||||
| 📐 位置兼容模式 | 设置页新增「位置兼容模式」开关:为 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 预览盘符路径 | |
|
||||
**✨ 新功能**
|
||||
|
||||
- 🔀 **与 dsh-web-ui 家族右侧面板互斥**:读取 `aionui-panel` 设置命名空间的提供方选择——当选择「使用 aionui-panel」时,整个 better-sidebar(右侧栏 / 底部面板 / 浮动入口 / 各类接管)不再挂载;选择 DSH-better-sidebar(或未安装 aionui)时正常。设置页保存后实时生效(settings-document 推送),无需刷新
|
||||
|
||||
### v0.12.3
|
||||
|
||||
**✨ 新功能**
|
||||
|
||||
- 🎨 **皮肤兼容(令牌驱动)**:全面消费 DSH 设计令牌,与 dsh-web-ui 皮肤中心 10 款皮肤兼容,换肤自动跟随;终端/编辑器表面在透明/半透明玻璃值下回退不透明底色,文字不叠在皮肤背景上([#110](https://github.com/omdsh-dev/DSH-better-sidebar/pull/110),修复 #106 #105 #90 #60,附带 #52 #57 #92)
|
||||
- 🗂️ **统一路径处理**:UNC 路径 / 软链接分类(目录软链接可展开、失效链接标红)、HTML 路由平台守卫([#134](https://github.com/omdsh-dev/DSH-better-sidebar/pull/134),#65 #67 #43 #79 #115)
|
||||
- 🖥️ **终端 shell 可配置**:设置项自定义 shell,Windows 自动探测 pwsh([#95](https://github.com/omdsh-dev/DSH-better-sidebar/pull/95))
|
||||
- 📝 **编辑器新增语言**:C# / Kotlin / Swift 语法高亮([#120](https://github.com/omdsh-dev/DSH-better-sidebar/pull/120))
|
||||
- 🧭 **设置页导航图标**:设置页导航图标与布局优化([#114](https://github.com/omdsh-dev/DSH-better-sidebar/pull/114))
|
||||
- ➕ **推荐插件目录新增**:`dsh-git-remotes`——Git 远程 Tab(分支/上游/ahead-behind、fetch 可 prune、ff-only pull、确认后才 push,不替换内置暂存/提交)([#91](https://github.com/omdsh-dev/DSH-better-sidebar/pull/91));`dsh-video-preview`——视频内联预览(.mp4/.webm/.mov/.mkv/.avi 等,自带 /video 宿主路由支持 HTTP Range 206 拖进度条,不受 20MB mediaLimit 限制)([#126](https://github.com/omdsh-dev/DSH-better-sidebar/pull/126))
|
||||
|
||||
**🐛 修复**
|
||||
|
||||
- 🔧 **xterm 依赖迁移**:弃用的 xterm 迁移至 `@xterm/xterm`(Closes [#122](https://github.com/omdsh-dev/DSH-better-sidebar/issues/122),[#128](https://github.com/omdsh-dev/DSH-better-sidebar/pull/128))
|
||||
- 📝 **Markdown 编辑器**:选区转对话弹窗恢复可用([#24](https://github.com/omdsh-dev/DSH-better-sidebar/pull/24))
|
||||
- 🐛 **node-pty 加载失败不再拖垮 server**([#140](https://github.com/omdsh-dev/DSH-better-sidebar/issues/140)):宿主半改为懒加载 node-pty,缺失时插件照常挂载,终端以修复提示横幅(可复制命令 + 重试按钮)呈现,agent 终端工具自动跳过
|
||||
- 🧪 测试工程:单元测试拆分(#141)+ smoke 偶发失败修复
|
||||
|
||||
**🚀 工程**
|
||||
|
||||
- 接入 GitHub Release 自动发版 npm(Trusted Publishing,产物带 provenance),本版起打 tag 即自动发布([#148](https://github.com/omdsh-dev/DSH-better-sidebar/pull/148))
|
||||
|
||||
### v0.12.2
|
||||
|
||||
- 📐 **位置兼容模式**:设置页新增开关:为 Windows 右上角原生标题栏预留顶部空间,侧边栏按钮与内容整体下移(默认关闭);下移距离可在齿轮弹窗中自定义(0–120px)
|
||||
- 🔌 **服务化基座**:完整类型导出 + `version`/`features` 能力探测、状态订阅(`getSnapshot`/`subscribeState`)、tab 角标、`onOpen`/`onActivate`/`onClose` 生命周期回调、`updateTab`/`activateTab`/`openFile`、定向打开、`meta` 跨刷新持久化、插件自有设置(`pluginToggles`/`render`)、外链点击目标认领(`urlTarget`)
|
||||
- ➕ **添加插件**:设置页「推荐插件目录」+ 一键复制安装命令;内置 Office 预览迁至推荐插件
|
||||
- 🖱️ **标签页滚轮**:标签页栏支持鼠标滚轮横向滚动
|
||||
- 🐛 **修复**:远程访问 403(信任栅栏改用 `trustedHosts`)、侧边栏崩溃 [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31)、Windows 下 HTML 预览盘符路径
|
||||
|
||||
### v0.12.1
|
||||
|
||||
- 🔌 **服务化基座**:完整类型导出 + `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 预览盘符路径
|
||||
|
||||
> 📝 说明:0.12.0 正式版因 npm 判定版本已发布无法复用,正式发布改用 0.12.1,两者内容一致。
|
||||
|
||||
### v0.12.0
|
||||
|
||||
- 🔌 **服务化基座**:完整类型导出 + `version`/`features` 能力探测、状态订阅、tab 角标、生命周期回调、定向打开、`meta` 跨刷新持久化、插件自有设置
|
||||
- ➕ **添加插件**:设置页「推荐插件目录」+ 一键复制安装命令;内置 Office 预览迁至推荐插件
|
||||
- 🖱️ **标签页滚轮**:标签页栏支持鼠标滚轮横向滚动
|
||||
- 🐛 **修复**:远程访问 403(信任栅栏改用 `trustedHosts`)、侧边栏崩溃 [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31)、Windows 下 HTML 预览盘符路径
|
||||
|
||||
## 🚀 安装
|
||||
|
||||
**前置**:已装好 DSH(`dsh web` 能正常运行),Node.js ≥ 20、pnpm ≥ 10。
|
||||
|
||||
**macOS / Linux**(Windows 装了 Git Bash 或 WSL 也可):
|
||||
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
**Windows(PowerShell 5.1+ / pwsh)**:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1 | iex
|
||||
dsh plugin --profile web add dsh-better-sidebar@latest
|
||||
```
|
||||
|
||||
装完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可看到侧边栏(DSH 对 client 改动热加载,无需重启;仅 host 半更新时需要重启)。
|
||||
|
||||
<details>
|
||||
<summary><b>指定版本 / 装完自动重启(可选)</b></summary>
|
||||
|
||||
```sh
|
||||
# macOS / Linux
|
||||
curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash -s 0.12.2 --restart
|
||||
|
||||
# Windows PowerShell
|
||||
& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.12.2 -Restart
|
||||
```
|
||||
|
||||
不确定的话,可先加 `--dry-run`(PowerShell 用 `-DryRun`)预览步骤再执行。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>手动安装(逐步命令,想看清每一步)</b></summary>
|
||||
|
||||
与一键脚本等价。**第 ③ 步可重复执行;①② 只需做一次。**
|
||||
|
||||
**macOS / Linux(bash)**:
|
||||
|
||||
```sh
|
||||
cd ~/.dsh/profiles/web
|
||||
|
||||
# ① 放行 node-pty / protobufjs 的构建脚本(pnpm 11 默认拦截;pnpm 10 可跳过)
|
||||
pnpm approve-builds --all
|
||||
|
||||
# ② 放行「发布不足 24h」的新版本(装老版本可跳过;若已有该键,把下面那行并入其下即可)
|
||||
cat >> pnpm-workspace.yaml <<'EOF'
|
||||
minimumReleaseAgeExclude:
|
||||
- dsh-better-sidebar
|
||||
EOF
|
||||
|
||||
# ③ 安装并自动挂载(不带 @版本 = npm 的 latest;固定版本写 dsh-better-sidebar@0.12.2)
|
||||
npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar
|
||||
```
|
||||
|
||||
**Windows(PowerShell)**:
|
||||
|
||||
```powershell
|
||||
cd ~\.dsh\profiles\web
|
||||
|
||||
# ① 放行构建脚本
|
||||
pnpm approve-builds --all
|
||||
|
||||
# ② 放行新版本(一次性;若已有该键,把 - dsh-better-sidebar 并入其下即可)
|
||||
Add-Content -Path pnpm-workspace.yaml -Value "`nminimumReleaseAgeExclude:`n - dsh-better-sidebar"
|
||||
|
||||
# ③ 安装并自动挂载
|
||||
npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>脚本内部做了什么(技术细节)</b></summary>
|
||||
|
||||
一键脚本自动完成 4 件事(全部幂等,可安全重复执行):
|
||||
|
||||
1. 预写 `allowBuilds`(node-pty / protobufjs),规避 pnpm 11 的构建脚本拦截;
|
||||
2. 预写 `minimumReleaseAgeExclude`,放行「发布不足 24 小时」的新版本;
|
||||
3. 执行 `dsh plugin --profile web add dsh-better-sidebar`:登记依赖 → 识别包内 `dsh.bundle.patch` → 自动注册进 `dsh.profile.bundles` 挂载;
|
||||
4. 清理旧版残留的手动挂载行,避免「双挂载」(页面出现两个侧边栏)。
|
||||
|
||||
`curl | bash` / `irm | iex` 会执行远程代码——脚本已随仓库开源(`scripts/install.sh` / `scripts/install.ps1`),可先下载审阅。插件以 npm 包 `dsh-better-sidebar@0.12.2` 发布,通过 `dsh.bundle.patch`(随包的 `cordis.patch.yml`)由官方 CLI 自动挂载,**不修改 DSH 源码**。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>更新</b></summary>
|
||||
|
||||
```sh
|
||||
dsh plugin --profile web add dsh-better-sidebar
|
||||
dsh plugin --profile web add dsh-better-sidebar@latest
|
||||
```
|
||||
|
||||
或重跑一次一键脚本;也可把 `~/.dsh/profiles/web/package.json` 里的版本号改高后 `pnpm install`。改完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可(client 改动无需重启 DSH)。
|
||||
也可把 `~/.dsh/profiles/web/package.json` 里的版本号改高后 `pnpm install`。改完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可(client 改动无需重启 DSH)。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -151,12 +119,13 @@ dsh plugin --profile web add dsh-better-sidebar
|
||||
|
||||
| 现象 | 原因与解决 |
|
||||
|---|---|
|
||||
| 报 `Ignored build scripts` | pnpm 11 拦截构建脚本。跑 `pnpm approve-builds --all`(一键脚本已自动处理)。 |
|
||||
| 报 `minimum release age` / 版本不足 24h | 装的版本发布不足 24 小时。等 24h 或重跑一次(pnpm 会自动补 `minimumReleaseAgeExclude`);一键脚本已自动处理。 |
|
||||
| 报 `Ignored build scripts` | pnpm 11 拦截构建脚本。在 profile 目录(`~/.dsh/profiles/web`)跑 `pnpm approve-builds --all`。 |
|
||||
| 报 `minimum release age` / 版本不足 24h | 装的版本发布不足 24 小时。等 24h 或重跑一次(pnpm 会自动补 `minimumReleaseAgeExclude`)。 |
|
||||
| 报「找不到 profile 目录」 | 先跑一次 `dsh web`,让它初始化 `~/.dsh/profiles/web`。 |
|
||||
| 页面出现**两个侧边栏** | 双挂载:`~/.dsh/profiles/web/cordis.patch.yml` 还留着旧的手动挂载行,删掉那段 `- insert: ... better-sidebar ...`(一键脚本会自动清)。 |
|
||||
| 页面出现**两个侧边栏** | 双挂载:`~/.dsh/profiles/web/cordis.patch.yml` 还留着旧的手动挂载行,删掉那段 `- insert: ... better-sidebar ...`。 |
|
||||
| Windows 下终端无法使用 | `node-pty` 依赖预编译二进制;若当前 Node 版本没有对应产物,需装编译工具链(VS Build Tools)。主流 Node 版本一般已有预编译。 |
|
||||
| Windows 没有 bash / curl | 直接用 PowerShell 一键命令;或安装 Git Bash / WSL 再跑 bash 命令。 |
|
||||
| 终端提示「node-pty 加载失败」 | `node-pty` 安装缺失/损坏(如 pnpm 拦截了构建脚本)。终端横幅会给出修复命令:复制到 DSH 所在环境的终端/cmd 执行(在 `~/.dsh/profiles/web` 下 `pnpm approve-builds --all && pnpm rebuild node-pty`),完成后重启 DSH 并点重试。插件与 DSH 核心使用同一 `node-pty@^1.1.0`,修复后两者同步恢复。 |
|
||||
| 提示 `dsh: command not found` | 先安装 DSH;或直接用 `npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar@latest`。 |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -177,7 +146,7 @@ dsh plugin --profile web add dsh-better-sidebar
|
||||
5. 硬刷新浏览器(Cmd/Ctrl+Shift+R)即可看到效果(client 改动无需重启 DSH;host 半改动才需重启)
|
||||
```
|
||||
|
||||
更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.12.2"` 再 `pnpm install`。
|
||||
更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.13.0"` 再 `pnpm install`。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+64
-95
@@ -21,7 +21,7 @@
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **🗂️ File Workbench**: file explorer (lazy-loading tree) + CodeMirror editor; inline preview for images / Markdown / HTML / PDF / Office
|
||||
- **🗂️ File Workbench**: file explorer (lazy-loading tree; symlinks show their target kind — directory links expand, dangling links flagged) + CodeMirror editor; inline preview for images / Markdown / HTML / PDF / Office
|
||||
- **🌐 Embedded Browser**: multiple web tabs with back / forward / refresh; content runs in a sandboxed iframe; external links are routed by protocol by default — HTTP opens in the sidebar, HTTPS goes to the system browser (both adjustable in settings)
|
||||
- **💻 Real Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model
|
||||
- **🌿 Git Panel**: real diff + VSCode-style diff tabs, history, right-click to stage / commit / revert
|
||||
@@ -36,113 +36,81 @@
|
||||
|
||||
## 🆕 Recent Updates
|
||||
|
||||
<small>v0.12.2</small>
|
||||
<div align="center">
|
||||
<a href="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0"><img width="45%" alt="Service API base screenshot" src="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0" /></a>
|
||||
<a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="45%" alt="Add Plugins screenshot" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a>
|
||||
</div>
|
||||
|
||||
> 📝 **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.
|
||||
### v0.13.0
|
||||
|
||||
| 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 | |
|
||||
**✨ New features**
|
||||
|
||||
- 🔀 **Mutual exclusion with the dsh-web-ui family right panel**: reads the `aionui-panel` settings namespace's provider choice — when "Use aionui-panel" is selected, the whole better-sidebar (right sidebar / bottom panel / floating entry / all takeovers) does not mount; with DSH-better-sidebar (or no aionui installed) it behaves as before. Takes effect live after a settings save (settings-document push), no reload needed
|
||||
|
||||
### v0.12.3
|
||||
|
||||
**✨ New features**
|
||||
|
||||
- 🎨 **Skin compatibility (token-driven)**: fully consumes DSH design tokens and follows the dsh-web-ui skin center's 10 skins automatically; terminal/editor surfaces fall back to opaque backgrounds under transparent/translucent-glass token values so text never scrolls over the skin art ([#110](https://github.com/omdsh-dev/DSH-better-sidebar/pull/110), fixes #106 #105 #90 #60, also #52 #57 #92)
|
||||
- 🗂️ **Unified path handling**: UNC / symlink classification (directory symlinks expandable, broken links highlighted) + HTML-route platform guards ([#134](https://github.com/omdsh-dev/DSH-better-sidebar/pull/134), #65 #67 #43 #79 #115)
|
||||
- 🖥️ **Configurable terminal shell**: custom shell setting with Windows pwsh auto-probe ([#95](https://github.com/omdsh-dev/DSH-better-sidebar/pull/95))
|
||||
- 📝 **Editor languages**: C# / Kotlin / Swift syntax highlighting ([#120](https://github.com/omdsh-dev/DSH-better-sidebar/pull/120))
|
||||
- 🧭 **Settings nav icon**: settings-page navigation icon and layout polish ([#114](https://github.com/omdsh-dev/DSH-better-sidebar/pull/114))
|
||||
- ➕ **Recommended-plugin catalog**: added `dsh-git-remotes` — Git Remotes tab (branches/upstream/ahead-behind, fetch with prune, ff-only pull, confirm-before-push; does not replace the built-in stage/commit tab) ([#91](https://github.com/omdsh-dev/DSH-better-sidebar/pull/91)); and `dsh-video-preview` — inline video preview (.mp4/.webm/.mov/.mkv/.avi etc.) backed by a /video host route with HTTP Range (206) scrubbing, not capped by the 20MB mediaLimit ([#126](https://github.com/omdsh-dev/DSH-better-sidebar/pull/126))
|
||||
|
||||
**🐛 Fixes**
|
||||
|
||||
- 🔧 **xterm migration**: deprecated xterm dependency migrated to `@xterm/xterm` (Closes [#122](https://github.com/omdsh-dev/DSH-better-sidebar/issues/122), [#128](https://github.com/omdsh-dev/DSH-better-sidebar/pull/128))
|
||||
- 📝 **Markdown editor**: selection-to-conversation popup restored ([#24](https://github.com/omdsh-dev/DSH-better-sidebar/pull/24))
|
||||
- 🐛 **node-pty load failure no longer crashes the server** ([#140](https://github.com/omdsh-dev/DSH-better-sidebar/issues/140)): the host half now lazy-loads node-pty — when it is missing the plugin still mounts, the terminal shows a repair banner (copyable command + Retry button), and agent terminal tools are skipped
|
||||
- 🧪 Test engineering: unit spec split (#141) + flaky smoke cleanup fix
|
||||
|
||||
**🚀 Engineering**
|
||||
|
||||
- npm publishing wired to GitHub Releases (Trusted Publishing, provenance-attached tarballs); tagging a release publishes automatically ([#148](https://github.com/omdsh-dev/DSH-better-sidebar/pull/148))
|
||||
|
||||
### v0.12.2
|
||||
|
||||
- 📐 **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`)
|
||||
- ➕ **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
|
||||
|
||||
### v0.12.1
|
||||
|
||||
- 🔌 **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
|
||||
|
||||
> 📝 Note: the 0.12.0 final could not be reused (npm reported the version as already published), so the public release became 0.12.1 — both carry identical content.
|
||||
|
||||
### v0.12.0
|
||||
|
||||
- 🔌 **Service API base**: complete type exports + `version`/`features` capability detection, state subscription, tab badges, lifecycle callbacks, targeted open, `meta` persisted across reloads, plugin-owned settings
|
||||
- ➕ **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
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
**Prerequisites**: DSH installed (`dsh web` boots), Node.js ≥ 20, pnpm ≥ 10.
|
||||
|
||||
**macOS / Linux** (also works in Git Bash / WSL on Windows):
|
||||
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
**Windows (PowerShell 5.1+ / pwsh)**:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1 | iex
|
||||
dsh plugin --profile web add dsh-better-sidebar@latest
|
||||
```
|
||||
|
||||
Then **hard-refresh the browser** (Cmd/Ctrl+Shift+R) to see the sidebar (DSH hot-reloads client changes; only host-half updates need a restart).
|
||||
|
||||
<details>
|
||||
<summary><b>Pin a version / auto-restart (optional)</b></summary>
|
||||
|
||||
```sh
|
||||
# macOS / Linux
|
||||
curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash -s 0.12.2 --restart
|
||||
|
||||
# Windows PowerShell
|
||||
& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.12.2 -Restart
|
||||
```
|
||||
|
||||
Not sure? Add `--dry-run` (`-DryRun` in PowerShell) to preview before running.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Manual install (step by step)</b></summary>
|
||||
|
||||
Equivalent to the one-click script. **Step ③ is repeatable; ①② only need to run once.**
|
||||
|
||||
**macOS / Linux (bash)**:
|
||||
|
||||
```sh
|
||||
cd ~/.dsh/profiles/web
|
||||
|
||||
# ① Allow node-pty / protobufjs build scripts (pnpm 11 blocks them by default; skip on pnpm 10)
|
||||
pnpm approve-builds --all
|
||||
|
||||
# ② Allow versions published less than 24h ago (skip for older releases; if the key already exists, merge the line under it instead)
|
||||
cat >> pnpm-workspace.yaml <<'EOF'
|
||||
minimumReleaseAgeExclude:
|
||||
- dsh-better-sidebar
|
||||
EOF
|
||||
|
||||
# ③ 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
|
||||
```
|
||||
|
||||
**Windows (PowerShell)**:
|
||||
|
||||
```powershell
|
||||
cd ~\.dsh\profiles\web
|
||||
|
||||
# ① Allow build scripts
|
||||
pnpm approve-builds --all
|
||||
|
||||
# ② Allow fresh releases (once; if the key already exists, merge - dsh-better-sidebar under it instead)
|
||||
Add-Content -Path pnpm-workspace.yaml -Value "`nminimumReleaseAgeExclude:`n - dsh-better-sidebar"
|
||||
|
||||
# ③ Install and auto-mount
|
||||
npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>What the script does (technical details)</b></summary>
|
||||
|
||||
The one-click script does four things, all idempotent (safe to re-run):
|
||||
|
||||
1. Pre-writes `allowBuilds` (node-pty / protobufjs) to dodge pnpm 11's build-script block;
|
||||
2. Pre-writes `minimumReleaseAgeExclude` to allow versions younger than 24 hours;
|
||||
3. Runs `dsh plugin --profile web add dsh-better-sidebar`: registers the dependency → detects `dsh.bundle.patch` → auto-appends the plugin to `dsh.profile.bundles`;
|
||||
4. Removes any leftover hand-written mount line to avoid double-mounting (two sidebars on the page).
|
||||
|
||||
`curl | bash` / `irm | iex` executes remote code — the scripts are open source in the repo (`scripts/install.sh` / `scripts/install.ps1`); download and review them first if you prefer. The plugin ships as npm package `dsh-better-sidebar@0.12.2` and mounts via `dsh.bundle.patch` (the shipped `cordis.patch.yml`), so the DSH source is never modified.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Updating</b></summary>
|
||||
|
||||
```sh
|
||||
dsh plugin --profile web add dsh-better-sidebar
|
||||
dsh plugin --profile web add dsh-better-sidebar@latest
|
||||
```
|
||||
|
||||
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.
|
||||
or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.13.0"`) and run `pnpm install`. Then hard-refresh the browser (Cmd/Ctrl+Shift+R) — client changes do not need a DSH restart.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -151,12 +119,13 @@ or re-run the one-click script; or bump the version in `~/.dsh/profiles/web/pack
|
||||
|
||||
| Symptom | Cause & fix |
|
||||
|---|---|
|
||||
| `Ignored build scripts` | pnpm 11 blocked build scripts. Run `pnpm approve-builds --all` (the one-click script handles it). |
|
||||
| `minimum release age` / version `< 24h` | The release is younger than 24 hours. Wait, or re-run once (pnpm auto-adds `minimumReleaseAgeExclude`); the one-click script handles it. |
|
||||
| `Ignored build scripts` | pnpm 11 blocked build scripts. Run `pnpm approve-builds --all` in the profile directory (`~/.dsh/profiles/web`). |
|
||||
| `minimum release age` / version `< 24h` | The release is younger than 24 hours. Wait, or re-run once (pnpm auto-adds `minimumReleaseAgeExclude`). |
|
||||
| "profile directory not found" | Run `dsh web` once so it initializes `~/.dsh/profiles/web`. |
|
||||
| Two sidebars on the page | Double-mount: `~/.dsh/profiles/web/cordis.patch.yml` still has the old hand-written `- insert: ... better-sidebar ...` line — delete it (the one-click script cleans it). |
|
||||
| Two sidebars on the page | Double-mount: `~/.dsh/profiles/web/cordis.patch.yml` still has the old hand-written `- insert: ... better-sidebar ...` line — delete it. |
|
||||
| Terminal fails on Windows | `node-pty` relies on prebuilt binaries; if none match your Node version, install a build toolchain (VS Build Tools). Mainstream Node versions are usually covered. |
|
||||
| No bash / curl on Windows | Use the PowerShell one-click command, or install Git Bash / WSL and run the bash commands. |
|
||||
| Terminal shows "node-pty failed to load" | The `node-pty` install is missing or broken (e.g. pnpm skipped its build script). The terminal banner shows a repair command — copy it into a terminal/cmd on the DSH machine and run it (in `~/.dsh/profiles/web`: `pnpm approve-builds --all && pnpm rebuild node-pty`), then restart DSH and click Retry. The plugin and DSH core share the same `node-pty@^1.1.0`, so the repair restores both. |
|
||||
| `dsh: command not found` | Install DSH first, or run `npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar@latest`. |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -177,7 +146,7 @@ To debug local changes or track the dev branch, point the dependency at a local
|
||||
5. Restart DSH and hard-refresh
|
||||
```
|
||||
|
||||
Update: `git pull && pnpm install && pnpm build` → just hard-refresh the browser (client changes hot-reload; only host-half changes need a DSH restart). To switch back to the npm channel, restore `"dsh-better-sidebar": "^0.12.2"` 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.13.0"` and re-run `pnpm install`.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -480,7 +480,7 @@ const { value } = await res.json() // 错误时 { ok: false, error: { code, me
|
||||
| 方法 | 说明 |
|
||||
|---|---|
|
||||
| `session.cwd` | 会话权威 cwd(`{ cwd, root, parent }`) |
|
||||
| `fs.tree` | 目录列表(`{ path, entries: FsEntry[], truncated }`) |
|
||||
| `fs.tree` | 目录列表(`{ path, entries: FsEntry[], truncated }`;FsEntry 含 `isSymlink`/`broken`,目录软链接的 `isDir` 按目标类型) |
|
||||
| `fs.read` | 读文件:文本返回 `{ kind: 'text', content, truncated }`;二进制返回 `{ kind: 'binary', size, truncated, head }`(head = base64 前 4KB) |
|
||||
| `fs.write` | 原子写文件 |
|
||||
| `git.status` / `git.diff` / `git.log` 等 | 全套 Git 只读 + 写操作 |
|
||||
|
||||
@@ -260,7 +260,9 @@ if (officeKind === 'docx' || officeKind === 'xlsx') {
|
||||
- `xlsx`(SheetJS,~400KB,xlsx 导入)
|
||||
- `@univerjs/core` + `@univerjs/sheets` + `@univerjs/sheets-ui` + `@univerjs/sheets-formula` + `@univerjs/sheets-conditional-formatting` + `@univerjs/sheets-chart` + `@univerjs/docs-ui` + `@univerjs/design` + `@univerjs/engine-render`(~5MB 合计)
|
||||
|
||||
**不放 `peerDependencies`**:这些是插件自有运行时依赖,不由 web profile 提供(对比现有 `node-pty`/`xterm`/`CodeMirror` 也在 `dependencies`)。
|
||||
**不放 `peerDependencies`**:这些是插件自有运行时依赖,不由 web profile 提供(对比现有 `node-pty`/`CodeMirror` 也在 `dependencies`)。
|
||||
|
||||
> **实施偏差记录(#122,2026-08)**:上文"`xterm` 也在 `dependencies`"已过时——`xterm` 与 `@xterm/addon-fit` 自 v0.13.0 起从 `dependencies` 迁至 `devDependencies`:二者始终内联进 `lib/client-terminal.js`(tsdown `noExternal` 全内联 + CSS 构建期解析),运行时零依赖;同时代码与开发依赖已从已弃用的 `xterm` 迁移到官方新包名 `@xterm/xterm@^5.5.0`(addon-fit@0.10.0 的 peer 约束锁 `^5.0.0`,不用 6.x),消除用户安装时的 deprecated 与 missing-peer 警告。Office 预览若落地,其依赖按同一原则处理。
|
||||
|
||||
### 5.2 tsdown.config.ts 调整
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ URL 采用**路径编码**(无 query)——WHATSG URL 解析对纯路径相
|
||||
- 设计初期考虑过 srcdoc + `<base>` 注入以实现"预览反映未保存草稿",因 srcdoc 无沙箱时继承父源的致命风险与 base 注入的脆弱性,改采 route-src(预览 = 已保存文件);相对资源经路径编码 URL 天然解析,无需内容改写。
|
||||
- `normalizeBrowserUrl` 的 scheme 判别:朴素正则会把 `example.com:8080` 误判为 scheme,最终采用「`://` 完整 URL + 危险 scheme 黑名单 + 解析后协议兜底」三段式。
|
||||
- `nextBrowser` 在 sanitize 中为宽松字段(缺失/畸形回退 1),避免旧持久化布局整体失效(与严格校验的 `nextTerminal` 不同)。
|
||||
- **UNC 路径编码标记(PR #134 整合 #79/#115 时)**:`/sidebar/html` 对 `\\server\share\...` / `//server/share/...` 采用 sessionId 后的 `//` 标记,且解码统一重建为**前斜杠** `//server/share/...` 而非反斜杠——`node:path` 在 win32/POSIX 上分别解析为 `\\server\share\...` / `/server/share/...`,因此无需任何平台信号;原「400 双斜杠」约束改为「首个空段仅作 UNC 标记,其余空段仍 400」。
|
||||
|
||||
## 8.1 追加需求(2026-08-11,v0.5.0 内)
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@
|
||||
- 插件侧不可消除:不改 DSH 源码、不 patch node-pty 依赖是仓库硬约束(见 AGENTS.md §0),且 `IPty.kill()` 是 node-pty 的唯一 kill 通道;
|
||||
- 建议:保持现状(无功能影响),上游如提供无控制台规避选项再跟进;reporter 若在意日志噪音,可反馈 microsoft/node-pty。
|
||||
|
||||
> 交叉引用(issue #140):与本文档「仅刷日志」不同,**依赖缺失/损坏**(node-pty 安装不完整导致模块加载失败)已有插件侧降级处理——宿主半懒加载 node-pty,失败时终端 tab 显示可复制的修复命令(`scripts/install.sh --repair` / `install.ps1 -Repair`),详见 `src/pty-deps.ts` 与 `tests/pty-deps.spec.ts`。
|
||||
|
||||
## 4. 验证
|
||||
|
||||
- 全量测试:`pnpm test` → 34 文件 / 430 例全部通过(基线 425 + 同期 PR #43 的 html 盘符修复 1 例 + 本次新增 4 例);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# 皮肤兼容(令牌驱动):面板表面、终端透明度回退与独立 CSS 修复
|
||||
|
||||
**日期**:2026-08-15
|
||||
**状态**:已实施(本文档含实施偏差记录)
|
||||
**目标版本**:随发布 bump(本 PR 不改版本号、不更新 README——发布流程统一处理)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
让 better-sidebar 与 **dsh-web-ui 的皮肤中心**兼容:用户切换皮肤时侧边栏自动跟随换肤,零每皮肤适配(KISS:不做 aionui 式"每款皮肤 remap 一套令牌"的重活)。附带修复同批 CSS 问题([#52](https://github.com/omdsh-dev/DSH-better-sidebar/issues/52) 弹出框遮挡、[#57](https://github.com/omdsh-dev/DSH-better-sidebar/issues/57) 前半图标尺寸、[#92](https://github.com/omdsh-dev/DSH-better-sidebar/issues/92) 拖拽回归防护),并覆盖 [#60](https://github.com/omdsh-dev/DSH-better-sidebar/issues/60)/[#105](https://github.com/omdsh-dev/DSH-better-sidebar/issues/105)/[#90](https://github.com/omdsh-dev/DSH-better-sidebar/issues/90) 的相关诉求。
|
||||
|
||||
## 2. 调研结论
|
||||
|
||||
dsh-web-ui(`zhu1090093659/dsh-web-ui`)的皮肤机制:
|
||||
|
||||
- 皮肤 = cordis 插件(`ui-skin-<id>`),皮肤中心通过 `cordis.patch.yml` 切换激活;皮肤插件 client half 在 `document.body` 上打 `data-dsh-<skin>` 属性并注入 CSS。
|
||||
- **10 款皮肤全部覆盖两层令牌**:`--dsw-static-*` 基础色板 + `--dsw-alias-*` 语义层(`--dsw-alias-bg-layer-1`、`--dsw-alias-bg-base` 等做成半透明玻璃 rgba 0.16–0.7,`--dsw-specific-sidebar-fill` 也全部覆盖)。
|
||||
- dsh-web-ui 自家右侧面板(aionui)用独立 `--aion-*` 令牌,每款皮肤单独 remap——**重**。
|
||||
|
||||
结论:better-sidebar 只要**消费 DSH 标准令牌**(alias 层),换肤就自动跟随,不需要任何每皮肤适配。此前围绕第三方皮肤插件(Aqua/deep-whale)设计的类名改名与 `data-bs-*` 语义钩子按 KISS 原则**全部回退**(见 §6)。
|
||||
|
||||
## 3. 设计
|
||||
|
||||
### 3.1 面板表面(`sidebar.module.css`)
|
||||
|
||||
```css
|
||||
.panel { background: var(--dsw-alias-bg-layer-1); }
|
||||
.bottomPanel { background: var(--dsw-alias-bg-layer-1); }
|
||||
```
|
||||
|
||||
- 直接消费通用卡片表面令牌,**不新增自有令牌、不加属性钩子**。
|
||||
- **绝不消费 `--dsw-specific-sidebar-fill`**:那是宿主左侧导航列专属令牌,皮肤系统按左导航语义覆盖它(dsh-web-ui 半透明玻璃/主题色、Aqua `transparent`),面板消费它会失去填充或与标签令牌冲突。
|
||||
- stock 外观色阶变化仅 ±8 RGB(bluish-50→75 / 900→875),视觉无差;dsh-web-ui 换肤后面板获得与宿主一致的玻璃效果。
|
||||
|
||||
### 3.2 终端/编辑器透明度回退(`theme.ts` / `TerminalView.tsx`)
|
||||
|
||||
`effectiveTokenValue` 现在把以下值视为"未设置"(触发调用方 `||` 回退):
|
||||
|
||||
- 空串与 CSS 重置关键字(`transparent`/`initial`/`inherit`/`unset`);
|
||||
- **alpha < 0.9 的半透明颜色**(dsh-web-ui 皮肤的 `--dsw-alias-bg-base` 是 rgba 0.16–0.7 玻璃)。
|
||||
|
||||
新增 `colorAlpha()` 解析 computed 颜色(rgb()/rgba()/hsl()/hsla() 逗号与空格语法、`#rgb/#rgba/#rrggbb/#rrggbbaa`);无法解析的格式(命名色、`color()`)视为不透明放行。阈值 0.9:≥ 0.9 的近不透明值(如皮肤作用域内 0.96 瓷器玻璃)放行,皮肤仍能控制终端表面。
|
||||
|
||||
### 3.3 独立修复(与皮肤兼容无关,保留)
|
||||
|
||||
- **z-index(#52)**:`.toggleCluster` 55→45、`.panel`/`.bottomPanel` 50→40;角手柄移入面板后为面板内局部 `z-index: 2`。依据:DSH 浮层栈为 100(Menu/HoverCard/Tooltip/PopupSelect/Modal=1000),30–99 无任何 DSH 元素。
|
||||
- **角手柄几何 CSS 化**:移入右面板 DOM,`position: absolute; left: -6px; bottom: calc(var(--dsh-sidebar-height, 0px) + 6px)`——复用拖拽逐帧写入的布局变量,删除 `cornerRef` JS 内联坐标。
|
||||
- **刷新图标统一 `size={14}`(#57 前半)**:Explorer/Git/Diff 的 `IconRefreshOutline16` 显式 14px,与 Subagent/Browser 一致。
|
||||
- **拖拽逐帧回归 e2e(#92)**:`tests/e2e/drag-layout.e2e.ts` rAF 采样,断言拖拽期间 `data-dsh-sidebar-dragging` 存在、`#root` transition 为 none、会话列与面板边单调 1:1 跟随(实测通过)。拖条以 `cursor: col-resize` 语义定位(无属性钩子可依赖)。
|
||||
|
||||
## 4. 测试
|
||||
|
||||
- `tests/theme.spec.ts`:`colorAlpha`(hex/rgb/hsl/不可解析)与 `effectiveTokenValue`(transparent/关键词/半透明回退/0.96 放行/缺失)。
|
||||
- `tests/e2e/mount.e2e.ts`:追加 `--dsh-sidebar-width` 布局变量随面板挂载生效的断言(令牌驱动契约的挂载级守护)。
|
||||
- `tests/e2e/drag-layout.e2e.ts`:拖拽逐帧回归。
|
||||
- 门禁:`pnpm typecheck && pnpm test && pnpm build && pnpm pack && pnpm test:mount`。
|
||||
|
||||
## 5. 文档
|
||||
|
||||
- AGENTS.md §8「皮肤兼容(令牌驱动)」:面板表面令牌、透明度阈值、z-index 层级、根锚点 `[data-dsh-better-sidebar]`、类名非契约。
|
||||
- README 不在本批更新(发布时随版本条目更新)。
|
||||
|
||||
## 6. 实施偏差记录
|
||||
|
||||
- **首版契约(已回退)**:初版围绕第三方皮肤插件设计了 `--dsh-sidebar-surface`/`--dsh-resize-strip-offset` 自有令牌(review 后已删)、panel 家族类名 kebab-case 改名(breaking)与 `data-bs-*` 语义钩子。确认主目标是 dsh-web-ui 皮肤中心后,按 KISS 全部回退:令牌驱动下这些都不需要,改名还引入无谓 breaking。最终契约 = 消费标准令牌 + 透明度阈值,无自有概念。
|
||||
- **角手柄 `z-index` 语义变化**:移入面板后 z-index 为面板内层叠(2,与拖条一致),对外绝对层级由面板(40)决定;两面板同开时手柄盒与底面板上沿无重叠(≥6px 间隙),行为不变。
|
||||
- **拖拽禁用断言修正**:`transition: none` 的 computed 值为 `transition-property: none`(非 `all`),已按实测修正。
|
||||
- **面板默认表面色阶微调**:±8 RGB,未做像素级补偿(e2e 不依赖具体颜色)。
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "dsh-external/dsh-better-sidebar",
|
||||
"version": "0.12.2",
|
||||
"version": "0.13.0",
|
||||
"main": "./lib/index.js",
|
||||
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
|
||||
"engines": {
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dsh-better-sidebar",
|
||||
"version": "0.12.2",
|
||||
"version": "0.13.0",
|
||||
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
@@ -129,13 +129,11 @@
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/view": "^6.43.8",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"clsx": "^2.1.1",
|
||||
"node-pty": "^1.1.0",
|
||||
"rxjs": "^7.8.2",
|
||||
"schemastery": "^3.18.0",
|
||||
"ws": "^8.18.0",
|
||||
"xterm": "^5.3.0"
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
@@ -159,6 +157,8 @@
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.1",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"jsdom": "^29.1.1",
|
||||
"lightningcss": "^1.32.0",
|
||||
|
||||
Generated
+12
-22
@@ -74,9 +74,6 @@ importers:
|
||||
'@lezer/highlight':
|
||||
specifier: ^1.2.3
|
||||
version: 1.2.3
|
||||
'@xterm/addon-fit':
|
||||
specifier: ^0.10.0
|
||||
version: 0.10.0(@xterm/xterm@5.4.0)
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
@@ -92,9 +89,6 @@ importers:
|
||||
ws:
|
||||
specifier: ^8.18.0
|
||||
version: 8.21.3
|
||||
xterm:
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
devDependencies:
|
||||
'@cordisjs/plugin-loader':
|
||||
specifier: ^1.0.0-rc.5
|
||||
@@ -159,6 +153,12 @@ importers:
|
||||
'@types/ws':
|
||||
specifier: ^8.5.10
|
||||
version: 8.18.1
|
||||
'@xterm/addon-fit':
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0
|
||||
'@xterm/xterm':
|
||||
specifier: ^5.5.0
|
||||
version: 5.5.0
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.8(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
@@ -1186,13 +1186,11 @@ packages:
|
||||
'@vitest/utils@4.1.10':
|
||||
resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==}
|
||||
|
||||
'@xterm/addon-fit@0.10.0':
|
||||
resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==}
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^5.0.0
|
||||
'@xterm/addon-fit@0.11.0':
|
||||
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
|
||||
|
||||
'@xterm/xterm@5.4.0':
|
||||
resolution: {integrity: sha512-GlyzcZZ7LJjhFevthHtikhiDIl8lnTSgol6eTM4aoSNLcuXu3OEhnbqdCVIjtIil3jjabf3gDtb1S8FGahsuEw==}
|
||||
'@xterm/xterm@5.5.0':
|
||||
resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==}
|
||||
|
||||
'@yuku-codegen/binding-android-arm64@0.8.5':
|
||||
resolution: {integrity: sha512-+oEBxK35kdbskhxozjZhI8N6qrMOfphpIkXnqBj6N2DC08rdkg10MUMRNKZLBNU6uNyoYiLcLyCE6aFV2sfMKw==}
|
||||
@@ -2166,10 +2164,6 @@ packages:
|
||||
xmlchars@2.2.0:
|
||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||
|
||||
xterm@5.3.0:
|
||||
resolution: {integrity: sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==}
|
||||
deprecated: This package is now deprecated. Move to @xterm/xterm instead.
|
||||
|
||||
yuku-ast@0.8.5:
|
||||
resolution: {integrity: sha512-Ez2CI2BnPK/if0tVI7jB9UUDSNibcChjJDEPEKuWPJNRkbvzSoAQrSqpKtdzl8qTPp4ftVb87f/jx5HIKOieQw==}
|
||||
|
||||
@@ -3390,11 +3384,9 @@ snapshots:
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.1
|
||||
|
||||
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.4.0)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 5.4.0
|
||||
'@xterm/addon-fit@0.11.0': {}
|
||||
|
||||
'@xterm/xterm@5.4.0': {}
|
||||
'@xterm/xterm@5.5.0': {}
|
||||
|
||||
'@yuku-codegen/binding-android-arm64@0.8.5':
|
||||
optional: true
|
||||
@@ -4385,8 +4377,6 @@ snapshots:
|
||||
|
||||
xmlchars@2.2.0: {}
|
||||
|
||||
xterm@5.3.0: {}
|
||||
|
||||
yuku-ast@0.8.5:
|
||||
dependencies:
|
||||
'@yuku-toolchain/types': 0.8.5
|
||||
|
||||
+87
-49
@@ -1,4 +1,4 @@
|
||||
# =============================================================================
|
||||
## =============================================================================
|
||||
# dsh-better-sidebar 一键安装脚本(官方 CLI 方式,Windows PowerShell 5.1+ / pwsh)
|
||||
#
|
||||
# 通过 DSH 官方插件命令安装 npm 包并自动挂载:
|
||||
@@ -16,9 +16,15 @@
|
||||
# & ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.10.2 -Restart
|
||||
# # 本地保存后运行
|
||||
# powershell -ExecutionPolicy Bypass -File install.ps1 -Version 0.10.2 -DryRun
|
||||
# # 修复 node-pty 依赖(终端提示「node-pty 加载失败」时用,见 issue #140)
|
||||
# powershell -ExecutionPolicy Bypass -File install.ps1 -Repair
|
||||
#
|
||||
# 参数:
|
||||
# -Version npm 版本号/范围,缺省 latest(自动解析为最新)。
|
||||
# -Repair 修复模式:不重装插件,只确保 profile 的 pnpm-workspace.yaml
|
||||
# 放行 node-pty 构建脚本,然后重跑 pnpm install + pnpm rebuild
|
||||
# node-pty。
|
||||
# -Profile 目标 profile 名(缺省 web);安装与修复模式均适用。
|
||||
# -Restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅提示)。会断开当前页面会话。
|
||||
# -DryRun 只打印将要执行的操作,不写任何文件。
|
||||
#
|
||||
@@ -39,7 +45,9 @@
|
||||
param(
|
||||
[string]$Version = '',
|
||||
[switch]$Restart,
|
||||
[switch]$DryRun
|
||||
[switch]$DryRun,
|
||||
[switch]$Repair,
|
||||
[string]$Profile = 'web'
|
||||
)
|
||||
|
||||
$PKG = 'dsh-better-sidebar'
|
||||
@@ -53,7 +61,7 @@ if ($env:DSH_HOME) {
|
||||
} else {
|
||||
$DSH_HOME = Join-Path $HOME '.dsh'
|
||||
}
|
||||
$PROFILE_DIR = Join-Path $DSH_HOME 'profiles\web'
|
||||
$PROFILE_DIR = Join-Path $DSH_HOME "profiles\$Profile"
|
||||
$WS_YML = Join-Path $PROFILE_DIR 'pnpm-workspace.yaml'
|
||||
$PATCH_YML = Join-Path $PROFILE_DIR 'cordis.patch.yml'
|
||||
|
||||
@@ -88,35 +96,10 @@ function Get-DshCli {
|
||||
return $null
|
||||
}
|
||||
|
||||
# 前置校验
|
||||
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
|
||||
Die '未找到 node(DSH 运行需要 Node.js >= 20),请先安装 Node.js 并加入 PATH。'
|
||||
}
|
||||
if (-not (Test-Path $PROFILE_DIR)) {
|
||||
Die "找不到 profile 目录:$PROFILE_DIR(请先安装并运行过一次 dsh web)"
|
||||
}
|
||||
if (-not (Test-Path $WS_YML)) {
|
||||
Die "找不到 $WS_YML(请先初始化 web profile)"
|
||||
}
|
||||
|
||||
$SPEC = Resolve-Spec $Version
|
||||
$CLI = Get-DshCli
|
||||
if (-not $CLI) {
|
||||
Die '未找到 dsh 或 npx。请先安装 DSH(并确保 Node/npm 可用),或用 DSH_CMD 指定。'
|
||||
}
|
||||
Say "目标:$CLI plugin --profile web add $PKG@$SPEC(profile: $PROFILE_DIR)"
|
||||
|
||||
if ($DryRun) {
|
||||
Say "[dry-run] 步骤 1:确保 $WS_YML 含 allowBuilds(node-pty/protobufjs: true)与 minimumReleaseAgeExclude($PKG)"
|
||||
Say "[dry-run] 步骤 2:执行 $CLI plugin --profile web add $PKG@$SPEC(安装 + bundle 自动注册)"
|
||||
Say "[dry-run] 步骤 3:校验 dsh.profile.bundles 含 $PKG"
|
||||
Say "[dry-run] 步骤 4:幂等移除 $PATCH_YML 里旧的 better-sidebar 手动挂载行(避免双挂载)"
|
||||
if ($Restart) { Say '[dry-run] 步骤 5:pm2 restart dsh-web' } else { Say '[dry-run] 步骤 5:提示用户手动重启 DSH' }
|
||||
exit 0
|
||||
}
|
||||
|
||||
# 步骤 1:预写 workspace 设置(幂等),保证 pnpm 不拦截构建、放行本插件新版本
|
||||
$wsScript = @'
|
||||
# 步骤 1(安装与修复共用):预写 workspace 设置(幂等),保证 pnpm 不拦截
|
||||
# node-pty/protobufjs 构建脚本、放行本插件新版本
|
||||
function Ensure-WorkspaceSettings {
|
||||
$wsScript = @'
|
||||
const fs = require("fs");
|
||||
const p = process.argv[2];
|
||||
let t = fs.readFileSync(p, "utf8");
|
||||
@@ -141,29 +124,84 @@ if (!/^\s*-\s+dsh-better-sidebar\s*$/m.test(t)) {
|
||||
if (t !== before) fs.writeFileSync(p, t);
|
||||
console.log(t === before ? "unchanged" : "updated");
|
||||
'@
|
||||
# PowerShell 5.1 把含内嵌双引号的多行 JS 作为参数传给 `node -e` 时,引号会被
|
||||
# Windows 命令行解析吞掉,导致 JS 语法错误(Expected ',', got ':')。
|
||||
# 改用临时文件方式,兼容 PS 5.1 与 pwsh 7。
|
||||
$wsJs = Join-Path $env:TEMP ("dshbs-ws-" + [guid]::NewGuid().ToString("N") + ".js")
|
||||
Set-Content -LiteralPath $wsJs -Value $wsScript -Encoding UTF8
|
||||
$wsOut = node $wsJs "$WS_YML" 2>&1
|
||||
$wsCode = $LASTEXITCODE
|
||||
Remove-Item -LiteralPath $wsJs -Force -ErrorAction SilentlyContinue
|
||||
$wsResult = (($wsOut | Out-String)).Trim()
|
||||
if ($wsCode -ne 0) { Die "处理 $WS_YML 失败(node 退出码 $wsCode):$wsResult" }
|
||||
if ($wsResult -eq 'updated') {
|
||||
Say "已确保 $WS_YML:allowBuilds(node-pty/protobufjs: true)+ minimumReleaseAgeExclude($PKG)"
|
||||
} else {
|
||||
Say 'workspace 设置已就绪,跳过'
|
||||
# PowerShell 5.1 把含内嵌双引号的多行 JS 作为参数传给 `node -e` 时,引号会被
|
||||
# Windows 命令行解析吞掉,导致 JS 语法错误(Expected ',', got ':')。
|
||||
# 改用临时文件方式,兼容 PS 5.1 与 pwsh 7。
|
||||
$wsJs = Join-Path $env:TEMP ("dshbs-ws-" + [guid]::NewGuid().ToString("N") + ".js")
|
||||
Set-Content -LiteralPath $wsJs -Value $wsScript -Encoding UTF8
|
||||
$wsOut = node $wsJs "$WS_YML" 2>&1
|
||||
$wsCode = $LASTEXITCODE
|
||||
Remove-Item -LiteralPath $wsJs -Force -ErrorAction SilentlyContinue
|
||||
$wsResult = (($wsOut | Out-String)).Trim()
|
||||
if ($wsCode -ne 0) { Die "处理 $WS_YML 失败(node 退出码 $wsCode):$wsResult" }
|
||||
if ($wsResult -eq 'updated') {
|
||||
Say "已确保 $WS_YML:allowBuilds(node-pty/protobufjs: true)+ minimumReleaseAgeExclude($PKG)"
|
||||
} else {
|
||||
Say 'workspace 设置已就绪,跳过'
|
||||
}
|
||||
}
|
||||
|
||||
# 前置校验
|
||||
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
|
||||
Die '未找到 node(DSH 运行需要 Node.js >= 20),请先安装 Node.js 并加入 PATH。'
|
||||
}
|
||||
if (-not (Test-Path $PROFILE_DIR)) {
|
||||
Die "找不到 profile 目录:$PROFILE_DIR(请先安装并运行过一次 dsh web)"
|
||||
}
|
||||
if (-not (Test-Path $WS_YML)) {
|
||||
Die "找不到 $WS_YML(请先初始化 $Profile profile)"
|
||||
}
|
||||
|
||||
# ── 修复模式(issue #140):不重装插件,只修复 node-pty 依赖 ────────────
|
||||
# 终端提示「node-pty 加载失败」时运行:确保 allowBuilds 后重跑
|
||||
# pnpm install + pnpm rebuild node-pty(重放被 pnpm 11 拦截的构建脚本)。
|
||||
if ($Repair) {
|
||||
if ($DryRun) {
|
||||
Say "[dry-run] 修复:确保 $WS_YML 含 allowBuilds(node-pty: true)"
|
||||
Say "[dry-run] 修复:cd $PROFILE_DIR; pnpm install; pnpm rebuild node-pty"
|
||||
exit 0
|
||||
}
|
||||
Say "修复模式:重装 node-pty(profile: $Profile,$PROFILE_DIR)..."
|
||||
Ensure-WorkspaceSettings
|
||||
Push-Location $PROFILE_DIR
|
||||
try {
|
||||
pnpm install
|
||||
if ($LASTEXITCODE -ne 0) { Die "pnpm install 失败(退出码 $LASTEXITCODE)。请确认 pnpm 在 PATH 上、网络可用,然后重试。" }
|
||||
pnpm rebuild node-pty
|
||||
if ($LASTEXITCODE -ne 0) { Die "pnpm rebuild node-pty 失败(退出码 $LASTEXITCODE)。请确认 pnpm 在 PATH 上,然后重试。" }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
Say '修复完成:node-pty 已重装(与 DSH 核心保持同一版本)。请重启 DSH 后重试终端。'
|
||||
exit 0
|
||||
}
|
||||
|
||||
$SPEC = Resolve-Spec $Version
|
||||
$CLI = Get-DshCli
|
||||
if (-not $CLI) {
|
||||
Die '未找到 dsh 或 npx。请先安装 DSH(并确保 Node/npm 可用),或用 DSH_CMD 指定。'
|
||||
}
|
||||
Say "目标:$CLI plugin --profile $Profile add $PKG@$SPEC(profile: $PROFILE_DIR)"
|
||||
|
||||
if ($DryRun) {
|
||||
Say "[dry-run] 步骤 1:确保 $WS_YML 含 allowBuilds(node-pty/protobufjs: true)与 minimumReleaseAgeExclude($PKG)"
|
||||
Say "[dry-run] 步骤 2:执行 $CLI plugin --profile $Profile add $PKG@$SPEC(安装 + bundle 自动注册)"
|
||||
Say "[dry-run] 步骤 3:校验 dsh.profile.bundles 含 $PKG"
|
||||
Say "[dry-run] 步骤 4:幂等移除 $PATCH_YML 里旧的 better-sidebar 手动挂载行(避免双挂载)"
|
||||
if ($Restart) { Say '[dry-run] 步骤 5:pm2 restart dsh-web' } else { Say '[dry-run] 步骤 5:提示用户手动重启 DSH' }
|
||||
exit 0
|
||||
}
|
||||
|
||||
# 步骤 1:预写 workspace 设置(幂等),保证 pnpm 不拦截构建、放行本插件新版本
|
||||
Ensure-WorkspaceSettings
|
||||
|
||||
# 步骤 2:官方 CLI 安装 + bundle 自动注册(含挂载)
|
||||
if ($CLI -eq 'dsh') {
|
||||
$cliArgs = @('plugin', '--profile', 'web', 'add', "$PKG@$SPEC")
|
||||
$cliArgs = @('plugin', '--profile', $Profile, 'add', "$PKG@$SPEC")
|
||||
} else {
|
||||
$cliArgs = @('-y', '--package', '@deepseek-ai/dsh', 'dsh', 'plugin', '--profile', 'web', 'add', "$PKG@$SPEC")
|
||||
$cliArgs = @('-y', '--package', '@deepseek-ai/dsh', 'dsh', 'plugin', '--profile', $Profile, 'add', "$PKG@$SPEC")
|
||||
}
|
||||
Say "执行 $CLI plugin --profile web add $PKG@$SPEC ..."
|
||||
Say "执行 $CLI plugin --profile $Profile add $PKG@$SPEC ..."
|
||||
$addOut = & $CLI @cliArgs 2>&1
|
||||
$addCode = $LASTEXITCODE
|
||||
$addOut | ForEach-Object { $_ }
|
||||
|
||||
+85
-44
@@ -12,9 +12,14 @@
|
||||
#
|
||||
# 用法:
|
||||
# bash scripts/install.sh [版本] [--restart] [--dry-run]
|
||||
# bash scripts/install.sh --repair [--profile <名>] [--dry-run]
|
||||
#
|
||||
# 版本 npm 版本号/范围,缺省为 latest(自动解析为 ^<最新>)。
|
||||
# 示例:0.10.2、^0.10.2、~0.10.2、latest
|
||||
# --repair 修复模式:不重装插件,只确保 profile 的 pnpm-workspace.yaml
|
||||
# 放行 node-pty 构建脚本,然后重跑 pnpm install + pnpm rebuild
|
||||
# node-pty(终端提示「node-pty 加载失败」时用它,见 issue #140)。
|
||||
# --profile 目标 profile 名(缺省 web);安装与修复模式均适用。
|
||||
# --restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅打印提示)。
|
||||
# 注意:重启会断开当前 DSH 页面会话,默认不自动重启。
|
||||
# --dry-run 只打印将要执行的操作,不写任何文件。
|
||||
@@ -42,11 +47,15 @@ set -euo pipefail
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = "-h" ] || [ "$arg" = "--help" ]; then
|
||||
cat <<'EOF'
|
||||
dsh-better-sidebar 一键安装脚本
|
||||
dsh-better-sidebar 一键安装 / 依赖修复脚本
|
||||
|
||||
用法:bash scripts/install.sh [版本] [--restart] [--dry-run]
|
||||
用法:
|
||||
bash scripts/install.sh [版本] [--restart] [--dry-run] [--profile <名>]
|
||||
bash scripts/install.sh --repair [--profile <名>] [--dry-run]
|
||||
|
||||
版本 npm 版本号/范围,缺省 latest(自动解析为最新)。示例:0.10.2、^0.10.2、latest
|
||||
--repair 修复模式:确保 profile 放行 node-pty 构建脚本并重装 node-pty(终端提示依赖加载失败时用)
|
||||
--profile 目标 profile 名(缺省 web)
|
||||
--restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅提示)
|
||||
--dry-run 只打印将要执行的操作,不写任何文件
|
||||
|
||||
@@ -57,30 +66,73 @@ EOF
|
||||
done
|
||||
|
||||
DSH_HOME="${DSH_HOME:-${HOME:-${USERPROFILE:-}}/.dsh}"
|
||||
PROFILE_DIR="$DSH_HOME/profiles/web"
|
||||
WS_YML="$PROFILE_DIR/pnpm-workspace.yaml"
|
||||
PATCH_YML="$PROFILE_DIR/cordis.patch.yml"
|
||||
REGISTRY="${REGISTRY:-https://registry.npmjs.org}"
|
||||
PKG="dsh-better-sidebar"
|
||||
DSH_CMD="${DSH_CMD:-dsh}"
|
||||
|
||||
RESTART=false
|
||||
DRY_RUN=false
|
||||
REPAIR=false
|
||||
VERSION_SPEC=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
PROFILE_NAME="web"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--restart) RESTART=true ;;
|
||||
--dry-run) DRY_RUN=true ;;
|
||||
--repair) REPAIR=true ;;
|
||||
--profile)
|
||||
if [ $# -lt 2 ]; then echo "--profile 需要一个 profile 名(如 web)" >&2; exit 2; fi
|
||||
PROFILE_NAME="$2"; shift ;;
|
||||
-h|--help) : ;; # 已在上面处理
|
||||
-*) echo "未知参数: ${arg}(用 -h 查看用法)" >&2; exit 2 ;;
|
||||
*) VERSION_SPEC="$arg" ;;
|
||||
-*) echo "未知参数: ${1}(用 -h 查看用法)" >&2; exit 2 ;;
|
||||
*) VERSION_SPEC="$1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
PROFILE_DIR="$DSH_HOME/profiles/$PROFILE_NAME"
|
||||
WS_YML="$PROFILE_DIR/pnpm-workspace.yaml"
|
||||
PATCH_YML="$PROFILE_DIR/cordis.patch.yml"
|
||||
|
||||
say() { printf '\033[32m[install]\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[33m[warn]\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[31m[error]\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# 步骤 1(安装与修复共用):预写 workspace 设置(幂等),保证 pnpm 不拦截
|
||||
# node-pty/protobufjs 构建脚本、放行本插件新版本
|
||||
ensure_workspace_settings() {
|
||||
WS_RESULT="$(node -e '
|
||||
const fs = require("fs");
|
||||
const p = process.argv[1];
|
||||
let t = fs.readFileSync(p, "utf8");
|
||||
const before = t;
|
||||
// allowBuilds:把 node-pty/protobufjs 归位为 true
|
||||
t = t.replace(/^(\s*)(node-pty|protobufjs):.*$/gm, "$1$2: true");
|
||||
if (!/^\s*allowBuilds:\s*$/m.test(t)) {
|
||||
t += "\nallowBuilds:\n node-pty: true\n protobufjs: true\n";
|
||||
} else {
|
||||
for (const k of ["node-pty", "protobufjs"]) {
|
||||
if (!new RegExp("^\\s*" + k + ":\\s*true\\s*$", "m").test(t)) {
|
||||
t = t.replace(/^(\s*allowBuilds:\s*)$/m, "$1\n " + k + ": true");
|
||||
}
|
||||
}
|
||||
}
|
||||
// minimumReleaseAgeExclude:放行本插件(版本无关),避免 <24h 新版本被拒
|
||||
if (!/^\s*-\s+dsh-better-sidebar\s*$/m.test(t)) {
|
||||
if (/^\s*minimumReleaseAgeExclude:\s*$/m.test(t)) {
|
||||
t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - dsh-better-sidebar");
|
||||
} else {
|
||||
t += "\nminimumReleaseAgeExclude:\n - dsh-better-sidebar\n";
|
||||
}
|
||||
}
|
||||
if (t !== before) fs.writeFileSync(p, t);
|
||||
console.log(t === before ? "unchanged" : "updated");
|
||||
' "$WS_YML")"
|
||||
[ "$WS_RESULT" = "updated" ] \
|
||||
&& say "已确保 ${WS_YML}:allowBuilds(node-pty/protobufjs: true)+ minimumReleaseAgeExclude(${PKG})" \
|
||||
|| say "workspace 设置已就绪,跳过"
|
||||
}
|
||||
|
||||
# 解析用户给的版本 -> CLI 要用的 npm spec("x.y.z" / "^x.y.z" / latest)
|
||||
resolve_spec() {
|
||||
local given="${1:-latest}"
|
||||
@@ -120,15 +172,33 @@ dsh_cli() {
|
||||
command -v node >/dev/null 2>&1 || die "未找到 node(DSH 运行需要 Node.js ≥ 20),请先安装 Node.js 并加入 PATH。"
|
||||
|
||||
[ -d "$PROFILE_DIR" ] || die "找不到 profile 目录:${PROFILE_DIR}(请先安装并运行过一次 dsh web)"
|
||||
[ -f "$WS_YML" ] || die "找不到 ${WS_YML}(请先初始化 web profile)"
|
||||
[ -f "$WS_YML" ] || die "找不到 ${WS_YML}(请先初始化 ${PROFILE_NAME} profile)"
|
||||
|
||||
# ── 修复模式(issue #140):不重装插件,只修复 node-pty 依赖 ────────────
|
||||
# 终端提示「node-pty 加载失败」时运行:确保 allowBuilds 后重跑
|
||||
# pnpm install + pnpm rebuild node-pty(重放被 pnpm 11 拦截的构建脚本)。
|
||||
if [ "$REPAIR" = true ]; then
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
say "[dry-run] 修复:确保 $WS_YML 含 allowBuilds(node-pty: true)"
|
||||
say "[dry-run] 修复:cd $PROFILE_DIR && pnpm install && pnpm rebuild node-pty"
|
||||
exit 0
|
||||
fi
|
||||
say "修复模式:重装 node-pty(profile: ${PROFILE_NAME},${PROFILE_DIR})..."
|
||||
ensure_workspace_settings
|
||||
if ! (cd "$PROFILE_DIR" && pnpm install && pnpm rebuild node-pty); then
|
||||
die "修复失败:pnpm install / pnpm rebuild node-pty 非零退出。请确认 pnpm 在 PATH 上、网络可用,然后重试。"
|
||||
fi
|
||||
say "修复完成:node-pty 已重装(与 DSH 核心保持同一版本)。请重启 DSH 后重试终端。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SPEC="$(resolve_spec "$VERSION_SPEC")"
|
||||
CLI="$(dsh_cli)"
|
||||
say "目标:$CLI plugin --profile web add $PKG@${SPEC}(profile: ${PROFILE_DIR})"
|
||||
say "目标:$CLI plugin --profile $PROFILE_NAME add $PKG@${SPEC}(profile: ${PROFILE_DIR})"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
say "[dry-run] 步骤 1:确保 $WS_YML 含 allowBuilds(node-pty/protobufjs: true)与 minimumReleaseAgeExclude(${PKG})"
|
||||
say "[dry-run] 步骤 2:执行 $CLI plugin --profile web add $PKG@${SPEC}(安装 + bundle 自动注册)"
|
||||
say "[dry-run] 步骤 2:执行 $CLI plugin --profile $PROFILE_NAME add $PKG@${SPEC}(安装 + bundle 自动注册)"
|
||||
say "[dry-run] 步骤 3:校验 dsh.profile.bundles 含 $PKG"
|
||||
say "[dry-run] 步骤 4:幂等移除 $PATCH_YML 里旧的 better-sidebar 手动挂载行(避免双挂载)"
|
||||
if [ "$RESTART" = true ]; then say "[dry-run] 步骤 5:pm2 restart dsh-web"; else say "[dry-run] 步骤 5:提示用户手动重启 DSH"; fi
|
||||
@@ -136,40 +206,11 @@ if [ "$DRY_RUN" = true ]; then
|
||||
fi
|
||||
|
||||
# 步骤 1:预写 workspace 设置(幂等),保证 pnpm 不拦截构建、不放行旧版本策略
|
||||
WS_RESULT="$(node -e '
|
||||
const fs = require("fs");
|
||||
const p = process.argv[1];
|
||||
let t = fs.readFileSync(p, "utf8");
|
||||
const before = t;
|
||||
// allowBuilds:把 node-pty/protobufjs 归位为 true
|
||||
t = t.replace(/^(\s*)(node-pty|protobufjs):.*$/gm, "$1$2: true");
|
||||
if (!/^\s*allowBuilds:\s*$/m.test(t)) {
|
||||
t += "\nallowBuilds:\n node-pty: true\n protobufjs: true\n";
|
||||
} else {
|
||||
for (const k of ["node-pty", "protobufjs"]) {
|
||||
if (!new RegExp("^\\s*" + k + ":\\s*true\\s*$", "m").test(t)) {
|
||||
t = t.replace(/^(\s*allowBuilds:\s*)$/m, "$1\n " + k + ": true");
|
||||
}
|
||||
}
|
||||
}
|
||||
// minimumReleaseAgeExclude:放行本插件(版本无关),避免 <24h 新版本被拒
|
||||
if (!/^\s*-\s+dsh-better-sidebar\s*$/m.test(t)) {
|
||||
if (/^\s*minimumReleaseAgeExclude:\s*$/m.test(t)) {
|
||||
t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - dsh-better-sidebar");
|
||||
} else {
|
||||
t += "\nminimumReleaseAgeExclude:\n - dsh-better-sidebar\n";
|
||||
}
|
||||
}
|
||||
if (t !== before) fs.writeFileSync(p, t);
|
||||
console.log(t === before ? "unchanged" : "updated");
|
||||
' "$WS_YML")"
|
||||
[ "$WS_RESULT" = "updated" ] \
|
||||
&& say "已确保 ${WS_YML}:allowBuilds(node-pty/protobufjs: true)+ minimumReleaseAgeExclude(${PKG})" \
|
||||
|| say "workspace 设置已就绪,跳过"
|
||||
ensure_workspace_settings
|
||||
|
||||
# 步骤 2:官方 CLI 安装 + bundle 自动注册(含挂载)
|
||||
say "执行 $CLI plugin --profile web add $PKG@$SPEC ..."
|
||||
if ! $CLI plugin --profile web add "$PKG@$SPEC" 2>&1 | tail -n +1; then
|
||||
say "执行 $CLI plugin --profile $PROFILE_NAME add $PKG@$SPEC ..."
|
||||
if ! $CLI plugin --profile "$PROFILE_NAME" add "$PKG@$SPEC" 2>&1 | tail -n +1; then
|
||||
warn "dsh plugin add 失败。已预写 allowBuilds 与 minimumReleaseAgeExclude,仍失败的可能原因:"
|
||||
warn " - 网络/登录问题:npm registry 不可达或需要登录。"
|
||||
warn " - 依赖安装冲突:可手动重试 cd $PROFILE_DIR && pnpm install。"
|
||||
|
||||
+9
-4
@@ -12,8 +12,9 @@
|
||||
* Both registries share the same shell resolver and spawn-helper fix.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import * as nodePty from 'node-pty'
|
||||
import type { IPty } from 'node-pty'
|
||||
import { ensureSpawnHelper, shellSpawnArgs } from './pty-manager.ts'
|
||||
import { loadRequiredNodePty, type NodePtyModule } from './pty-deps.ts'
|
||||
import { SidebarError } from './wire.ts'
|
||||
|
||||
/** Per-agent-terminal transcript bound (bytes kept for replay and reads). */
|
||||
@@ -106,7 +107,7 @@ export interface AgentTerminalHandle {
|
||||
/** The working directory the process was spawned with. */
|
||||
cwd: string
|
||||
/** The live pty process. */
|
||||
pty: nodePty.IPty
|
||||
pty: IPty
|
||||
/** Output accumulated since spawn (bounded; head dropped when over the limit). */
|
||||
transcript: string
|
||||
/** Whether the top-level process exited (transcript stays replayable). */
|
||||
@@ -189,7 +190,11 @@ export class AgentPtyRegistry {
|
||||
private readonly sessions = new Map<string, AgentTerminalHandle>()
|
||||
private readonly changeListeners = new Set<() => void>()
|
||||
|
||||
constructor(private readonly shell: string) {
|
||||
constructor(
|
||||
private readonly shell: string,
|
||||
/** The loaded node-pty module (injected so a broken install degrades instead of crashing the plugin). */
|
||||
private readonly nodePty: NodePtyModule = loadRequiredNodePty(),
|
||||
) {
|
||||
ensureSpawnHelper()
|
||||
}
|
||||
|
||||
@@ -211,7 +216,7 @@ export class AgentPtyRegistry {
|
||||
): string {
|
||||
const uuid = randomUUID()
|
||||
const dims = clampDims(cols, rows)
|
||||
const pty = nodePty.spawn(this.shell, shellSpawnArgs(), {
|
||||
const pty = this.nodePty.spawn(this.shell, shellSpawnArgs(), {
|
||||
name: 'xterm-256color',
|
||||
cols: dims.cols,
|
||||
rows: dims.rows,
|
||||
|
||||
@@ -90,7 +90,7 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
|
||||
title={t('refresh')}
|
||||
onClick={refresh}
|
||||
>
|
||||
<IconRefreshOutline16 />
|
||||
<IconRefreshOutline16 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{loading && <div className={css.gitPlaceholder}>{t('loading')}</div>}
|
||||
|
||||
+341
-23
@@ -1,22 +1,43 @@
|
||||
/**
|
||||
* The editor tab host: resolves a file's previewer through the sidebar
|
||||
* registry (`matchFileViewer`), fetches bytes per the matched viewer's
|
||||
* fetch strategy, and renders its component — or the shared download pane
|
||||
* when nothing can render the file. The header shows the file title; the
|
||||
* editable code/markdown viewers render their own toolbar below it.
|
||||
* The editor tab host: the single FILES WINDOW. It resolves a file's
|
||||
* previewer through the sidebar registry (`matchFileViewer`), fetches bytes
|
||||
* per the matched viewer's fetch strategy, and renders its component — or
|
||||
* the shared download pane when nothing can render the file. A tab without
|
||||
* a path (the seeded "Files" home) renders an empty-state hint instead of
|
||||
* the viewer loading flow; that path-less window IS the file explorer.
|
||||
*
|
||||
* The chrome depends on the `editorExplorer` mode (read reactively so
|
||||
* toggling it re-renders without a reload):
|
||||
* - merged (in-place): tree click / path-input Enter switch the CURRENT
|
||||
* tab in place (updateTab rewrites path/title; the tab keeps its id and
|
||||
* meta, so treeOpen/treeWidth survive the switch);
|
||||
* - split: they open through `openSidebarFile` (a per-path dedupe tab),
|
||||
* and a PATH-LESS window is the standalone explorer — it renders ONLY
|
||||
* the tree panel (search + FileTree, full-window), no editor chrome.
|
||||
* Editor tabs (with a path) keep the full chrome in both modes.
|
||||
* The tree's context menu offers the explicit escapes in both modes: open
|
||||
* in a new tab (per-path dedupe) or to the side (a fresh tab in a fresh
|
||||
* rightward split of the current pane).
|
||||
*
|
||||
* The strategy dispatch is pure (planFirstMatch / planFsReadOutcome in
|
||||
* editor-load.ts); this component only wires it to the host APIs.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { createElement } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCheckOutline16, IconFolderOpen16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { api, mediaUrl, type SessionScope } from './api.ts'
|
||||
import { BinaryDownload } from './binary-download.tsx'
|
||||
import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './editor-load.ts'
|
||||
import { baseName } from './FileTree.tsx'
|
||||
import { openSidebarFile } from './intercept.tsx'
|
||||
import { TreePanel } from './TreePanel.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import type { FileViewerDescriptor } from './service.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { resolveSidebarPath } from './produced-files.ts'
|
||||
import type { EditorToolbarControls, EditorToolbarState, FileViewerDescriptor } from './service.ts'
|
||||
import { firstLeaf, insertLeafAt, leafWithTab, mintTabId, treeOf, type SidebarStore, type SidebarTab } from './state.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
type EditorLoad =
|
||||
@@ -25,11 +46,156 @@ type EditorLoad =
|
||||
| { status: 'ready'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown }
|
||||
| { status: 'binary' }
|
||||
|
||||
export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: SessionScope; path: string; title: string }) {
|
||||
const { ctx, store, scope, path, title } = props
|
||||
/** The docked tree panel's width bounds (drag-resize clamps into them). */
|
||||
const TREE_WIDTH_DEFAULT = 240
|
||||
const TREE_WIDTH_MIN = 160
|
||||
const TREE_WIDTH_MAX = 480
|
||||
|
||||
/** The tab's persisted meta object (a malformed meta reads as empty). */
|
||||
function metaOf(tab: SidebarTab): Record<string, unknown> {
|
||||
return tab.meta !== null && typeof tab.meta === 'object' && !Array.isArray(tab.meta)
|
||||
? tab.meta as Record<string, unknown>
|
||||
: {}
|
||||
}
|
||||
|
||||
/** Read the persisted tree-panel flag of one editor tab: an explicit
|
||||
* boolean meta wins; otherwise path-less tabs (the seeded home) default
|
||||
* open and file tabs default closed. */
|
||||
function treeOpenOf(tab: SidebarTab): boolean {
|
||||
const treeOpen = metaOf(tab).treeOpen
|
||||
return typeof treeOpen === 'boolean' ? treeOpen : (tab.path === undefined || tab.path === '')
|
||||
}
|
||||
|
||||
/** Read the persisted tree-panel width (clamped; default 240). */
|
||||
function treeWidthOf(tab: SidebarTab): number {
|
||||
const width = metaOf(tab).treeWidth
|
||||
return typeof width === 'number' && Number.isFinite(width)
|
||||
? Math.min(TREE_WIDTH_MAX, Math.max(TREE_WIDTH_MIN, Math.round(width)))
|
||||
: TREE_WIDTH_DEFAULT
|
||||
}
|
||||
|
||||
/** Merge a patch into the tab's persisted meta (rides the layout). */
|
||||
function patchMeta(ctx: Context, tab: SidebarTab, patch: Record<string, unknown>): void {
|
||||
ctx.betterSidebar?.updateTab(tab.id, { meta: { ...metaOf(tab), ...patch } })
|
||||
}
|
||||
|
||||
/** Clamp one dock width into the contract range. */
|
||||
function clampTreeWidth(value: number): number {
|
||||
return Math.min(TREE_WIDTH_MAX, Math.max(TREE_WIDTH_MIN, Math.round(value)))
|
||||
}
|
||||
|
||||
export function EditorHost(props: {
|
||||
ctx: Context
|
||||
store: SidebarStore
|
||||
scope: SessionScope
|
||||
tab: SidebarTab
|
||||
expanded: string[]
|
||||
onToggleDir: (path: string) => void
|
||||
onReferenceFile: (path: string) => void
|
||||
}) {
|
||||
const { ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile } = props
|
||||
const path = tab.path ?? ''
|
||||
const title = tab.title
|
||||
const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
|
||||
|
||||
// Reactive prefs read: flipping editorExplorer re-renders this tab with no
|
||||
// reload. The snapshot is the bare boolean so unrelated store churn never
|
||||
// re-renders the editor.
|
||||
const inPlace = useSyncExternalStore(
|
||||
useCallback((callback: () => void) => store.subscribe(callback), [store]),
|
||||
useCallback(() => store.getSnapshot().prefs.editorExplorer, [store]),
|
||||
)
|
||||
// A path-less tab shows the empty-state hint in merged mode — and in split
|
||||
// mode it is the standalone explorer (tree-only, see the render below).
|
||||
const showEmpty = path === ''
|
||||
const treeOnly = showEmpty && !inPlace
|
||||
|
||||
/**
|
||||
* Open a file from THIS window (tree click / search row / path input):
|
||||
* merged mode switches this tab in place (stable id, meta survives);
|
||||
* split mode opens a per-path dedupe tab through openSidebarFile.
|
||||
*/
|
||||
const openFile = (absolute: string): void => {
|
||||
if (inPlace) {
|
||||
ctx.betterSidebar?.updateTab(tab.id, { path: absolute, title: baseName(absolute) })
|
||||
} else {
|
||||
openSidebarFile(ctx, store, scope.sessionId, absolute)
|
||||
}
|
||||
}
|
||||
|
||||
/** The context menu's explicit "new tab" escape (per-path dedupe). */
|
||||
const openFileNewTab = (absolute: string): void => {
|
||||
openSidebarFile(ctx, store, scope.sessionId, absolute)
|
||||
}
|
||||
|
||||
/**
|
||||
* The context menu's "open to the side": a fresh editor tab (uid id — the
|
||||
* `'editor:' + path` convention would clash with the id safety net on a
|
||||
* second side-open of the same file) in a rightward split of THIS pane.
|
||||
*/
|
||||
const openFileSide = (absolute: string): void => {
|
||||
store.reduce((state) => {
|
||||
const key = treeOf(state, tab.id)
|
||||
const pane = leafWithTab(state[key], tab.id) ?? firstLeaf(state[key])
|
||||
const fresh: SidebarTab = {
|
||||
id: mintTabId(),
|
||||
type: 'editor',
|
||||
title: baseName(absolute),
|
||||
path: absolute,
|
||||
meta: { treeOpen: false },
|
||||
}
|
||||
const { node, leafId } = insertLeafAt(state[key], pane.id, 'row', fresh, false)
|
||||
return { ...state, [key]: node, activePane: leafId }
|
||||
})
|
||||
}
|
||||
|
||||
// The viewer's toolbar, hoisted into THIS header: the text editor reports
|
||||
// its state and registers its commands (both null/absent for viewers
|
||||
// without a toolbar — image, pdf, binary download).
|
||||
const [toolbar, setToolbar] = useState<EditorToolbarState | null>(null)
|
||||
const controlsRef = useRef<EditorToolbarControls | null>(null)
|
||||
const onToolbarState = useCallback((next: EditorToolbarState) => {
|
||||
setToolbar(prev => prev !== null && JSON.stringify(prev) === JSON.stringify(next) ? prev : next)
|
||||
}, [])
|
||||
const onToolbarControls = useCallback((controls: EditorToolbarControls | null) => {
|
||||
controlsRef.current = controls
|
||||
}, [])
|
||||
|
||||
// The docked panel's drag-resize: pointer capture on the handle itself
|
||||
// (no window listeners — the captured pointer keeps tracking even off the
|
||||
// handle). Local width while dragging, persisted into meta.treeWidth on
|
||||
// release. The panel docks right, so dragging LEFT widens it.
|
||||
const [dragWidth, setDragWidth] = useState<number | null>(null)
|
||||
const dragRef = useRef<{ startX: number; startWidth: number } | null>(null)
|
||||
const treeWidth = dragWidth ?? treeWidthOf(tab)
|
||||
|
||||
const onResizeStart = (event: React.PointerEvent): void => {
|
||||
event.preventDefault()
|
||||
// jsdom lacks setPointerCapture — the tests dispatch plain MouseEvents.
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId)
|
||||
dragRef.current = { startX: event.clientX, startWidth: treeWidth }
|
||||
}
|
||||
const onResizeMove = (event: React.PointerEvent): void => {
|
||||
const drag = dragRef.current
|
||||
if (drag === null) return
|
||||
setDragWidth(clampTreeWidth(drag.startWidth + (drag.startX - event.clientX)))
|
||||
}
|
||||
const onResizeEnd = (event: React.PointerEvent): void => {
|
||||
const drag = dragRef.current
|
||||
if (drag === null) return
|
||||
dragRef.current = null
|
||||
setDragWidth(null)
|
||||
const finalWidth = clampTreeWidth(drag.startWidth + (drag.startX - event.clientX))
|
||||
if (finalWidth !== treeWidthOf(tab)) patchMeta(ctx, tab, { treeWidth: finalWidth })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// A (re)load or a path-less tab clears any hoisted toolbar state — the
|
||||
// fresh viewer re-registers its own.
|
||||
setToolbar(null)
|
||||
// The seeded home tab (no path) never loads a viewer — the empty-state
|
||||
// hint renders until the user picks a file.
|
||||
if (showEmpty) return
|
||||
let cancelled = false
|
||||
// Aborts the matched viewer's `load` when the editor tears down (tab
|
||||
// closed, path changed, session switched) or re-matches the viewer.
|
||||
@@ -81,24 +247,176 @@ export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: Se
|
||||
}
|
||||
apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf))
|
||||
return () => { cancelled = true; controller.abort() }
|
||||
}, [scope.sessionId, scope.cwd, path, ctx])
|
||||
}, [scope.sessionId, scope.cwd, path, ctx, showEmpty])
|
||||
|
||||
const treeOpen = treeOpenOf(tab)
|
||||
/** Persist the panel flag on the tab (survives reloads with the layout). */
|
||||
const toggleTree = (): void => { patchMeta(ctx, tab, { treeOpen: !treeOpen }) }
|
||||
const saveLabel = toolbar === null ? ''
|
||||
: toolbar.saveState === 'saving' ? t('loading')
|
||||
: toolbar.saveState === 'saved' ? t('saved')
|
||||
: toolbar.saveState === 'failed' ? t('saveFailed') : ''
|
||||
|
||||
// Split mode: the path-less window IS the standalone explorer — the tree
|
||||
// panel fills the whole tab (search + FileTree, full form), no editor
|
||||
// chrome. File opens land in new per-path tabs through openFile above.
|
||||
if (treeOnly) {
|
||||
return (
|
||||
<div className={css.editor}>
|
||||
<TreePanel
|
||||
full
|
||||
sessionId={scope.sessionId}
|
||||
cwd={scope.cwd}
|
||||
expanded={expanded}
|
||||
onToggle={onToggleDir}
|
||||
onOpenFile={openFile}
|
||||
onOpenFileNewTab={openFileNewTab}
|
||||
onOpenFileSide={openFileSide}
|
||||
onReferenceFile={onReferenceFile}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.editor}>
|
||||
<div className={css.editorHeader}>
|
||||
<span className={css.editorTitle} title={path}>{title}</span>
|
||||
<EditorPathInput key={path} path={path} cwd={scope.cwd} onOpen={openFile} />
|
||||
{toolbar?.modes === true && (
|
||||
<div className={css.editorModeToggle}>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.editorModeButton, toolbar.mode === 'preview' && css.editorModeActive)}
|
||||
onClick={() => { controlsRef.current?.setMode('preview') }}
|
||||
>
|
||||
{t('preview')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.editorModeButton, toolbar.mode === 'edit' && css.editorModeActive)}
|
||||
onClick={() => { controlsRef.current?.setMode('edit') }}
|
||||
>
|
||||
{t('edit')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{toolbar?.dirty === true && <span className={css.dirtyDot} title={t('unsaved')} />}
|
||||
{toolbar?.editable === true && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('save')}
|
||||
title={`${t('save')} (Ctrl/Cmd+S)`}
|
||||
onClick={() => { controlsRef.current?.save() }}
|
||||
>
|
||||
<IconCheckOutline16 size={14} />
|
||||
</button>
|
||||
)}
|
||||
{saveLabel !== '' && (
|
||||
<span className={clsx(css.editorStatus, toolbar?.saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.iconButton, treeOpen && css.editorTreeToggleActive)}
|
||||
aria-label={t('editorTreeToggle')}
|
||||
title={t('editorTreeToggle')}
|
||||
aria-pressed={treeOpen}
|
||||
onClick={toggleTree}
|
||||
>
|
||||
<IconFolderOpen16 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className={css.editorBody}>
|
||||
<div className={css.editorMain}>
|
||||
{showEmpty && <div className={css.editorPlaceholder}>{t('editorEmptyHint')}</div>}
|
||||
{!showEmpty && load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{!showEmpty && load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
|
||||
{!showEmpty && load.status === 'binary' && <BinaryDownload scope={scope} path={path} />}
|
||||
{!showEmpty && load.status === 'ready' && createElement(load.viewer.component, {
|
||||
ctx, store, scope, path, title,
|
||||
viewerId: load.viewer.id,
|
||||
content: load.content,
|
||||
truncated: load.truncated,
|
||||
mediaUrl: load.mediaUrl,
|
||||
customData: load.customData,
|
||||
// The viewer's toolbar always hoists into this host's header.
|
||||
toolbar: 'host',
|
||||
onToolbarState,
|
||||
onToolbarControls,
|
||||
})}
|
||||
</div>
|
||||
{treeOpen && (
|
||||
<div className={css.editorTreeDock} style={{ width: treeWidth }}>
|
||||
<div
|
||||
className={css.editorTreeResize}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={t('editorTreeToggle')}
|
||||
onPointerDown={onResizeStart}
|
||||
onPointerMove={onResizeMove}
|
||||
onPointerUp={onResizeEnd}
|
||||
onPointerCancel={onResizeEnd}
|
||||
/>
|
||||
<TreePanel
|
||||
sessionId={scope.sessionId}
|
||||
cwd={scope.cwd}
|
||||
expanded={expanded}
|
||||
onToggle={onToggleDir}
|
||||
onOpenFile={openFile}
|
||||
onOpenFileNewTab={openFileNewTab}
|
||||
onOpenFileSide={openFileSide}
|
||||
onReferenceFile={onReferenceFile}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
|
||||
{load.status === 'binary' && <BinaryDownload scope={scope} path={path} />}
|
||||
{load.status === 'ready' && createElement(load.viewer.component, {
|
||||
ctx, store, scope, path, title,
|
||||
viewerId: load.viewer.id,
|
||||
content: load.content,
|
||||
truncated: load.truncated,
|
||||
mediaUrl: load.mediaUrl,
|
||||
customData: load.customData,
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The header's path input: shows the current file relative to the session
|
||||
* cwd (absolute when outside it). Enter resolves the typed path (relative
|
||||
* input joins onto the cwd — the same resolution `openSidebarFile` uses)
|
||||
* and opens it through the parent's mode-aware open (in-place switch or a
|
||||
* per-path dedupe tab); Escape/blur restores the current value. The parent
|
||||
* keys it by `path` so an in-place switch remounts and reseeds the draft.
|
||||
*/
|
||||
function EditorPathInput(props: { path: string; cwd: string | undefined; onOpen: (path: string) => void }) {
|
||||
const { path, cwd, onOpen } = props
|
||||
const display = path === '' ? '' : relativeTo(cwd ?? '', path)
|
||||
const [value, setValue] = useState(display)
|
||||
|
||||
const commit = (): void => {
|
||||
const input = value.trim()
|
||||
if (input === '' || input === display) {
|
||||
setValue(display)
|
||||
return
|
||||
}
|
||||
onOpen(resolveSidebarPath(cwd, input))
|
||||
// Split mode: the open lands in a NEW/deduped editor tab — THIS tab's
|
||||
// path stays, so the input falls back to its own display value. (Merged
|
||||
// mode remounts this input on the new path; the reset is harmless.)
|
||||
setValue(display)
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
className={css.editorPathInput}
|
||||
value={value}
|
||||
placeholder={t('editorPathPlaceholder')}
|
||||
title={path}
|
||||
spellCheck={false}
|
||||
onChange={(event) => { setValue(event.target.value) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
commit()
|
||||
} else if (event.key === 'Escape') {
|
||||
setValue(display)
|
||||
}
|
||||
}}
|
||||
onBlur={() => { setValue(display) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
/**
|
||||
* The file explorer: a lazy VSCode-style tree rooted at the session's
|
||||
* working directory. Levels load on expansion (one API call per directory),
|
||||
* directories sort first, hidden entries render dimmed, and the expansion
|
||||
* set lives in the per-session state. Clicking a file opens an editor tab.
|
||||
* The controlled file tree behind the files window's tree panel (TreePanel
|
||||
* wraps it with the search box): a lazy VSCode-style tree rooted at the
|
||||
* session's working directory. Levels load on expansion (one API call per
|
||||
* directory), directories sort first, hidden entries render dimmed. The
|
||||
* expansion set lives in the per-session state (owned by the caller); the
|
||||
* caller also owns the refresh affordance — a `refreshTick` bump wipes the
|
||||
* level cache so the visible set reloads.
|
||||
*
|
||||
* Row actions: hovering a row reveals an @-reference button on the far
|
||||
* right (appends `@<relative path>` to the composer draft), and right-click
|
||||
* opens a context menu to copy the relative or absolute path (with a brief
|
||||
* "copied" label replacing the button after a successful write); file rows
|
||||
* also offer a download action (the host serves raw bytes, binary-safe).
|
||||
* opens a context menu: file rows offer the caller's open escapes
|
||||
* (new tab / to the side, only when the callbacks exist) and a download
|
||||
* action (the host serves raw bytes, binary-safe); every row can copy the
|
||||
* relative or absolute path (with a brief "copied" label replacing the
|
||||
* button after a successful write).
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16, IconFolderClose16, IconFolderOpen16,
|
||||
IconRefreshOutline16, Menu, writeClipboard,
|
||||
IconLinkOutline16, Menu, writeClipboard,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { api, downloadUrl, type FsEntry } from './api.ts'
|
||||
import { relativeTo } from './paths.ts'
|
||||
@@ -27,7 +32,7 @@ interface LevelData {
|
||||
}
|
||||
|
||||
/** Root label: the last path segment (mirror of the host rootLabel). */
|
||||
function baseName(path: string): string {
|
||||
export function baseName(path: string): string {
|
||||
const trimmed = path.replace(/[\\/]+$/, '')
|
||||
const at = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
|
||||
return at === -1 ? trimmed : trimmed.slice(at + 1)
|
||||
@@ -36,19 +41,24 @@ function baseName(path: string): string {
|
||||
/** How long the row's "copied" label stays after a successful write. */
|
||||
const COPIED_MS = 1200
|
||||
|
||||
export function ExplorerView(props: {
|
||||
export function FileTree(props: {
|
||||
sessionId: string
|
||||
cwd: string | undefined
|
||||
expanded: string[]
|
||||
onToggle: (path: string) => void
|
||||
onOpenFile: (path: string) => void
|
||||
/** Context-menu "open in a new tab" (file rows; absent → no entry). */
|
||||
onOpenFileNewTab?: (path: string) => void
|
||||
/** Context-menu "open to the side" (file rows; absent → no entry). */
|
||||
onOpenFileSide?: (path: string) => void
|
||||
/** Insert `@<relative path>` into the composer draft. */
|
||||
onReferenceFile: (path: string) => void
|
||||
/** Bump to wipe the level cache and reload the visible set. */
|
||||
refreshTick: number
|
||||
}) {
|
||||
const { sessionId, cwd, expanded, onToggle, onOpenFile, onReferenceFile } = props
|
||||
const { sessionId, cwd, expanded, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, onReferenceFile, refreshTick } = props
|
||||
const [data, setData] = useState<Record<string, LevelData>>({})
|
||||
const dataRef = useRef(data)
|
||||
const [refreshTick, setRefreshTick] = useState(0)
|
||||
/** The row whose path was just copied ("copied" label replaces its button). */
|
||||
const [copiedPath, setCopiedPath] = useState<string | null>(null)
|
||||
/** Open context menu: the row path (and whether it is a directory) plus the cursor position. */
|
||||
@@ -69,9 +79,19 @@ export function ExplorerView(props: {
|
||||
})
|
||||
}, [sessionId, cwd, storeLevel])
|
||||
|
||||
// The caller's refresh tick wipes the cache (declared BEFORE the load
|
||||
// effect so the reload below sees the empty cache).
|
||||
const lastTick = useRef(refreshTick)
|
||||
useEffect(() => {
|
||||
if (lastTick.current === refreshTick) return
|
||||
lastTick.current = refreshTick
|
||||
dataRef.current = {}
|
||||
setData({})
|
||||
}, [refreshTick])
|
||||
|
||||
useEffect(() => {
|
||||
// Load the visible set; already-loaded levels (kept in the cache) are
|
||||
// not refetched. Only the refresh button wipes the cache.
|
||||
// not refetched. Only the refresh tick wipes the cache.
|
||||
const root = cwd
|
||||
if (root === undefined) return
|
||||
loadDir(root)
|
||||
@@ -163,6 +183,7 @@ export function ExplorerView(props: {
|
||||
>
|
||||
{isOpen ? <IconFolderOpen16 size={14} /> : <IconFolderClose16 size={14} />}
|
||||
<span className={css.explorerName}>{entry.name}</span>
|
||||
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
|
||||
{rowActions(entry)}
|
||||
</div>
|
||||
{isOpen && renderLevel(entry.path, depth + 1)}
|
||||
@@ -174,9 +195,9 @@ export function ExplorerView(props: {
|
||||
key={entry.path}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={clsx(css.explorerRow, entry.hidden && css.explorerHidden)}
|
||||
className={clsx(css.explorerRow, entry.hidden && css.explorerHidden, entry.broken && css.explorerBroken)}
|
||||
style={{ paddingLeft: depth * 22 + 6 }}
|
||||
title={entry.path}
|
||||
title={entry.broken ? `${entry.path} — ${t('brokenSymlink')}` : entry.path}
|
||||
onClick={() => { onOpenFile(entry.path) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
@@ -188,6 +209,7 @@ export function ExplorerView(props: {
|
||||
>
|
||||
<IconCodeOutline16 size={14} />
|
||||
<span className={css.explorerName}>{entry.name}</span>
|
||||
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
|
||||
{rowActions(entry)}
|
||||
</div>
|
||||
)
|
||||
@@ -195,64 +217,53 @@ export function ExplorerView(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.explorer}>
|
||||
<div className={css.explorerHeader}>
|
||||
<span className={css.explorerRoot} title={root}>{root === undefined ? t('noSession') : baseName(root)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('refresh')}
|
||||
title={t('refresh')}
|
||||
onClick={() => {
|
||||
dataRef.current = {}
|
||||
setData({})
|
||||
setRefreshTick(tick => tick + 1)
|
||||
}}
|
||||
>
|
||||
<IconRefreshOutline16 />
|
||||
</button>
|
||||
</div>
|
||||
<div className={css.explorerBody}>
|
||||
{root === undefined ? (
|
||||
<div className={css.explorerEmpty}>{t('noSession')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={css.explorerRow}
|
||||
style={{ paddingLeft: 6 }}
|
||||
onContextMenu={(event) => { openRowMenu(event, root, true) }}
|
||||
>
|
||||
<IconFolderOpen16 size={14} />
|
||||
<span className={css.explorerName}>{baseName(root)}</span>
|
||||
{copiedPath === root
|
||||
? <span className={css.explorerCopied}>{t('copied')}</span>
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className={css.explorerRef}
|
||||
aria-label={t('referenceFile')}
|
||||
title={t('referenceFile')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onReferenceFile(root)
|
||||
}}
|
||||
>
|
||||
{t('referenceFile')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{data[root] !== undefined && renderLevel(root, 1)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={css.explorerBody}>
|
||||
{root === undefined ? (
|
||||
<div className={css.explorerEmpty}>{t('noSession')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={css.explorerRow}
|
||||
style={{ paddingLeft: 6 }}
|
||||
onContextMenu={(event) => { openRowMenu(event, root, true) }}
|
||||
>
|
||||
<IconFolderOpen16 size={14} />
|
||||
<span className={css.explorerName}>{baseName(root)}</span>
|
||||
{copiedPath === root
|
||||
? <span className={css.explorerCopied}>{t('copied')}</span>
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className={css.explorerRef}
|
||||
aria-label={t('referenceFile')}
|
||||
title={t('referenceFile')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onReferenceFile(root)
|
||||
}}
|
||||
>
|
||||
{t('referenceFile')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{data[root] !== undefined && renderLevel(root, 1)}
|
||||
</>
|
||||
)}
|
||||
{/*
|
||||
The one shared context menu, positioned at the right-click cursor
|
||||
(portal so the explorer's overflow clip cannot crop it).
|
||||
(portal so the tree's overflow clip cannot crop it).
|
||||
*/}
|
||||
<Menu
|
||||
open={rowMenu !== null}
|
||||
onClose={() => { setRowMenu(null) }}
|
||||
items={[
|
||||
// The open escapes head the FILE menu (dirs only get copy).
|
||||
...(rowMenu?.isDir === false && onOpenFileNewTab !== undefined
|
||||
? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: <IconCodeOutline16 size={14} /> }]
|
||||
: []),
|
||||
...(rowMenu?.isDir === false && onOpenFileSide !== undefined
|
||||
? [{ id: 'open-side', label: t('openFileSide'), icon: <IconFolderOpen16 size={14} /> }]
|
||||
: []),
|
||||
// Download applies to files only (the host route refuses directories).
|
||||
...(rowMenu?.isDir === false
|
||||
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={14} /> }]
|
||||
@@ -264,6 +275,14 @@ export function ExplorerView(props: {
|
||||
const target = rowMenu
|
||||
if (target === null) return
|
||||
setRowMenu(null)
|
||||
if (id === 'open-new-tab') {
|
||||
onOpenFileNewTab?.(target.path)
|
||||
return
|
||||
}
|
||||
if (id === 'open-side') {
|
||||
onOpenFileSide?.(target.path)
|
||||
return
|
||||
}
|
||||
if (id === 'download') {
|
||||
downloadFile(target.path)
|
||||
return
|
||||
@@ -297,7 +297,7 @@ export function GitView(props: {
|
||||
title={t('refresh')}
|
||||
onClick={() => { void refresh() }}
|
||||
>
|
||||
<IconRefreshOutline16 />
|
||||
<IconRefreshOutline16 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -24,12 +24,11 @@
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
/* Tall inventories (7 tab cards + 9 viewer cards + general rows) must
|
||||
never be clipped by the settings shell: fill the shell's options area
|
||||
and scroll inside the section when the content outgrows the panel. */
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
max-width: 760px;
|
||||
/* The DSH settings shell's `.options` area owns vertical scrolling. Keep
|
||||
this page a plain content column like Plugins → Plugin list so its
|
||||
scrollbar stays at the shell edge instead of being inset by the options
|
||||
area's right padding. */
|
||||
}
|
||||
|
||||
/* One-line section intro (the DSH section heading+intro recipe). */
|
||||
@@ -404,6 +403,59 @@
|
||||
width: 76px;
|
||||
}
|
||||
|
||||
/* Select rows: the closed anchor is a compact button (selected option's
|
||||
icon when the options carry icons, plus title and a chevron); the dropdown
|
||||
itself is the primitives Menu (portal). */
|
||||
.selectAnchor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 220px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selectAnchor:hover {
|
||||
border-color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
.selectAnchorIcon {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.selectAnchorText {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* One big-icon option card inside the select dropdown. */
|
||||
.selectOption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.selectOptionIcon {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.selectOptionText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.suffix {
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
@@ -417,10 +469,13 @@
|
||||
width: min(460px, 100%);
|
||||
}
|
||||
|
||||
/* Popup rows: the same settings-row recipe with switch controls. */
|
||||
/* Popup rows: each setting gets the same outer chrome as a plugin-list
|
||||
PluginCard (l2 hairline, 12px radius, layer-3 fill). The row's title,
|
||||
description and control geometry stay unchanged inside that frame. */
|
||||
.popupRows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -429,16 +484,16 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 2px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
min-width: 0;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
transition: border-color 0.16s, background 0.16s;
|
||||
}
|
||||
|
||||
.popupRow:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.popupRows > :last-child {
|
||||
border-bottom: none;
|
||||
border-color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
/* The popup's Done button: the settings primary-button recipe
|
||||
|
||||
@@ -41,9 +41,11 @@
|
||||
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
IconCheckOutline16,
|
||||
IconChevronDownOutline14,
|
||||
IconPlusOutline16,
|
||||
IconSettingsOutline16,
|
||||
Input,
|
||||
Menu,
|
||||
Modal,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import clsx from 'clsx'
|
||||
@@ -209,6 +211,10 @@ export function FeatureSettingsRows(props: {
|
||||
* display (clamped for numbers, the current pref when the input is
|
||||
* invalid). Optional: rows with no handler keep their draft. */
|
||||
onCommit?: (toggle: SidebarSettingToggle, raw: string) => string
|
||||
/** Commit one select row: the picked option's value (single) or the array
|
||||
* of picked values (`multi: true`). Optional: rows with no handler are
|
||||
* display-only. */
|
||||
onSelectValue?: (toggle: SidebarSettingToggle, next: unknown) => void
|
||||
/** Explicit value source (v0.12.0+): when given, rows read their values
|
||||
* from it instead of the `prefs` face — plugin-owned rows read their
|
||||
* own blob, so a plugin key can never collide with (or silently read)
|
||||
@@ -216,12 +222,23 @@ export function FeatureSettingsRows(props: {
|
||||
* the latter collides with the inherited Object.prototype.valueOf.) */
|
||||
valueSource?: (key: string) => unknown
|
||||
}) {
|
||||
const { toggles, prefs, onToggle, onCommit, valueSource } = props
|
||||
const { toggles, prefs, onToggle, onCommit, onSelectValue, valueSource } = props
|
||||
const read = valueSource ?? ((key: string): unknown => (prefs as unknown as Record<string, unknown>)[key])
|
||||
return (
|
||||
<div className={css.popupRows}>
|
||||
{toggles.map(toggle => {
|
||||
const title = textOf(toggle.title)
|
||||
if (toggle.type === 'select') {
|
||||
return (
|
||||
<SelectRow
|
||||
key={toggle.key}
|
||||
toggle={toggle}
|
||||
title={title}
|
||||
value={read(toggle.key)}
|
||||
onSelectValue={onSelectValue}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if ((toggle.type ?? 'switch') === 'switch') {
|
||||
return (
|
||||
<div key={toggle.key} className={css.popupRow}>
|
||||
@@ -302,6 +319,99 @@ function TypedRow(props: {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One select row: a dropdown over the toggle's declared `options`. When any
|
||||
* option carries an icon, the dropdown renders big-icon option cards (icon +
|
||||
* title + desc) and the closed anchor shows the selected option's icon as
|
||||
* well; without icons both are a single line of text. Single-pick commits the
|
||||
* option's value and closes; `multi` toggles membership, commits the picked
|
||||
* values as an array (in options order), and stays open.
|
||||
*/
|
||||
function SelectRow(props: {
|
||||
toggle: SidebarSettingToggle
|
||||
title: string
|
||||
value: unknown
|
||||
onSelectValue?: (toggle: SidebarSettingToggle, next: unknown) => void
|
||||
}) {
|
||||
const { toggle, title, value, onSelectValue } = props
|
||||
const options = toggle.options ?? []
|
||||
const multi = toggle.multi === true
|
||||
const [open, setOpen] = useState(false)
|
||||
const hasIcons = options.some(option => option.icon !== undefined)
|
||||
const picked: readonly unknown[] = multi ? (Array.isArray(value) ? value : []) : [value]
|
||||
const selected = options.filter(option => picked.includes(option.value))
|
||||
|
||||
/** Commit one picked option (toggle semantics under multi). */
|
||||
const pick = (index: number): void => {
|
||||
const option = options[index]
|
||||
if (option === undefined) return
|
||||
if (!multi) {
|
||||
onSelectValue?.(toggle, option.value)
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
const current = Array.isArray(value) ? [...value] : []
|
||||
const at = current.indexOf(option.value)
|
||||
if (at >= 0) current.splice(at, 1)
|
||||
else current.push(option.value)
|
||||
// Stable wire order: follow the declared options order, not pick order.
|
||||
onSelectValue?.(toggle, options.filter(o => current.includes(o.value)).map(o => o.value))
|
||||
}
|
||||
|
||||
const anchor = (
|
||||
<button
|
||||
type="button"
|
||||
className={css.selectAnchor}
|
||||
aria-label={title}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
onClick={() => { setOpen(now => !now) }}
|
||||
>
|
||||
{!multi && hasIcons && selected[0] !== undefined && (
|
||||
<span className={css.selectAnchorIcon}>{iconOf(selected[0].icon, 16)}</span>
|
||||
)}
|
||||
<span className={css.selectAnchorText}>
|
||||
{selected.length === 0 ? '—' : selected.map(option => textOf(option.title)).join(', ')}
|
||||
</span>
|
||||
<IconChevronDownOutline14 size={12} />
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={css.popupRow}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{title}</span>
|
||||
{textOf(toggle.desc) !== '' && <span className={css.desc}>{textOf(toggle.desc)}</span>}
|
||||
</span>
|
||||
<span className={css.control}>
|
||||
<Menu
|
||||
open={open}
|
||||
anchor={anchor}
|
||||
items={options.map((option, index) => ({
|
||||
id: String(index),
|
||||
label: hasIcons
|
||||
? (
|
||||
<span className={css.selectOption}>
|
||||
<span className={css.selectOptionIcon}>{iconOf(option.icon, 24)}</span>
|
||||
<span className={css.selectOptionText}>
|
||||
<span className={css.title}>{textOf(option.title)}</span>
|
||||
{textOf(option.desc) !== '' && <span className={css.desc}>{textOf(option.desc)}</span>}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
: textOf(option.title),
|
||||
}))}
|
||||
selectedId={!multi && selected[0] !== undefined ? String(options.indexOf(selected[0])) : undefined}
|
||||
selectedIds={multi ? selected.map(option => String(options.indexOf(option))) : undefined}
|
||||
onSelect={(id) => { pick(Number(id)) }}
|
||||
onClose={() => { setOpen(false) }}
|
||||
portal
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The secondary settings popup body of one feature (tab or viewer):
|
||||
* - `settings.render` (custom panel) when declared — rendered with the
|
||||
@@ -318,12 +428,14 @@ export function SettingsBody(props: {
|
||||
service: BetterSidebarService
|
||||
onToggle: (toggle: SidebarSettingToggle, next: boolean) => void
|
||||
onCommit: (toggle: SidebarSettingToggle, raw: string) => string
|
||||
onSelectValue: (toggle: SidebarSettingToggle, next: unknown) => void
|
||||
onPluginToggle: (toggle: SidebarSettingToggle, next: boolean) => void
|
||||
onPluginCommit: (toggle: SidebarSettingToggle, raw: string) => string
|
||||
onPluginSelectValue: (toggle: SidebarSettingToggle, next: unknown) => void
|
||||
onPluginWrite: (key: string, value: unknown) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { feature, prefs, store, service, onToggle, onCommit, onPluginToggle, onPluginCommit, onPluginWrite, onClose } = props
|
||||
const { feature, prefs, store, service, onToggle, onCommit, onSelectValue, onPluginToggle, onPluginCommit, onPluginSelectValue, onPluginWrite, onClose } = props
|
||||
const render = feature.settings?.render
|
||||
if (render !== undefined) {
|
||||
return (
|
||||
@@ -356,6 +468,7 @@ export function SettingsBody(props: {
|
||||
prefs={prefs}
|
||||
onToggle={onToggle}
|
||||
onCommit={onCommit}
|
||||
onSelectValue={onSelectValue}
|
||||
/>
|
||||
)}
|
||||
{pluginToggles.length > 0 && (
|
||||
@@ -364,6 +477,7 @@ export function SettingsBody(props: {
|
||||
prefs={prefs}
|
||||
onToggle={onPluginToggle}
|
||||
onCommit={onPluginCommit}
|
||||
onSelectValue={onPluginSelectValue}
|
||||
valueSource={(key) => pluginBlob[key]}
|
||||
/>
|
||||
)}
|
||||
@@ -493,6 +607,12 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
applyPref({ [toggle.key]: next })
|
||||
}
|
||||
|
||||
/** Commit one declaratively-declared select row (the option's value, or an
|
||||
* array of values under `multi`). */
|
||||
const onSelectSetting = (toggle: SidebarSettingToggle, next: unknown): void => {
|
||||
applyPref({ [toggle.key]: next })
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit one declaratively-declared text/number row. Numbers are parsed
|
||||
* and clamped to the toggle's declared min/max (an unparsable input falls
|
||||
@@ -807,8 +927,10 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
prefs={prefs}
|
||||
onToggle={onToggleSetting}
|
||||
onCommit={onCommitSetting}
|
||||
onSelectValue={onSelectSetting}
|
||||
onPluginToggle={(toggle, next) => { onPluginToggle(settingsFor.id, toggle, next) }}
|
||||
onPluginCommit={(toggle, raw) => onPluginCommitSetting(settingsFor.id, toggle, raw)}
|
||||
onPluginSelectValue={(toggle, next) => { applyPluginSetting(settingsFor.id, toggle.key, next) }}
|
||||
onPluginWrite={(key, value) => { applyPluginSetting(settingsFor.id, key, value) }}
|
||||
onClose={() => { setSettingsFor(null) }}
|
||||
store={store}
|
||||
|
||||
+51
-49
@@ -462,7 +462,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// pointer up (clamping + persistence).
|
||||
const panelRef = useRef<HTMLDivElement | null>(null)
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||
const cornerRef = useRef<HTMLDivElement | null>(null)
|
||||
const widthDrag = useRef({ startX: 0, startWidth: 0 })
|
||||
const [draggingWidth, setDraggingWidth] = useState(false)
|
||||
const bottomDrag = useRef({ startY: 0, startHeight: 0 })
|
||||
@@ -501,10 +500,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRect.right) + (width - (state?.width ?? 0))}px`)
|
||||
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
|
||||
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
|
||||
if (cornerRef.current !== null) {
|
||||
cornerRef.current.style.left = `${window.innerWidth - width - 6}px`
|
||||
cornerRef.current.style.top = `${window.innerHeight - height - 6}px`
|
||||
}
|
||||
// The corner handle positions itself relative to the panel (CSS
|
||||
// `bottom: calc(var(--dsh-sidebar-height) + 6px)`), so these two layout
|
||||
// variables are all it needs — no viewport coordinates written here
|
||||
// (issue #106: skins that inset the panels must not fight JS coords).
|
||||
}
|
||||
|
||||
// Drags write at most once per frame: pointer events fire several times
|
||||
@@ -775,11 +774,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
ref={panelRef}
|
||||
className={clsx(css.panel, !state.panelOpen && css.panelHidden)}
|
||||
style={{ width: narrow ? '100vw' : Math.min(state.width, window.innerWidth) }}
|
||||
|
||||
data-dragging={anyDragging || undefined}
|
||||
>
|
||||
{!narrow && (
|
||||
<div
|
||||
className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)}
|
||||
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
@@ -814,6 +815,49 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
getTabBadge={tabBadgeOf}
|
||||
/>
|
||||
</div>
|
||||
{/*
|
||||
The shared corner (only while BOTH panels are open): the
|
||||
intersection of the right panel's left edge and the bottom panel's
|
||||
top edge. Horizontal drags resize the right panel's width, vertical
|
||||
drags the bottom panel's height — the two panels drag against each
|
||||
other. Rendered INSIDE the right panel and positioned by CSS
|
||||
relative to it (left edge + the bottom panel's height via the
|
||||
--dsh-sidebar-height layout variable) — no JS-written viewport
|
||||
coordinates to keep in sync. (Never on narrow viewports: the
|
||||
bottom panel does not exist there.)
|
||||
*/}
|
||||
{!narrow && state.panelOpen && state.bottomOpen && (
|
||||
<div
|
||||
className={css.cornerHandle}
|
||||
data-dragging={draggingCorner || undefined}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
cornerDrag.current = {
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
startWidth: state.width,
|
||||
startHeight: state.bottomHeight,
|
||||
}
|
||||
setDraggingCorner(true)
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
const { startX, startY, startWidth, startHeight } = cornerDrag.current
|
||||
const width = clampWidth(startWidth + (startX - event.clientX))
|
||||
const height = clampHeight(startHeight + (startY - event.clientY))
|
||||
scheduleDrag(width, height)
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
const { startX, startY, startWidth, startHeight } = cornerDrag.current
|
||||
stopDragScheduling()
|
||||
store.reduce(s => setBottomHeight(setWidth(s, startWidth + (startX - event.clientX)), startHeight + (startY - event.clientY)))
|
||||
setDraggingCorner(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/*
|
||||
The bottom panel: a second, independent workbench. It squeezes ONLY
|
||||
@@ -842,10 +886,12 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// fill — without it the corner looks cut off).
|
||||
borderRight: state.panelOpen ? '1px solid var(--dsw-alias-border-l2)' : undefined,
|
||||
}}
|
||||
|
||||
data-dragging={(draggingBottom || draggingCorner) || undefined}
|
||||
>
|
||||
<div
|
||||
className={clsx(css.bottomResize, draggingBottom && css.bottomResizeActive)}
|
||||
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
@@ -896,50 +942,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/*
|
||||
The shared corner (only while BOTH panels are open): the intersection
|
||||
of the right panel's left edge and the bottom panel's top edge.
|
||||
Horizontal drags resize the right panel's width, vertical drags the
|
||||
bottom panel's height — the two panels drag against each other.
|
||||
(Never on narrow viewports: the bottom panel does not exist there.)
|
||||
*/}
|
||||
{!narrow && state.panelOpen && state.bottomOpen && (
|
||||
<div
|
||||
ref={cornerRef}
|
||||
className={css.cornerHandle}
|
||||
style={{
|
||||
left: window.innerWidth - state.width - 6,
|
||||
top: window.innerHeight - state.bottomHeight - 6,
|
||||
}}
|
||||
data-dragging={draggingCorner || undefined}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
cornerDrag.current = {
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
startWidth: state.width,
|
||||
startHeight: state.bottomHeight,
|
||||
}
|
||||
setDraggingCorner(true)
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
const { startX, startY, startWidth, startHeight } = cornerDrag.current
|
||||
const width = clampWidth(startWidth + (startX - event.clientX))
|
||||
const height = clampHeight(startHeight + (startY - event.clientY))
|
||||
scheduleDrag(width, height)
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
const { startX, startY, startWidth, startHeight } = cornerDrag.current
|
||||
stopDragScheduling()
|
||||
store.reduce(s => setBottomHeight(setWidth(s, startWidth + (startX - event.clientX)), startHeight + (startY - event.clientY)))
|
||||
setDraggingCorner(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,20 +20,32 @@
|
||||
* reconnect grace.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Terminal, type ITheme } from 'xterm'
|
||||
import { Terminal, type ITheme } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import 'xterm/css/xterm.css'
|
||||
import { writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { t } from './locales.ts'
|
||||
import { openWhenSized } from './open-when-sized.ts'
|
||||
import type { SessionScope } from './api.ts'
|
||||
import { api, type SessionScope, type TerminalDepsStatus } from './api.ts'
|
||||
import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts'
|
||||
import { isDarkScheme, subscribeColorScheme, tokenValue } from './theme.ts'
|
||||
import { isDarkScheme, subscribeColorScheme, effectiveTokenValue, tokenValue } from './theme.ts'
|
||||
import { resolveTerminalFont } from './terminal-font.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** How many consecutive unreasoned failures before showing the error banner. */
|
||||
const FAILURE_LIMIT = 3
|
||||
|
||||
/**
|
||||
* The WS close-code-1011 reason the host sends when node-pty is unavailable
|
||||
* (mirror of the host's PTY_DEPS_MISSING; the value is a wire contract, so
|
||||
* the two sides keep the literal in lockstep). The view then fetches the
|
||||
* full repair details from /sidebar/api/terminal.deps.
|
||||
*/
|
||||
const PTY_DEPS_MISSING = 'pty-deps-missing'
|
||||
|
||||
/** The degraded-mode payload rendered by {@link TerminalDepsBanner}. */
|
||||
type TerminalDepsInfo = Extract<TerminalDepsStatus, { ok: false }>
|
||||
|
||||
/**
|
||||
* Curated ANSI palettes for the terminal. The surface colors (background,
|
||||
* foreground, cursor, selection) ride the theme tokens so the terminal
|
||||
@@ -61,8 +73,14 @@ const ANSI_LIGHT: Record<string, string> = {
|
||||
/** The xterm theme for the current scheme (surface from tokens, ANSI curated). */
|
||||
function xtermTheme(): ITheme {
|
||||
const dark = isDarkScheme()
|
||||
const background = tokenValue('--dsw-alias-bg-base') || (dark ? '#111114' : '#ffffff')
|
||||
const foreground = tokenValue('--dsw-alias-label-primary') || (dark ? '#e6e6e6' : '#1a1a1a')
|
||||
// Skin systems set --dsw-alias-bg-base to `transparent` or translucent
|
||||
// glass values (the dsh-web-ui skins use rgba 0.16–0.7); effectiveTokenValue
|
||||
// treats those as unset below the opacity floor, so the opaque fallback
|
||||
// engages and the terminal never renders see-through over the skin's
|
||||
// backdrop (issue #90). Effectively opaque scoped surfaces (e.g. a skin's
|
||||
// 0.96 porcelain) pass through — the skin still controls the terminal.
|
||||
const background = effectiveTokenValue('--dsw-alias-bg-base') || (dark ? '#111114' : '#ffffff')
|
||||
const foreground = effectiveTokenValue('--dsw-alias-label-primary') || (dark ? '#e6e6e6' : '#1a1a1a')
|
||||
return {
|
||||
background,
|
||||
foreground,
|
||||
@@ -78,6 +96,7 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [fatal, setFatal] = useState<string | null>(null)
|
||||
const [depsFatal, setDepsFatal] = useState<TerminalDepsInfo | null>(null)
|
||||
const [lastUrl, setLastUrl] = useState<string | null>(null)
|
||||
const connectRef = useRef<(() => void) | null>(null)
|
||||
|
||||
@@ -151,6 +170,25 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
||||
}
|
||||
socket.onclose = (event) => {
|
||||
setConnected(false)
|
||||
// node-pty dependency missing/broken (issue #140): the host closed
|
||||
// with the short marker. Fetch the full repair details over HTTP —
|
||||
// a WS close reason is capped at 123 bytes, too small for the
|
||||
// pasteable command. A failed fetch falls back to the plain banner.
|
||||
if (event.code === 1011 && event.reason === PTY_DEPS_MISSING) {
|
||||
void api.terminalDeps().then((status) => {
|
||||
if (status.ok) {
|
||||
// The host recovered between the close and the fetch — the
|
||||
// plain banner with a retry is the honest state.
|
||||
setFatal(t('terminalDepsFailed'))
|
||||
return
|
||||
}
|
||||
setFatal(null)
|
||||
setDepsFatal(status)
|
||||
}).catch(() => {
|
||||
setFatal(t('terminalDepsFailed'))
|
||||
})
|
||||
return
|
||||
}
|
||||
// A server-side refusal carries a close code + reason; retrying it
|
||||
// forever would only spin the banner, so surface it with a retry.
|
||||
if (event.code === 1011 && event.reason !== '') {
|
||||
@@ -257,6 +295,9 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
||||
|
||||
return (
|
||||
<div className={css.terminalWrap}>
|
||||
{depsFatal !== null && (
|
||||
<TerminalDepsBanner deps={depsFatal} onRetry={() => { setDepsFatal(null); connectRef.current?.() }} />
|
||||
)}
|
||||
{fatal !== null && (
|
||||
<div className={css.terminalBanner}>
|
||||
{t('terminalError')}: {fatal}
|
||||
@@ -270,8 +311,48 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{fatal === null && !connected && <div className={css.terminalBanner}>{t('disconnected')}</div>}
|
||||
{fatal === null && depsFatal === null && !connected && <div className={css.terminalBanner}>{t('disconnected')}</div>}
|
||||
<div ref={hostRef} className={css.terminal} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The node-pty dependency failure banner (issue #140): explains that the
|
||||
* terminal's native dependency failed to load and shows the PASTEABLE repair
|
||||
* command (bash / cmd / PowerShell) with a copy button — the user pastes it
|
||||
* into a terminal where their DSH profile lives and runs it, then retries.
|
||||
* Extracted as a standalone component for direct testing.
|
||||
*/
|
||||
export function TerminalDepsBanner(props: { deps: TerminalDepsInfo; onRetry: () => void }) {
|
||||
const { deps, onRetry } = props
|
||||
const [copied, setCopied] = useState(false)
|
||||
const copy = async (): Promise<void> => {
|
||||
const written = await writeClipboard(deps.command)
|
||||
if (written) {
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className={css.terminalDepsBanner}>
|
||||
<div className={css.terminalDepsTitle}>{t('terminalDepsFailed')}</div>
|
||||
<div className={css.terminalDepsHint}>
|
||||
{t('terminalDepsHint')}
|
||||
{deps.profile !== null ? t('terminalDepsProfile', { profile: deps.profile }) : ''}
|
||||
</div>
|
||||
<div className={css.terminalDepsCommandRow}>
|
||||
<pre className={css.terminalRepairCommand}>{deps.command}</pre>
|
||||
<button type="button" className={css.terminalRetry} onClick={() => { void copy() }} aria-label={t('copy')}>
|
||||
{copied ? t('copied') : t('copy')}
|
||||
</button>
|
||||
</div>
|
||||
{deps.note !== undefined && <div className={css.terminalDepsNote}>{deps.note}</div>}
|
||||
<div className={css.terminalDepsActions}>
|
||||
<button type="button" className={css.terminalRetry} onClick={onRetry}>
|
||||
{t('terminalRetry')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
* so this component never fetches or dispatches — it only edits.
|
||||
*
|
||||
* The toolbar (mode toggle / dirty dot / save / status) renders as its own
|
||||
* row below the host's title bar, VSCode-style.
|
||||
* row below the host's title bar, VSCode-style — unless the host passes
|
||||
* `toolbar: 'host'` (the merged editor-explorer mode), in which case this
|
||||
* component skips the row and reports state + registers commands through
|
||||
* the FileViewerProps toolbar callbacks so the host's path-input header
|
||||
* renders the controls instead.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
@@ -24,7 +28,7 @@ import { SandboxStatusBar } from './SandboxStatusBar.tsx'
|
||||
import { appendToDraft } from './conversation-draft.ts'
|
||||
import { buildSelectionInsert, linesOfSelection } from './selection-payload.ts'
|
||||
import { t } from './locales.ts'
|
||||
import type { FileViewerProps } from './service.ts'
|
||||
import type { EditorToolbarState, FileViewerProps } from './service.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** Previewable files (rendered output vs source editing). */
|
||||
@@ -141,11 +145,11 @@ export function TextEditor(props: FileViewerProps) {
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
]),
|
||||
// Selection popup (the catch-all code viewer only): a non-empty
|
||||
// Selection popup (the code and markdown editors): a non-empty
|
||||
// selection anchors the floating "add to conversation" button above
|
||||
// its head. Scrolling (geometry/viewport change) or losing focus
|
||||
// hides it; typing collapses the selection and hides it too.
|
||||
...(viewerId === 'code' ? [
|
||||
...(viewerId === 'code' || viewerId === 'markdown' ? [
|
||||
CodeMirrorView.updateListener.of((update) => {
|
||||
if (update.geometryChanged || update.viewportChanged) {
|
||||
hidePopup()
|
||||
@@ -278,8 +282,31 @@ export function TextEditor(props: FileViewerProps) {
|
||||
const [localUnlock, setLocalUnlock] = useState(() => props.store?.getPrefs().htmlViewerDefaultUnsafe === true)
|
||||
const htmlNoSandbox = props.store?.getPrefs().htmlViewerNoSandbox === true || localUnlock
|
||||
|
||||
// Host-toolbar mode (the merged editor header renders the controls): skip
|
||||
// the own toolbar row, report the state after every relevant render (the
|
||||
// JSON key guards redundant calls), and register the commands on mount.
|
||||
const hostToolbar = props.toolbar === 'host'
|
||||
const lastToolbarRef = useRef('')
|
||||
useEffect(() => {
|
||||
if (!hostToolbar) return
|
||||
const state: EditorToolbarState = { modes: markdown || html, mode, dirty, editable, saveState }
|
||||
const key = JSON.stringify(state)
|
||||
if (lastToolbarRef.current === key) return
|
||||
lastToolbarRef.current = key
|
||||
props.onToolbarState?.(state)
|
||||
})
|
||||
useEffect(() => {
|
||||
if (!hostToolbar) return
|
||||
// `save` reads live refs only, and `setMode` is the stable state setter —
|
||||
// registering this render's closures is safe for the mount's lifetime.
|
||||
props.onToolbarControls?.({ setMode, save })
|
||||
return () => { props.onToolbarControls?.(null) }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hostToolbar])
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hostToolbar && (
|
||||
<div className={css.editorHeader}>
|
||||
{(markdown || html) && (
|
||||
<div className={css.editorModeToggle}>
|
||||
@@ -313,6 +340,7 @@ export function TextEditor(props: FileViewerProps) {
|
||||
)}
|
||||
{saveLabel !== '' && <span className={clsx(css.editorStatus, saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>}
|
||||
</div>
|
||||
)}
|
||||
{editable && (
|
||||
<>
|
||||
{truncated === true && mode === 'edit' && <div className={css.editorBanner}>{t('truncation')}</div>}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* The files window's tree surface: a global file-name search box on top
|
||||
* (300ms debounce; an in-flight search is aborted by the next keystroke)
|
||||
* over either the shared controlled FileTree (empty query) or the flat
|
||||
* result list (relative paths; click opens through the caller's mode-aware
|
||||
* open). Owns its refresh tick: the icon next to the search input clears
|
||||
* the tree cache. EditorHost docks it as the tab's right panel (wrapped in
|
||||
* a drag-resize handle) and provides the file context-menu open escapes.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { api } from './api.ts'
|
||||
import { FileTree } from './FileTree.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import { resolveSidebarPath } from './produced-files.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
export function TreePanel(props: {
|
||||
sessionId: string
|
||||
cwd: string | undefined
|
||||
expanded: string[]
|
||||
onToggle: (path: string) => void
|
||||
onOpenFile: (path: string) => void
|
||||
/** File context-menu "open in a new tab" (passed through to FileTree). */
|
||||
onOpenFileNewTab?: (path: string) => void
|
||||
/** File context-menu "open to the side" (passed through to FileTree). */
|
||||
onOpenFileSide?: (path: string) => void
|
||||
onReferenceFile: (path: string) => void
|
||||
/** Full-window presentation: the panel fills its host instead of docking
|
||||
* at a fixed width. */
|
||||
full?: boolean
|
||||
}) {
|
||||
const { sessionId, cwd, expanded, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, onReferenceFile, full } = props
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refreshTick, setRefreshTick] = useState(0)
|
||||
|
||||
const needle = query.trim()
|
||||
useEffect(() => {
|
||||
if (needle === '') {
|
||||
setResults(null)
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const timer = window.setTimeout(() => {
|
||||
api.fsSearch({ sessionId, cwd }, needle, controller.signal).then((found) => {
|
||||
setResults(found)
|
||||
setError(null)
|
||||
}).catch((failure: unknown) => {
|
||||
if (controller.signal.aborted) return
|
||||
setResults(null)
|
||||
setError(failure instanceof Error ? failure.message : String(failure))
|
||||
})
|
||||
}, 300)
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
controller.abort()
|
||||
}
|
||||
}, [sessionId, cwd, needle])
|
||||
|
||||
return (
|
||||
<div className={clsx(css.editorTreePanel, full === true && css.editorTreePanelFull)}>
|
||||
<div className={css.editorTreeSearch}>
|
||||
<input
|
||||
className={css.editorSearchInput}
|
||||
value={query}
|
||||
placeholder={t('editorSearchPlaceholder')}
|
||||
spellCheck={false}
|
||||
onChange={(event) => { setQuery(event.target.value) }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('refresh')}
|
||||
title={t('refresh')}
|
||||
onClick={() => { setRefreshTick(tick => tick + 1) }}
|
||||
>
|
||||
<IconRefreshOutline16 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{needle === '' ? (
|
||||
<FileTree
|
||||
sessionId={sessionId}
|
||||
cwd={cwd}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
onOpenFile={onOpenFile}
|
||||
onOpenFileNewTab={onOpenFileNewTab}
|
||||
onOpenFileSide={onOpenFileSide}
|
||||
onReferenceFile={onReferenceFile}
|
||||
refreshTick={refreshTick}
|
||||
/>
|
||||
) : (
|
||||
<div className={css.explorerBody}>
|
||||
{error !== null && <div className={clsx(css.editorSearchHint, css.editorError)}>{error}</div>}
|
||||
{error === null && results === null && <div className={css.editorSearchHint}>{t('loading')}</div>}
|
||||
{error === null && results !== null && results.matches.length === 0 && (
|
||||
<div className={css.editorSearchHint}>{t('editorSearchNoResults')}</div>
|
||||
)}
|
||||
{error === null && results !== null && results.matches.map(rel => (
|
||||
<button
|
||||
key={rel}
|
||||
type="button"
|
||||
className={css.editorSearchResult}
|
||||
title={rel}
|
||||
onClick={() => { onOpenFile(resolveSidebarPath(cwd, rel)) }}
|
||||
>
|
||||
{rel}
|
||||
</button>
|
||||
))}
|
||||
{error === null && results?.truncated === true && (
|
||||
<div className={css.editorSearchHint}>{t('editorSearchTruncated')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+37
-4
@@ -25,6 +25,10 @@ export interface FsEntry {
|
||||
path: string
|
||||
isDir: boolean
|
||||
hidden: boolean
|
||||
/** Whether the row is a symlink; `isDir` then describes the link's target. */
|
||||
isSymlink: boolean
|
||||
/** For symlinks: the target is missing or unreadable (stat failed). */
|
||||
broken: boolean
|
||||
}
|
||||
|
||||
/** Git status entry (host git shape). */
|
||||
@@ -75,6 +79,21 @@ export interface JobOutputResult {
|
||||
read: boolean
|
||||
}
|
||||
|
||||
/** Terminal dependency status (mirror of the host's depsStatus; issue #140). */
|
||||
export type TerminalDepsStatus =
|
||||
| { ok: true }
|
||||
| {
|
||||
ok: false
|
||||
/** The require-time error message (module missing, native binding broken…). */
|
||||
cause: string
|
||||
/** The pasteable repair command (terminal/cmd). */
|
||||
command: string
|
||||
/** The detected profile name (null when undetected → the command defaults to web). */
|
||||
profile: string | null
|
||||
/** Optional supplementary hint (fallback command only). */
|
||||
note?: string
|
||||
}
|
||||
|
||||
async function call<T>(method: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
|
||||
let response: Response
|
||||
try {
|
||||
@@ -116,6 +135,10 @@ export const api = {
|
||||
call<{ sessionId: string; cwd: string; root: string; parent: string | null }>('session.cwd', scopePayload(scope, {}), signal),
|
||||
fsTree: (scope: SessionScope, path: string, signal?: AbortSignal) =>
|
||||
call<{ path: string; entries: FsEntry[]; truncated: boolean }>('fs.tree', scopePayload(scope, { path }), signal),
|
||||
/** Global recursive file-name search rooted at the session cwd (the editor
|
||||
* side panel's search box); matches are cwd-relative '/'-separated paths. */
|
||||
fsSearch: (scope: SessionScope, query: string, signal?: AbortSignal) =>
|
||||
call<{ matches: string[]; truncated: boolean }>('fs.search', scopePayload(scope, { query }), signal),
|
||||
fsRead: (scope: SessionScope, path: string, signal?: AbortSignal) =>
|
||||
call<FsTextResult | FsBinaryResult>('fs.read', scopePayload(scope, { path }), signal),
|
||||
fsWrite: (scope: SessionScope, path: string, content: string) =>
|
||||
@@ -160,6 +183,11 @@ export const api = {
|
||||
/** Release an agent terminal by uuid (tab closed while WS was down). */
|
||||
agentPtyClose: (uuid: string) =>
|
||||
call<{ ok: true }>('agent-pty.close', { uuid }),
|
||||
/** Terminal dependency status (issue #140): after a WS close 1011 with
|
||||
* reason `pty-deps-missing` the view fetches the full repair details here
|
||||
* (the close reason itself is capped at 123 bytes). */
|
||||
terminalDeps: () =>
|
||||
call<TerminalDepsStatus>('terminal.deps', {}),
|
||||
/**
|
||||
* The output the model has read so far for one background job (replayed
|
||||
* from the owner session's event log — never the model's job_output
|
||||
@@ -175,7 +203,7 @@ export const api = {
|
||||
})),
|
||||
/** Read the side card preferences (plugin-global, no session scope). */
|
||||
settingsGet: () =>
|
||||
call<{ value?: unknown; revision?: number }>('settings.get', {}),
|
||||
call<{ value?: unknown; revision?: number; externalDisable?: boolean }>('settings.get', {}),
|
||||
/** Merge a patch into the side card preferences (revision-guarded). */
|
||||
settingsUpdate: (patch: Record<string, unknown>, expectedRevision?: number) =>
|
||||
call<{ value?: unknown; revision?: number }>('settings.update', {
|
||||
@@ -207,9 +235,14 @@ function fileUrl(scope: SessionScope, path: string, download: boolean): string {
|
||||
return `/sidebar/file?${params.toString()}`
|
||||
}
|
||||
|
||||
/** Absolute URL of the HTML preview route (see html-route.ts): the path is
|
||||
* fully encoded so the previewed page's relative assets resolve back into
|
||||
* the same route with the session scope intact. */
|
||||
/**
|
||||
* Absolute URL of the HTML preview route (see html-route.ts): the path is
|
||||
* fully encoded so the previewed page's relative assets resolve back into
|
||||
* the same route with the session scope intact. The UNC marker is
|
||||
* platform-neutral — the host's requireAbsolute resolves the decoded
|
||||
* forward-slash `//server/share/...` form on both win32 and POSIX — so no
|
||||
* client-side platform signal is needed.
|
||||
*/
|
||||
export function htmlUrl(scope: SessionScope, path: string): string {
|
||||
return encodeHtmlUrl(scope.sessionId, path)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
/**
|
||||
* The 7 built-in tab descriptors: the plugin registers its own pages
|
||||
* (explorer / git / terminal / browser / subagent / editor / diff) through
|
||||
* The 6 built-in tab descriptors: the plugin registers its own pages
|
||||
* (editor / git / terminal / browser / subagent / diff) through
|
||||
* the same {@link BetterSidebarService} external plugins use — eating its
|
||||
* own dogfood. The terminal descriptor owns its quota (`TERMINAL_LIMIT`)
|
||||
* and mints `terminal:<n>` ids through `createTab`; the browser mints
|
||||
* `browser:<n>` the same way (no quota).
|
||||
* `browser:<n>` the same way (no quota). The editor IS the files window
|
||||
* (the old standalone explorer merged into it).
|
||||
*/
|
||||
import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconPanelLeftOutline16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from '../../context-types.ts'
|
||||
import { allLeaves, isAgentTabId, type SidebarState } from '../state.ts'
|
||||
import { t } from '../locales.ts'
|
||||
import { openSidebarFile } from '../intercept.tsx'
|
||||
import { ExplorerView } from '../ExplorerView.tsx'
|
||||
import { EditorHost } from '../EditorHost.tsx'
|
||||
import { lazyChunkComponent } from '../lazy-chunk.tsx'
|
||||
import { GitView } from '../GitView.tsx'
|
||||
@@ -59,33 +59,52 @@ function uiTerminalCount(state: SidebarState): number {
|
||||
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
|
||||
}
|
||||
|
||||
/** The 7 built-in tab descriptors. */
|
||||
/** The 6 built-in tab descriptors. */
|
||||
export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: 'editor',
|
||||
title: () => t('editor'),
|
||||
icon: (size: number) => <IconCodeOutline16 size={size} />,
|
||||
order: -1,
|
||||
hidden: true,
|
||||
dedupeKey: (tab) => tab.path,
|
||||
component: ({ ctx, store, scope, tab }) => (
|
||||
<EditorHost ctx={ctx} store={store} scope={scope} path={tab.path ?? ''} title={tab.title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'explorer',
|
||||
title: () => t('explorer'),
|
||||
// The single files window: an editor tab with no path IS the file
|
||||
// explorer (empty hint + docked tree); with a path it previews/edits
|
||||
// the file. Visible in the + menu in the explorer's old slot.
|
||||
title: () => t('files'),
|
||||
icon: (size: number) => <IconFolderOpen16 size={size} />,
|
||||
order: 10,
|
||||
single: true,
|
||||
component: ({ ctx, store, scope, expanded, onToggleDir, onReferenceFile }) => (
|
||||
<ExplorerView
|
||||
sessionId={scope.sessionId}
|
||||
cwd={scope.cwd}
|
||||
hidden: false,
|
||||
dedupeKey: (tab) => tab.path,
|
||||
// Declarative settings: the file-open behavior picker (in-place switch
|
||||
// vs per-path windows) renders as an iconed select row under the
|
||||
// editor card's gear in the Side card settings page.
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'editorExplorer',
|
||||
type: 'select',
|
||||
title: () => t('editorExplorer'),
|
||||
desc: () => t('editorExplorerDesc'),
|
||||
options: [
|
||||
{
|
||||
value: true,
|
||||
icon: (size: number) => <IconPanelLeftOutline16 size={size} />,
|
||||
title: () => t('editorExplorerMerged'),
|
||||
desc: () => t('editorExplorerMergedDesc'),
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
icon: (size: number) => <IconCodeOutline16 size={size} />,
|
||||
title: () => t('editorExplorerSplit'),
|
||||
desc: () => t('editorExplorerSplitDesc'),
|
||||
},
|
||||
],
|
||||
}],
|
||||
},
|
||||
component: ({ ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile }) => (
|
||||
<EditorHost
|
||||
ctx={ctx}
|
||||
store={store}
|
||||
scope={scope}
|
||||
tab={tab}
|
||||
expanded={expanded ?? []}
|
||||
onToggle={onToggleDir ?? (() => { /* no-op */ })}
|
||||
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
|
||||
onToggleDir={onToggleDir ?? (() => { /* no-op */ })}
|
||||
onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })}
|
||||
/>
|
||||
),
|
||||
|
||||
+55
-14
@@ -20,7 +20,8 @@ import { RenderBoundary } from './RenderBoundary.tsx'
|
||||
import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx'
|
||||
import { registerLinkInterception } from './link-intercept.ts'
|
||||
import { registerImeGuard } from './ime-guard.ts'
|
||||
import { loadPrefs } from './prefs.ts'
|
||||
import { registerSettingsNavIcon } from './settings-nav-icon.ts'
|
||||
import { loadExternalDisable, loadPrefs } from './prefs.ts'
|
||||
import { SideCardSection } from './SideCardSection.tsx'
|
||||
import { api } from './api.ts'
|
||||
import { LOCALE_NS, attachLocale, t, zh, en } from './locales.ts'
|
||||
@@ -97,7 +98,30 @@ export function apply(ctx: Context): void {
|
||||
let disposed = false
|
||||
let root: Root | undefined
|
||||
let host: HTMLDivElement | undefined
|
||||
void (async () => {
|
||||
let mounted = false
|
||||
const unmount = (): void => {
|
||||
if (!mounted) return
|
||||
mounted = false
|
||||
root?.unmount()
|
||||
root = undefined
|
||||
host?.remove()
|
||||
host = undefined
|
||||
}
|
||||
const mount = (): void => {
|
||||
if (mounted || disposed) return
|
||||
try {
|
||||
host = document.createElement('div')
|
||||
host.setAttribute('data-dsh-better-sidebar', '')
|
||||
document.body.appendChild(host)
|
||||
root = createRoot(host)
|
||||
root.render(createElement(RenderBoundary, { className: css.boundaryError }, createElement(Sidebar, { ctx, store: sidebarStore })))
|
||||
mounted = true
|
||||
} catch (error) {
|
||||
fail('mount', error)
|
||||
}
|
||||
}
|
||||
const sync = async (): Promise<void> => {
|
||||
if (disposed) return
|
||||
// Resolve the user's side card prefs BEFORE the first session seeds,
|
||||
// so a brand-new conversation opens (or stays closed) at the chosen
|
||||
// width from first paint. A settings route failure falls back to the
|
||||
@@ -109,20 +133,26 @@ export function apply(ctx: Context): void {
|
||||
])
|
||||
if (prefs !== null) sidebarStore.setPrefs(prefs)
|
||||
if (disposed) return
|
||||
try {
|
||||
host = document.createElement('div')
|
||||
host.setAttribute('data-dsh-better-sidebar', '')
|
||||
document.body.appendChild(host)
|
||||
root = createRoot(host)
|
||||
root.render(createElement(RenderBoundary, { className: css.boundaryError }, createElement(Sidebar, { ctx, store: sidebarStore })))
|
||||
} catch (error) {
|
||||
fail('mount', error)
|
||||
}
|
||||
})()
|
||||
// Mutual exclusion with the dsh-web-ui family right panel: while the
|
||||
// aionui-panel provider is selected, the sidebar must not mount at
|
||||
// all. Re-evaluated on every settings-document update (live switch).
|
||||
const suspended = await loadExternalDisable(api)
|
||||
if (disposed) return
|
||||
sidebarStore.setSuspended(suspended)
|
||||
if (suspended) unmount()
|
||||
else mount()
|
||||
}
|
||||
void sync()
|
||||
// Live re-evaluation: the runtime broadcasts settings-document updates
|
||||
// (the aionui card saves through the same document). Best effort —
|
||||
// deployments without the 'remote' service fall back to boot-time
|
||||
// evaluation only.
|
||||
const remote = ctx.get('remote') as { $on?: (event: string, listener: () => void) => () => void } | undefined
|
||||
const offRemote = remote?.$on?.('settings/document-updated', () => { void sync() })
|
||||
return () => {
|
||||
disposed = true
|
||||
root?.unmount()
|
||||
host?.remove()
|
||||
offRemote?.()
|
||||
unmount()
|
||||
}
|
||||
}, 'dsh-better-sidebar: sidebar mount')
|
||||
|
||||
@@ -167,6 +197,7 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
return registerLinkInterception({
|
||||
takeoverEnabled: (url) => {
|
||||
if (sidebarStore.getSuspended()) return false
|
||||
const prefs = sidebarStore.getPrefs()
|
||||
if (prefs.browserInterceptLinks === false) return false
|
||||
const protocolOn = url.protocol === 'https:'
|
||||
@@ -213,6 +244,16 @@ export function apply(ctx: Context): void {
|
||||
'dsh-better-sidebar: IME composition guard',
|
||||
)
|
||||
|
||||
// DSH 0.1.x does not yet carry an icon through the settings.section
|
||||
// registration contract: its shell renders a generic gear for every
|
||||
// external section. Mark only this plugin's localized nav row so
|
||||
// layout.css can paint the requested Side card SVG; the disposer clears
|
||||
// the marker for HMR / plugin disable.
|
||||
ctx.effect(
|
||||
() => registerSettingsNavIcon(() => t('settingsNav')),
|
||||
'dsh-better-sidebar: settings navigation icon',
|
||||
)
|
||||
|
||||
// The "Side card" settings section: appears in the DSH Settings shell
|
||||
// once the shell's declaration is on the ledger (slots.inject waits for
|
||||
// it); the section reads/writes the prefs through the plugin's own
|
||||
|
||||
@@ -76,8 +76,10 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
|
||||
name: 'conversation.chat.turnTail',
|
||||
// Decline the takeover while the editor tab type is disabled in the side
|
||||
// card settings: the produced-files row falls back to the default
|
||||
// deliverables behavior instead of offering chips that cannot open.
|
||||
// deliverables behavior instead of offering chips that cannot open. Also
|
||||
// while the sidebar is externally disabled (aionui-panel chosen).
|
||||
select: (owner) => {
|
||||
if (store.getSuspended()) return null
|
||||
if (store.getPrefs().tabsEnabled['editor'] === false) return null
|
||||
return selectProducedFiles(owner)
|
||||
},
|
||||
@@ -99,7 +101,8 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
|
||||
*/
|
||||
export function registerOpenPathInterception(ctx: Context, store: SidebarStore): () => void {
|
||||
return wrapOpenPath(ctx.workspaces, {
|
||||
takeoverEnabled: () => store.getPrefs().interceptOpenPath !== false
|
||||
takeoverEnabled: () => !store.getSuspended()
|
||||
&& store.getPrefs().interceptOpenPath !== false
|
||||
&& store.getPrefs().tabsEnabled['editor'] !== false,
|
||||
currentSessionId: () => ctx.sessions.list.getSnapshot().current,
|
||||
openInSidebar: (path, sessionId) => { openSidebarFile(ctx, store, sessionId, path) },
|
||||
|
||||
@@ -23,6 +23,8 @@ import { toml } from '@codemirror/legacy-modes/mode/toml'
|
||||
import { nginx } from '@codemirror/legacy-modes/mode/nginx'
|
||||
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile'
|
||||
import { properties } from '@codemirror/legacy-modes/mode/properties'
|
||||
import { csharp, kotlin } from '@codemirror/legacy-modes/mode/clike'
|
||||
import { swift } from '@codemirror/legacy-modes/mode/swift'
|
||||
|
||||
/** The lowercased file extension of a path ('' when none). */
|
||||
export function extOf(path: string): string {
|
||||
@@ -48,6 +50,9 @@ export function languageKeyForExt(ext: string): string | null {
|
||||
case 'yaml': case 'yml': return 'yaml'
|
||||
case 'sql': return 'sql'
|
||||
case 'java': return 'java'
|
||||
case 'cs': return 'csharp'
|
||||
case 'kt': case 'kts': return 'kotlin'
|
||||
case 'swift': return 'swift'
|
||||
case 'c': case 'h': return 'c'
|
||||
case 'cc': case 'cpp': case 'cxx': case 'hpp': case 'hh': case 'hxx': return 'cpp'
|
||||
case 'rs': return 'rust'
|
||||
@@ -76,6 +81,9 @@ const FACTORIES: Record<string, () => Language | LanguageSupport> = {
|
||||
yaml: () => yaml(),
|
||||
sql: () => sql(),
|
||||
java: () => java(),
|
||||
csharp: () => StreamLanguage.define(csharp),
|
||||
kotlin: () => StreamLanguage.define(kotlin),
|
||||
swift: () => StreamLanguage.define(swift),
|
||||
c: () => cpp(),
|
||||
cpp: () => cpp(),
|
||||
rust: () => rust(),
|
||||
|
||||
@@ -51,6 +51,25 @@ body[data-dsh-sidebar-dragging] #root > div[data-slot="root"] > div > div:nth-ch
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* DSH 0.1.x gives external settings sections a generic gear and exposes no
|
||||
icon field in the settings.section contract. settings-nav-icon.ts marks
|
||||
only this plugin's localized row; render the requested Lucide
|
||||
gallery-horizontal-end SVG as a currentColor mask so it follows the native
|
||||
nav hover/active colors without changing the shell's 16px icon rhythm. */
|
||||
[data-dsh-better-sidebar-settings-nav] > svg:first-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-dsh-better-sidebar-settings-nav]::before {
|
||||
content: '';
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: currentColor;
|
||||
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 7v10'/%3E%3Cpath d='M6 5v14'/%3E%3Crect width='12' height='18' x='10' y='3' rx='2'/%3E%3C/svg%3E") center / contain no-repeat;
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 7v10'/%3E%3Cpath d='M6 5v14'/%3E%3Crect width='12' height='18' x='10' y='3' rx='2'/%3E%3C/svg%3E") center / contain no-repeat;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#root,
|
||||
#root > div[data-slot="root"] > div > div:nth-child(2) {
|
||||
|
||||
@@ -11,12 +11,28 @@
|
||||
|
||||
/** The zh dictionary (also registered into the DSH locale registry under {@link LOCALE_NS}). */
|
||||
export const zh = {
|
||||
files: '文件',
|
||||
explorer: '资源管理器',
|
||||
git: '源代码管理',
|
||||
terminal: '终端',
|
||||
editor: '编辑器',
|
||||
editorExplorer: '文件打开方式',
|
||||
editorExplorerDesc: '控制文件打开方式',
|
||||
editorExplorerMerged: '合并',
|
||||
editorExplorerMergedDesc: '文件在同一窗口内原地切换;新窗口默认展开文件树',
|
||||
editorExplorerSplit: '独立',
|
||||
editorExplorerSplitDesc: '无路径窗口即资源管理器(仅文件树);文件各自新开窗口(带文件树,默认收起)',
|
||||
editorTreeToggle: '文件树面板',
|
||||
editorPathPlaceholder: '输入文件路径(相对会话目录或绝对路径),Enter 打开',
|
||||
editorSearchPlaceholder: '按文件名搜索…',
|
||||
editorSearchNoResults: '无匹配文件',
|
||||
editorSearchTruncated: '结果过多,仅显示部分匹配',
|
||||
editorEmptyHint: '从右侧文件树或上方路径输入框选择文件开始预览',
|
||||
openFileNewTab: '在新 Tab 中打开',
|
||||
openFileSide: '在侧边打开',
|
||||
newTab: '新建标签页',
|
||||
openExplorer: '资源管理器',
|
||||
brokenSymlink: '失效的软链接',
|
||||
openGit: 'Git 面板',
|
||||
newTerminal: '新终端',
|
||||
terminalLimit: '终端数量已达上限 (3)',
|
||||
@@ -28,6 +44,9 @@ export const zh = {
|
||||
terminalError: '终端连接失败',
|
||||
terminalConnectFailed: '终端多次连接失败',
|
||||
terminalRetry: '重试',
|
||||
terminalDepsFailed: '终端依赖 node-pty 加载失败',
|
||||
terminalDepsHint: '在 DSH 所在环境的终端或 cmd 中执行以下命令修复,然后点重试(node-pty 与 DSH 核心保持同一版本):',
|
||||
terminalDepsProfile: '(检测到 profile:{profile})',
|
||||
preview: '预览',
|
||||
edit: '编辑',
|
||||
refresh: '刷新',
|
||||
@@ -226,18 +245,36 @@ export const zh = {
|
||||
openPlugin: '跳转',
|
||||
copyInstall: '复制安装命令',
|
||||
pluginOfficeDesc: '为 better-sidebar 编辑器提供 Office 三件套预览(.docx / .xlsx / .pptx),把重型 Office 渲染库拆出主包、按需安装',
|
||||
pluginGitRemotesDesc: 'better-sidebar Git 远程 Tab:看分支/上游/ahead-behind,fetch(可 prune)、ff-only pull、确认后才 push。不替换内置 Git 的暂存/提交,也不提供 force-push 或模型自动推送',
|
||||
pluginSentinelDesc: '条件驱动的 agent 唤醒系统:文件/进程/端口/HTTP/命令/webhook 传感器,条件达成自动唤醒休眠会话;注册「哨兵」Tab 展示服务器全局监控表',
|
||||
pluginSidebarQaDesc: '基于 better-sidebar 的划选提问tab分页: 对话划选 → 右侧面板提问 → 同工作区独立追问会话(❓追问·主题):快速无思考模型压缩主对话上下文后与引文一起注入,不打断主对话;追问可嵌套、可继续、可归档',
|
||||
pluginVideoPreviewDesc: '在 better-sidebar 编辑器内联预览视频文件(.mp4/.webm/.mov/.mkv/.avi 等),自带支持 HTTP Range(206)的 /video 宿主路由,可拖动进度条、不受 20MB mediaLimit 限制',
|
||||
}
|
||||
|
||||
/** The en dictionary (key-set-equal to zh, enforced by the type annotation). */
|
||||
export const en: Record<keyof typeof zh, string> = {
|
||||
files: 'Files',
|
||||
explorer: 'Explorer',
|
||||
git: 'Source Control',
|
||||
terminal: 'Terminal',
|
||||
editor: 'Editor',
|
||||
editorExplorer: 'File open behavior',
|
||||
editorExplorerDesc: 'Controls how files open',
|
||||
editorExplorerMerged: 'Merged',
|
||||
editorExplorerMergedDesc: 'Files switch in place in the same window; new windows start with the tree open',
|
||||
editorExplorerSplit: 'Separate',
|
||||
editorExplorerSplitDesc: 'Path-less windows are the standalone explorer (tree only); each file opens its own window (tree docked, closed by default)',
|
||||
editorTreeToggle: 'File tree panel',
|
||||
editorPathPlaceholder: 'File path (relative to the session directory or absolute), Enter to open',
|
||||
editorSearchPlaceholder: 'Search files by name…',
|
||||
editorSearchNoResults: 'No matching files',
|
||||
editorSearchTruncated: 'Too many results — showing a partial list',
|
||||
editorEmptyHint: 'Pick a file from the tree panel or the path input above to start previewing',
|
||||
openFileNewTab: 'Open in New Tab',
|
||||
openFileSide: 'Open to the Side',
|
||||
newTab: 'New tab',
|
||||
openExplorer: 'Explorer',
|
||||
brokenSymlink: 'Broken symlink',
|
||||
openGit: 'Git panel',
|
||||
newTerminal: 'New terminal',
|
||||
terminalLimit: 'Terminal limit reached (3)',
|
||||
@@ -249,6 +286,9 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
terminalError: 'Terminal connection failed',
|
||||
terminalConnectFailed: 'Terminal failed to connect repeatedly',
|
||||
terminalRetry: 'Retry',
|
||||
terminalDepsFailed: 'Terminal dependency node-pty failed to load',
|
||||
terminalDepsHint: 'Run the command below in a terminal or cmd on the DSH machine to repair it, then retry (node-pty stays in sync with the DSH core version):',
|
||||
terminalDepsProfile: ' (detected profile: {profile})',
|
||||
preview: 'Preview',
|
||||
edit: 'Edit',
|
||||
refresh: 'Refresh',
|
||||
@@ -447,8 +487,10 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
openPlugin: 'Open',
|
||||
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',
|
||||
pluginGitRemotesDesc: 'Git Remotes tab: branch/upstream/ahead-behind, fetch (optional prune), ff-only pull, and push only after an in-tab confirm. Does not replace the built-in Git stage/commit tab, and does not offer force-push or a model auto-push tool',
|
||||
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',
|
||||
pluginVideoPreviewDesc: 'Inline video preview (.mp4/.webm/.mov/.mkv/.avi etc.) for the better-sidebar editor, backed by a dedicated /video host route with HTTP Range (206) support — scrubbing works and files are not capped by the 20MB mediaLimit',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,8 +3,27 @@
|
||||
* the session cwd (for the @-reference button and "copy relative path").
|
||||
* The fs-tree joins with '/' even on Windows, so both separators normalize
|
||||
* to '/' before comparison.
|
||||
*
|
||||
* This module is dependency-free (no node:path in the client bundle): the
|
||||
* host is the authority for path semantics, so this mirror deliberately
|
||||
* accepts a SUPERSET of absolute forms — anything a Windows host would emit
|
||||
* (drive letters, UNC) plus POSIX roots. A form the host would reject
|
||||
* (e.g. a backslash UNC path on a POSIX host) passes through here and then
|
||||
* fails loudly in the host's requireAbsolute instead of being silently
|
||||
* joined onto the cwd.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Mirror of the host's absolute-path notion (see fs-tree.requireAbsolute):
|
||||
* POSIX roots, Windows drive letters, and Windows UNC network shares in
|
||||
* both backslash (`\\server\share\...`) and forward-slash
|
||||
* (`//server/share/...`) form. Deliberately a superset — see the module
|
||||
* comment — so a produced UNC path is never joined onto the cwd.
|
||||
*/
|
||||
export function isAbsolutePath(path: string): boolean {
|
||||
return path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path) || /^[\\/]{2}[^\\/]/.test(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* The path relative to the session's working directory.
|
||||
* @param cwd - the explorer root (absolute).
|
||||
|
||||
@@ -24,6 +24,13 @@ export const builtinTabPlugins: readonly PluginEntry[] = [
|
||||
// context consistent with the other entries.
|
||||
install: 'cd ~/.dsh && dsh plugin --profile web add "github:fuhefei/dsh-sentinel#v0.7.0"',
|
||||
},
|
||||
{
|
||||
id: 'dsh-git-remotes',
|
||||
name: 'dsh-git-remotes Git 远程',
|
||||
url: 'https://github.com/yq04/dsh-git-remotes',
|
||||
description: () => t('pluginGitRemotesDesc'),
|
||||
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-better-sidebar && dsh plugin --profile web add git+https://github.com/yq04/dsh-git-remotes.git',
|
||||
},
|
||||
{
|
||||
id: 'dsh-sidebar-qa',
|
||||
name: 'dsh-sidebar-qa 划选追问',
|
||||
|
||||
@@ -20,4 +20,11 @@ export const builtinViewerPlugins: readonly PluginEntry[] = [
|
||||
description: () => t('pluginOfficeDesc'),
|
||||
install: 'cd ~/.dsh && dsh plugin --profile web add @huanlin/dsh-plugin-better-sidebar-plugin-office',
|
||||
},
|
||||
{
|
||||
id: 'dsh-video-preview',
|
||||
name: '视频预览插件',
|
||||
url: 'https://github.com/zemul/dsh-video-preview',
|
||||
description: () => t('pluginVideoPreviewDesc'),
|
||||
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-video-preview',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -60,6 +60,9 @@ export function parsePrefs(value: unknown): SidebarPrefs {
|
||||
interceptOpenPath: typeof record.interceptOpenPath === 'boolean'
|
||||
? record.interceptOpenPath
|
||||
: SIDEBAR_PREFS_DEFAULTS.interceptOpenPath,
|
||||
editorExplorer: typeof record.editorExplorer === 'boolean'
|
||||
? record.editorExplorer
|
||||
: SIDEBAR_PREFS_DEFAULTS.editorExplorer,
|
||||
titleBarCompat: typeof record.titleBarCompat === 'boolean'
|
||||
? record.titleBarCompat
|
||||
: SIDEBAR_PREFS_DEFAULTS.titleBarCompat,
|
||||
@@ -136,3 +139,22 @@ export async function loadPrefs(settings: SidebarSettingsClient): Promise<Sideba
|
||||
return { ...SIDEBAR_PREFS_DEFAULTS }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the external-disable flag from the same settings route: the
|
||||
* dsh-web-ui family's aionui-panel provider choice. True only when the host
|
||||
* resolved `aionui-panel.rightPanel` to 'aionui-panel' — while true the
|
||||
* sidebar must not mount (the two right panels are mutually exclusive). Any
|
||||
* failure (route rejected, aionui absent, malformed response) reads false,
|
||||
* so a missing family never hides the sidebar.
|
||||
* @param settings - the settings wire face (the plugin api by default).
|
||||
* @returns the external-disable flag (false on any failure).
|
||||
*/
|
||||
export async function loadExternalDisable(settings: SidebarSettingsClient): Promise<boolean> {
|
||||
try {
|
||||
const view = await settings.settingsGet()
|
||||
return view.externalDisable === true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* Kept dependency-free so the takeover logic is unit-testable and the
|
||||
* replica is easy to diff against upstream when it drifts.
|
||||
*/
|
||||
import { isAbsolutePath } from './paths.ts'
|
||||
|
||||
/** Paths a tool-result view reports as produced, by render intent. */
|
||||
export function producedPaths(view: unknown): readonly string[] {
|
||||
@@ -76,10 +77,13 @@ export function selectProducedFiles(owner: unknown): readonly string[] | null {
|
||||
return paths.length === 0 ? null : paths
|
||||
}
|
||||
|
||||
/** Resolve a (possibly relative) path against the session cwd for the sidebar. */
|
||||
/**
|
||||
* Resolve a (possibly relative) path against the session cwd for the sidebar.
|
||||
* Absolute detection mirrors the host (see client/paths.isAbsolutePath):
|
||||
* POSIX roots, drive letters and UNC shares must not be joined onto the cwd.
|
||||
*/
|
||||
export function resolveSidebarPath(cwd: string | undefined, path: string): string {
|
||||
const absolute = path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path)
|
||||
if (absolute) return path
|
||||
if (isAbsolutePath(path)) return path
|
||||
const base = cwd ?? ''
|
||||
if (base === '') return path
|
||||
const separator = base.includes('\\') ? '\\' : '/'
|
||||
|
||||
+55
-3
@@ -48,14 +48,32 @@ export type { SessionScope } from './api.ts'
|
||||
export type { SidebarPrefs } from '../prefs-shared.ts'
|
||||
|
||||
/** The row control a declarative setting renders as in the settings popup. */
|
||||
export type SidebarSettingToggleType = 'switch' | 'text' | 'number'
|
||||
export type SidebarSettingToggleType = 'switch' | 'text' | 'number' | 'select'
|
||||
|
||||
/** One option of a `type: 'select'` setting row. */
|
||||
export interface SidebarSettingSelectOption {
|
||||
/** The value written to the setting key when this option is picked
|
||||
* (JSON-serializable: string / number / boolean). */
|
||||
value: string | number | boolean
|
||||
/** Option title (i18n friendly: string or () => string). */
|
||||
title: string | (() => string)
|
||||
/** Option description (i18n friendly); rendered under the title in the
|
||||
* icon dropdown. */
|
||||
desc?: string | (() => string)
|
||||
/** Option icon: when ANY option declares one, the dropdown renders
|
||||
* big-icon option cards and the closed control shows the selected
|
||||
* option's icon too; without icons both are a single line of text. */
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
}
|
||||
|
||||
/** One declarative setting of a tab/viewer, rendered as a nested row in the
|
||||
* Side card settings page (e.g. the Subagent page's "auto-open when a
|
||||
* subagent appears" switch, or the terminal's custom font rows). `type`
|
||||
* selects the control: 'switch' (default) renders the custom switch,
|
||||
* 'text' a free-form input committed on blur/Enter, 'number' a numeric
|
||||
* input clamped to `min`/`max`. */
|
||||
* input clamped to `min`/`max`, 'select' a dropdown over the declared
|
||||
* `options` (single-pick writes the option's value; `multi: true` writes
|
||||
* the array of picked values and defaults to false). */
|
||||
export interface SidebarSettingToggle {
|
||||
/** The SidebarPrefs field this toggle reads and writes ('autoOpenSubagent'). */
|
||||
key: string
|
||||
@@ -73,6 +91,11 @@ export interface SidebarSettingToggle {
|
||||
placeholder?: string
|
||||
/** Unit suffix rendered after the input (e.g. 'px' for a size row). */
|
||||
unit?: string
|
||||
/** Options of a `type: 'select'` row. */
|
||||
options?: readonly SidebarSettingSelectOption[]
|
||||
/** Whether a `type: 'select'` row allows picking several options (the
|
||||
* stored value is then an array of option values); defaults to false. */
|
||||
multi?: boolean
|
||||
}
|
||||
|
||||
/** Props of a descriptor's custom settings panel (`settings.render`). */
|
||||
@@ -240,6 +263,33 @@ export interface FileViewerProps {
|
||||
mediaUrl?: string
|
||||
/** custom load() return value (fetchStrategy='custom'). */
|
||||
customData?: unknown
|
||||
/** Internal (built-in text editor): 'host' asks the viewer to skip its own
|
||||
* toolbar row — the editor host's merged-mode header renders it instead,
|
||||
* fed through the two callbacks below. Viewers that ignore these fields
|
||||
* render exactly as before. */
|
||||
toolbar?: 'self' | 'host'
|
||||
/** Internal: the viewer reports its toolbar state (mode/dirty/save). */
|
||||
onToolbarState?: (state: EditorToolbarState) => void
|
||||
/** Internal: the viewer registers its toolbar commands on mount (null on
|
||||
* unmount). */
|
||||
onToolbarControls?: (controls: EditorToolbarControls | null) => void
|
||||
}
|
||||
|
||||
/** The toolbar state a text editor reports to the host's merged-mode header. */
|
||||
export interface EditorToolbarState {
|
||||
/** Whether the preview/edit mode toggle applies (markdown/html). */
|
||||
modes: boolean
|
||||
mode: 'preview' | 'edit'
|
||||
dirty: boolean
|
||||
/** Whether saving applies (text content loaded). */
|
||||
editable: boolean
|
||||
saveState: 'idle' | 'saving' | 'saved' | 'failed'
|
||||
}
|
||||
|
||||
/** The commands the host's merged-mode header sends back to the viewer. */
|
||||
export interface EditorToolbarControls {
|
||||
setMode(mode: 'preview' | 'edit'): void
|
||||
save(): void
|
||||
}
|
||||
|
||||
/** Describes one file previewer (builtins register themselves too). */
|
||||
@@ -420,7 +470,7 @@ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDes
|
||||
* The plugin version this service instance reports. Keep in lockstep with
|
||||
* `package.json`'s version — `tests/service.spec.ts` asserts the pair.
|
||||
*/
|
||||
export const SIDEBAR_SERVICE_VERSION = '0.12.2'
|
||||
export const SIDEBAR_SERVICE_VERSION = '0.13.0'
|
||||
|
||||
/**
|
||||
* Monotonic capability list consumers use to gate new API usage (features
|
||||
@@ -434,6 +484,7 @@ export const SIDEBAR_SERVICE_VERSION = '0.12.2'
|
||||
* - 'tabMeta': SidebarTab.meta (seeds, createTab, updateTab, persistence)
|
||||
* - 'pluginSettings': SidebarSettingsDeclaration.pluginToggles/render
|
||||
* - 'urlTarget' (v0.13.0): TabDescriptor.urlTarget (external-link claims)
|
||||
* - 'settingSelect': SidebarSettingToggle type 'select' (options/multi)
|
||||
*/
|
||||
export const SIDEBAR_FEATURES = [
|
||||
'badge',
|
||||
@@ -445,6 +496,7 @@ export const SIDEBAR_FEATURES = [
|
||||
'tabMeta',
|
||||
'pluginSettings',
|
||||
'urlTarget',
|
||||
'settingSelect',
|
||||
] as const
|
||||
|
||||
/** Run one plugin callback; a throw is logged and never breaks the caller. */
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Mark this plugin's row in the DSH settings navigation so its bundled CSS
|
||||
* can replace the shell's fallback gear with the Side card glyph.
|
||||
*
|
||||
* DSH 0.1.x projects only `id`, `order`, and `label` from a
|
||||
* `settings.section` registration, then chooses icons inside the settings
|
||||
* shell from a closed list of built-in ids. Until that public contract grows
|
||||
* an icon field, the plugin identifies only its own localized row after the
|
||||
* dialog mounts. The marker owns no shell structure and is removed on fiber
|
||||
* disposal, so the adaptation remains HMR-safe.
|
||||
*/
|
||||
|
||||
export const SETTINGS_NAV_MARKER = 'data-dsh-better-sidebar-settings-nav'
|
||||
|
||||
/**
|
||||
* Keep the marker on the settings-nav button whose visible text is this
|
||||
* plugin's current localized section label.
|
||||
* @param label - locale-aware label resolver used by the section registration.
|
||||
* @returns disposer that disconnects observation and removes owned markers.
|
||||
*/
|
||||
export function registerSettingsNavIcon(label: () => string): () => void {
|
||||
let disposed = false
|
||||
|
||||
const sync = (): void => {
|
||||
if (disposed) return
|
||||
const currentLabel = label().trim()
|
||||
const buttons = document.querySelectorAll<HTMLButtonElement>('[role="dialog"] nav button')
|
||||
for (const button of buttons) {
|
||||
const matches = currentLabel.length > 0 && button.textContent?.trim() === currentLabel
|
||||
if (matches) button.setAttribute(SETTINGS_NAV_MARKER, '')
|
||||
else button.removeAttribute(SETTINGS_NAV_MARKER)
|
||||
}
|
||||
}
|
||||
|
||||
sync()
|
||||
const observer = new MutationObserver(sync)
|
||||
observer.observe(document.body, { childList: true, subtree: true, characterData: true })
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
observer.disconnect()
|
||||
document.querySelectorAll(`[${SETTINGS_NAV_MARKER}]`)
|
||||
.forEach(element => { element.removeAttribute(SETTINGS_NAV_MARKER) })
|
||||
}
|
||||
}
|
||||
+257
-13
@@ -10,16 +10,29 @@
|
||||
|
||||
/* ── Shell ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Skinning contract (token-driven): every surface here consumes DSH's
|
||||
generic tokens (`--dsw-alias-*`), NEVER `--dsw-specific-sidebar-fill` —
|
||||
skin systems override that token to restyle the APP's own left-nav column
|
||||
(dsh-web-ui skins make it translucent glass or theme colors, Aqua makes it
|
||||
transparent), and a panel consuming it would lose its fill or clash with
|
||||
the label tokens. The panels use the generic card surface
|
||||
`--dsw-alias-bg-layer-1`, which every dsh-web-ui skin overrides — so
|
||||
switching skins in the dsh-web-ui skin center re-skins the sidebar panels
|
||||
automatically (see AGENTS.md §8). */
|
||||
|
||||
/* The persistent expand/collapse cluster at the top-right corner: two rail
|
||||
controls side by side (bottom panel's glyph LEFT of the right panel's).
|
||||
Always pinned to the viewport corner — inside the right panel's top-right
|
||||
while it is open, sitting flush in the 34px tab strip (28px buttons at
|
||||
top: 3) whose right end it really squeezes. */
|
||||
top: 3) whose right end it really squeezes. z-index 45: above the panels
|
||||
(40) but below the app's overlay stack (menus/tooltips/modals sit at
|
||||
100+), so a cordis approval popup is never hidden behind the workbench
|
||||
(issue #52). */
|
||||
.toggleCluster {
|
||||
position: fixed;
|
||||
top: 3px;
|
||||
right: 10px;
|
||||
z-index: 55;
|
||||
z-index: 45;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 4px;
|
||||
@@ -70,12 +83,18 @@
|
||||
/* Full height: the bottom panel squeezes only the center column, so the
|
||||
right panel never gives up any vertical position. */
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
/* z-index 40: above the app's in-flow content, below the app's overlay
|
||||
stack (100+), so menus/tooltips/modals always win over the panels
|
||||
(issue #52). The toggle cluster (45) sits above it; the corner handle
|
||||
is a child and stacks within the panel. */
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* The native sidebar surface: bluish-50 over white / bluish-900 over dark,
|
||||
exactly like the app's own sidebar column (SidebarRoot .root). */
|
||||
background: var(--dsw-specific-sidebar-fill);
|
||||
/* The generic card surface (see the root contract above). Never
|
||||
`--dsw-specific-sidebar-fill` — skin plugins restyle that token for the
|
||||
app's own left nav and a panel consuming it loses its fill or clashes
|
||||
with labels (#60/#105/#106). */
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
border-left: 1px solid var(--dsw-alias-border-l2);
|
||||
/* Expand/collapse slides the panel; fullscreen animates the width. */
|
||||
transition:
|
||||
@@ -101,6 +120,8 @@
|
||||
|
||||
.panelResize {
|
||||
position: absolute;
|
||||
/* Centered on the panel edge (an 8px hit strip straddling the border, the
|
||||
same pattern as the host's own frame handles). */
|
||||
left: -4px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
@@ -127,14 +148,16 @@
|
||||
column only (the Sidebar shell writes left/right inline: from the app's
|
||||
own left sidebar to the right panel's left edge) — the sidebars never
|
||||
give up any position. Same surface as the right panel, with a top
|
||||
hairline instead of the left one. */
|
||||
hairline instead of the left one. z-index 40 mirrors the right panel
|
||||
(issue #52: below the app's overlay stack so popups win over the
|
||||
workbench). */
|
||||
.bottomPanel {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--dsw-specific-sidebar-fill);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
border-top: 1px solid var(--dsw-alias-border-l2);
|
||||
/* Expand/collapse slides the panel; height drags animate the height. */
|
||||
transition:
|
||||
@@ -159,7 +182,7 @@
|
||||
}
|
||||
|
||||
/* The bottom panel's height drag: the top edge strip (mirror of the right
|
||||
panel's width strip). */
|
||||
panel's width strip), centered on the border like the width strip. */
|
||||
.bottomResize {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
@@ -235,12 +258,18 @@
|
||||
/* The shared corner (both panels open): the intersection of the right
|
||||
panel's left edge and the bottom panel's top edge. Horizontal drags
|
||||
resize the width, vertical drags the height — the two panels drag
|
||||
against each other. */
|
||||
against each other. Rendered INSIDE the right panel and positioned
|
||||
relative to it (left edge + the bottom panel's height from the layout
|
||||
variable), so no JS-written viewport coordinates are involved. The
|
||||
z-index is panel-local (above the panel's content; the panel itself sits
|
||||
at 40, below the app's overlay stack). */
|
||||
.cornerHandle {
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
left: -6px;
|
||||
bottom: calc(var(--dsh-sidebar-height, 0px) + 6px);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
z-index: 52;
|
||||
z-index: 2;
|
||||
cursor: nwse-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
@@ -637,6 +666,8 @@
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
/* Deep trees truncate their labels instead of scrolling horizontally. */
|
||||
overflow-x: hidden;
|
||||
padding: 2px 6px 8px;
|
||||
}
|
||||
|
||||
@@ -648,7 +679,12 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
/* border-box keeps the inline indent padding (depth * 22 + 6) INSIDE the
|
||||
row's 100% width — without it deep rows overflow and force a horizontal
|
||||
scrollbar on the tree body. */
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 34px;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
@@ -674,9 +710,25 @@
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* The link badge on symlink rows (folder and file rows alike). */
|
||||
.explorerSymlink {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* A symlink whose target is missing: error ink on the name (the row stays
|
||||
clickable — the editor surfaces the read failure). */
|
||||
.explorerBroken .explorerName {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.explorerName {
|
||||
/* min-width: 0 lets the flex item shrink below its content so the label
|
||||
truncates with an ellipsis instead of widening the row. */
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The hover-revealed @-reference button at the row's far right (and the
|
||||
@@ -758,6 +810,139 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Merged mode (the editorExplorer pref): the header's path input replaces
|
||||
the plain title, and a toggleable file tree docks at the tab's right edge. */
|
||||
.editorPathInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: var(--dsw-font-xxs-12);
|
||||
}
|
||||
|
||||
.editorPathInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
/* The tree-panel toggle in the header: active = panel open (the selected
|
||||
fill of the tab strip). */
|
||||
.editorTreeToggleActive {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
}
|
||||
|
||||
.editorBody {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.editorMain {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* The docked file tree: the wrapper owns the (drag-adjustable) width and
|
||||
the hairline separation; the resize handle is a wider invisible strip on
|
||||
its left edge with a hover affordance. */
|
||||
.editorTreeDock {
|
||||
position: relative;
|
||||
flex: none;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
border-left: 1px solid var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
.editorTreeResize {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
/* Inside the dock (left edge): pointer capture on the handle keeps the
|
||||
drag alive even when the pointer leaves the strip. */
|
||||
left: 0;
|
||||
width: 6px;
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.editorTreeResize:hover {
|
||||
background: var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.editorTreePanel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* The tree panel's full-window form (the `full` prop): fill the tab body. */
|
||||
.editorTreePanelFull {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.editorTreeSearch {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
.editorSearchInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: var(--dsw-font-xxs-12);
|
||||
}
|
||||
|
||||
.editorSearchInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.editorSearchHint {
|
||||
padding: 8px 12px;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.editorSearchResult {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: var(--dsw-font-xxs-12);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.editorSearchResult:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.editorStatus {
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
@@ -1264,6 +1449,65 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* The node-pty dependency failure banner (issue #140): a taller warn block
|
||||
carrying the pasteable repair command (bash / cmd / PowerShell). */
|
||||
.terminalDepsBanner {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
}
|
||||
|
||||
.terminalDepsTitle {
|
||||
font: var(--dsw-font-xxs-strong-12);
|
||||
color: var(--dsw-alias-state-warn-primary);
|
||||
}
|
||||
|
||||
.terminalDepsHint {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.terminalDepsCommandRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* The command the user pastes into a terminal / cmd. Selectable, wrapping,
|
||||
and scrollable when long (Windows paths can get very long). */
|
||||
.terminalRepairCommand {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 6px 8px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
user-select: text;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 4px;
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.terminalDepsNote {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.terminalDepsActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* The PER-TAB error strip: fills one tab's content area (unlike
|
||||
.boundaryError, the full-height fixed rail for shell-level crashes), so a
|
||||
crashing viewer/editor never hides the toggle cluster or the other tabs. */
|
||||
|
||||
+69
-18
@@ -13,8 +13,8 @@ import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts'
|
||||
import { isNarrowWidth } from './breakpoints.ts'
|
||||
|
||||
/**
|
||||
* Tab type identifier. Builtins register their ids (explorer / git / editor
|
||||
* / terminal / subagent / diff) through the sidebar service; external
|
||||
* Tab type identifier. Builtins register their ids (editor / git / terminal
|
||||
* / subagent / browser / diff) through the sidebar service; external
|
||||
* plugins register their own (e.g. `'my-plugin:db'`). Kept as `string` so
|
||||
* the registry stays open.
|
||||
*/
|
||||
@@ -25,7 +25,7 @@ export type SidebarDiffRef =
|
||||
| { kind: 'worktree'; path: string; staged: boolean; untracked?: boolean }
|
||||
| { kind: 'commit'; hash: string; hashFull: string; subject: string }
|
||||
|
||||
/** One open tab. `path` carries the file (editor) or is absent (explorer/git);
|
||||
/** One open tab. `path` carries the file (editor) or is absent (git/terminal);
|
||||
* `diff` carries the change a diff tab shows; `meta` (v0.12.0+) carries
|
||||
* plugin-owned JSON-serializable state, preserved across reloads. */
|
||||
export interface SidebarTab {
|
||||
@@ -105,6 +105,13 @@ function uid(prefix: string): string {
|
||||
return `${prefix}:${nextIdCounter}`
|
||||
}
|
||||
|
||||
/** Mint a fresh uid-based tab id. The `'editor:' + path` convention only
|
||||
* covers openSidebarFile opens (per-path dedupe); opens that must not
|
||||
* dedupe (the tree's "open to the side") mint through here. */
|
||||
export function mintTabId(): string {
|
||||
return uid('tab')
|
||||
}
|
||||
|
||||
/**
|
||||
* The largest numeric suffix across a raw persisted state's counter ids
|
||||
* (`pane:N` / `tab:N` / `split:N`). The uid counter is module-global and
|
||||
@@ -139,17 +146,24 @@ function maxCounterId(parsed: unknown): number {
|
||||
return max
|
||||
}
|
||||
|
||||
/** A fresh default state: one explorer tab in one pane, open per the caller's
|
||||
/** The default tab a fresh session seeds. */
|
||||
export type DefaultSeed = 'editor-home' | 'none'
|
||||
|
||||
/** A fresh default state: one seeded tab in one pane, open per the caller's
|
||||
* preference. `width` is the caller's preferred panel width (default
|
||||
* PANEL_DEFAULT) and `panelOpen` whether the panel starts expanded (default
|
||||
* true); the store seeds new sessions from the user's side card prefs.
|
||||
* `seedExplorer` places the default explorer tab — the store passes false
|
||||
* when the user disabled the explorer tab type in settings, so a fresh
|
||||
* session starts with an empty pane instead of a tab they turned off. */
|
||||
export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seedExplorer = true): SidebarState {
|
||||
* `seed` picks the seeded tab: 'editor-home' places the EMPTY files window
|
||||
* (an editor tab with no path whose tree panel starts open,
|
||||
* `meta.treeOpen: true`) — in BOTH editorExplorer modes that window is the
|
||||
* file explorer page — and 'none' starts with an empty pane (the store
|
||||
* passes it when the user disabled the editor tab type in settings). */
|
||||
export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seed: DefaultSeed = 'editor-home'): SidebarState {
|
||||
const leaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
|
||||
if (seedExplorer) {
|
||||
leaf.tabs = [{ id: uid('tab'), type: 'explorer', title: 'Explorer' }]
|
||||
if (seed === 'editor-home') {
|
||||
// No path: the editor host renders its empty-state hint and the docked
|
||||
// tree panel (treeOpen defaults open for path-less tabs; meta pins it).
|
||||
leaf.tabs = [{ id: uid('tab'), type: 'editor', title: 'Files', meta: { treeOpen: true } }]
|
||||
leaf.active = leaf.tabs[0]!.id
|
||||
}
|
||||
// The bottom panel starts closed with an empty pane (its welcome cards
|
||||
@@ -764,19 +778,21 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
|
||||
// New sessions seed from the user's side card prefs: the width is the
|
||||
// chosen percent of the window (clamped to the panel floor and the
|
||||
// viewport so a huge percent can never crush the app shell), the panel
|
||||
// starts open only when the preference says so, and the default explorer
|
||||
// tab is skipped when the user disabled the explorer tab type. On a
|
||||
// NARROW viewport a brand-new session starts collapsed instead — the
|
||||
// panel is a full-screen drawer there, and auto-opening it on first
|
||||
// paint would cover the conversation before the user asked. Only the
|
||||
// first seeding is affected: once the user expands the drawer,
|
||||
// `panelOpen: true` persists like any other state.
|
||||
// starts open only when the preference says so, and the seed tab is the
|
||||
// empty files window (tree panel open) in BOTH editorExplorer modes — a
|
||||
// disabled editor type seeds nothing. On a NARROW viewport a brand-new
|
||||
// session starts collapsed instead — the panel is a full-screen drawer
|
||||
// there, and auto-opening it on first paint would cover the conversation
|
||||
// before the user asked. Only the first seeding is affected: once the
|
||||
// user expands the drawer, `panelOpen: true` persists like any other
|
||||
// state.
|
||||
const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
|
||||
const width = viewport === undefined
|
||||
? PANEL_DEFAULT
|
||||
: defaultWidthFor(viewport, prefs.defaultWidthPercent)
|
||||
const openByDefault = prefs.openByDefault && (viewport === undefined || !isNarrowWidth(viewport))
|
||||
return makeDefaultState(width, openByDefault, prefs.tabsEnabled['explorer'] !== false)
|
||||
const seed: DefaultSeed = prefs.tabsEnabled['editor'] === false ? 'none' : 'editor-home'
|
||||
return makeDefaultState(width, openByDefault, seed)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -891,6 +907,21 @@ function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string
|
||||
// accept any string type here — an unregistered type renders an
|
||||
// <OrphanedTab/> at view time and recovers if its plugin loads later.
|
||||
if (typeof candidate.type !== 'string') return undefined
|
||||
// The standalone explorer tab type merged INTO the editor (the single
|
||||
// files window): a persisted explorer tab reopens as an editor home
|
||||
// tab — no path, tree panel open (an existing meta object survives).
|
||||
if (candidate.type === 'explorer') {
|
||||
const meta = candidate.meta !== null && typeof candidate.meta === 'object' && !Array.isArray(candidate.meta)
|
||||
? candidate.meta as Record<string, unknown>
|
||||
: undefined
|
||||
tabs.push({
|
||||
id: candidate.id,
|
||||
type: 'editor',
|
||||
title: 'Files',
|
||||
meta: { treeOpen: true, ...meta },
|
||||
})
|
||||
continue
|
||||
}
|
||||
// `meta` is plugin-owned JSON-serializable state (v0.12.0+): the
|
||||
// persisted value already went through JSON.parse, so it is inherently
|
||||
// serializable — carry it through verbatim (absent on older states).
|
||||
@@ -943,6 +974,26 @@ export class SidebarStore {
|
||||
private readonly persistTimers = new Map<string, number>()
|
||||
/** User-facing side card prefs seeding brand-new session states (defaults until the settings RPC resolves). */
|
||||
private prefs: SidebarPrefs = { ...SIDEBAR_PREFS_DEFAULTS }
|
||||
/**
|
||||
* External disable (the dsh-web-ui family's aionui-panel provider choice):
|
||||
* while true the sidebar must not mount at all. Not part of the snapshot —
|
||||
* nothing renders on it; the mount gate and the intercept predicates read
|
||||
* it directly.
|
||||
*/
|
||||
private suspended = false
|
||||
|
||||
/**
|
||||
* Set the external-disable flag (from the settings route) and remember it
|
||||
* for the mount gate and the intercept predicates.
|
||||
*/
|
||||
setSuspended(suspended: boolean): void {
|
||||
this.suspended = suspended
|
||||
}
|
||||
|
||||
/** Whether the sidebar is externally disabled (aionui-panel chosen). */
|
||||
getSuspended(): boolean {
|
||||
return this.suspended
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the side card prefs (the settings RPC result / settings page
|
||||
|
||||
@@ -30,6 +30,72 @@ export function tokenValue(name: string): string {
|
||||
return getComputedStyle(document.body).getPropertyValue(name).trim()
|
||||
}
|
||||
|
||||
/** Minimal alpha for a token color to count as effectively opaque. Skin
|
||||
* systems turn `--dsw-alias-bg-base` translucent for glass panels (the
|
||||
* dsh-web-ui skins use rgba 0.16–0.7; `transparent` is 0); below this
|
||||
* floor a text surface (terminal, editor) would render over the skin's
|
||||
* backdrop art, so callers fall back to an opaque color. Values at or
|
||||
* above the floor (e.g. a skin's scoped 0.96 porcelain) pass through —
|
||||
* the skin still controls the surface. */
|
||||
const OPAQUE_ALPHA_MIN = 0.9
|
||||
|
||||
/** The alpha channel of a computed CSS color, or null when the format is
|
||||
* not parseable (named colors, `color()`… — treated as opaque). Handles
|
||||
* the shapes getComputedStyle actually returns: the rgb()/rgba() and
|
||||
* hsl()/hsla() function forms (comma or space syntax, with or without the
|
||||
* `/ alpha` slot) and the #rgb/#rgba/#rrggbb/#rrggbbaa hex family. */
|
||||
export function colorAlpha(color: string): number | null {
|
||||
const s = color.trim()
|
||||
// #rgb / #rgba / #rrggbb / #rrggbbaa
|
||||
const hex = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(s)
|
||||
if (hex !== null) {
|
||||
const digits = hex[1]!
|
||||
if (digits.length === 3 || digits.length === 4) {
|
||||
const a = digits.length === 4 ? digits[3]! : 'f'
|
||||
return parseInt(a + a, 16) / 255
|
||||
}
|
||||
const alphaHex = digits.length === 8 ? digits.slice(6) : 'ff'
|
||||
return parseInt(alphaHex, 16) / 255
|
||||
}
|
||||
// rgb(r g b) / rgb(r g b / a) / rgba(r, g, b, a) — the trailing slot is alpha.
|
||||
const fn = /^(rgba?|hsla?)\(([^)]+)\)$/i.exec(s)
|
||||
if (fn !== null) {
|
||||
const parts = fn[2]!.split(/[,\s/]+/).filter(Boolean)
|
||||
const alphaPart = parts[3]
|
||||
if (alphaPart === undefined) return 1
|
||||
const alpha = Number.parseFloat(alphaPart)
|
||||
return Number.isFinite(alpha) ? alpha : 1
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* A token value that actually PAINTS something — the guard for text
|
||||
* surfaces (issue #90). Skin systems routinely set global tokens to
|
||||
* `transparent` (glass skins) or translucent glass values (`rgba(…,0.16–0.7)`,
|
||||
* e.g. the dsh-web-ui skins) — both are truthy strings, so callers using
|
||||
* `|| fallback` never fire and the terminal/editor goes see-through over
|
||||
* the skin's backdrop. This returns '' for visually inert values (unset
|
||||
* keywords, transparent, and any color below the opacity floor) so the
|
||||
* caller's fallback chain engages; effectively opaque values pass through.
|
||||
*/
|
||||
export function effectiveTokenValue(name: string): string {
|
||||
const raw = tokenValue(name)
|
||||
switch (raw) {
|
||||
case '':
|
||||
case 'transparent':
|
||||
case 'initial':
|
||||
case 'inherit':
|
||||
case 'unset':
|
||||
return ''
|
||||
default: {
|
||||
const alpha = colorAlpha(raw)
|
||||
if (alpha !== null && alpha < OPAQUE_ALPHA_MIN) return ''
|
||||
return raw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to color-scheme flips (the presenter toggles the body
|
||||
* attribute). The callback fires after the attribute changed; re-read the
|
||||
|
||||
+13
-1
@@ -48,6 +48,14 @@ export interface SidebarConfig {
|
||||
terminalsPerSession?: number
|
||||
/** How long a disconnected terminal process survives awaiting a reconnect. */
|
||||
reconnectGraceMs?: number
|
||||
/**
|
||||
* Terminal shell (absolute path or bare executable name) for BOTH the UI
|
||||
* terminal tabs and the model-facing `terminal_*` tools. Empty = auto:
|
||||
* POSIX follows `$SHELL` then the account login shell; Windows follows
|
||||
* `DSH_SIDEBAR_SHELL`, then probes for `pwsh.exe`, then falls back to the
|
||||
* inbox `powershell.exe` (5.1).
|
||||
*/
|
||||
shell?: string
|
||||
}
|
||||
|
||||
/** Schemastery schema for the plugin configuration. */
|
||||
@@ -57,6 +65,7 @@ export const Config: z<SidebarConfig> = z.object({
|
||||
listLimit: z.number().step(1).min(1).default(1000),
|
||||
terminalsPerSession: z.number().step(1).min(1).default(3),
|
||||
reconnectGraceMs: z.number().step(1).min(0).default(30_000),
|
||||
shell: z.string().default(''),
|
||||
})
|
||||
|
||||
/** Fully defaulted sidebar host settings. */
|
||||
@@ -66,6 +75,7 @@ export interface ResolvedSidebarConfig {
|
||||
listLimit: number
|
||||
terminalsPerSession: number
|
||||
reconnectGraceMs: number
|
||||
shell: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,6 +91,7 @@ export function resolveSidebarConfig(config: SidebarConfig | undefined): Resolve
|
||||
listLimit: config?.listLimit ?? 1000,
|
||||
terminalsPerSession: config?.terminalsPerSession ?? 3,
|
||||
reconnectGraceMs: config?.reconnectGraceMs ?? 30_000,
|
||||
shell: config?.shell?.trim() ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +99,7 @@ export function resolveSidebarConfig(config: SidebarConfig | undefined): Resolve
|
||||
|
||||
/** Schemastery schema for the user-facing preferences (validated by the settings service). */
|
||||
export const PrefsSchema: z<SidebarPrefs> = z.object({
|
||||
openByDefault: z.boolean().default(true),
|
||||
openByDefault: z.boolean().default(false),
|
||||
defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT),
|
||||
autoOpenSubagent: z.boolean().default(true),
|
||||
autoOpenJobs: z.boolean().default(true),
|
||||
@@ -97,6 +108,7 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
|
||||
terminalFontFamily: z.string().default(''),
|
||||
terminalFontSize: z.number().step(1).min(TERMINAL_FONT_SIZE_MIN).max(TERMINAL_FONT_SIZE_MAX).default(TERMINAL_FONT_SIZE_DEFAULT),
|
||||
interceptOpenPath: z.boolean().default(true),
|
||||
editorExplorer: 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),
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Recursive file-name search for the editor's merged-mode side panel.
|
||||
* Streams the tree with opendir and matches the query as a case-insensitive
|
||||
* substring of each entry's NAME (paths stay relative to the search root —
|
||||
* the client resolves them against the session cwd). No .gitignore semantics
|
||||
* (this is a name lookup, not a code search), but `.git` directories are
|
||||
* skipped outright (VCS internals are never useful results) and symlink
|
||||
* directories are NOT descended (cycle safety).
|
||||
*
|
||||
* Two performance budgets bound the walk: `maxMatches` (the client renders
|
||||
* the flat list) and `maxVisited` (a runaway tree — a home directory root,
|
||||
* a node_modules forest — must not stall the host). Exceeding either stops
|
||||
* early with `truncated: true`.
|
||||
*/
|
||||
import { opendir } from 'node:fs/promises'
|
||||
import { join, relative, sep } from 'node:path'
|
||||
|
||||
/** One search: the relative paths of the matching entries (dirs included so
|
||||
* the client can hint where matches live) plus the truncation flag. */
|
||||
export interface FsSearchResult {
|
||||
matches: string[]
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/** Search budgets (both injectable for tests). */
|
||||
export interface FsSearchOptions {
|
||||
/** Row cap of the result list (default 200). */
|
||||
maxMatches?: number
|
||||
/** Total entries visited before the walk gives up (default 100_000). */
|
||||
maxVisited?: number
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_MATCHES = 200
|
||||
const DEFAULT_MAX_VISITED = 100_000
|
||||
|
||||
/**
|
||||
* Search `root` recursively for entries whose name contains `query`
|
||||
* (case-insensitive).
|
||||
* @param root - absolute search root.
|
||||
* @param query - the name substring; empty matches nothing.
|
||||
* @param opts - budget overrides (tests).
|
||||
* @returns the matching paths RELATIVE to `root` ('/'-separated), sorted,
|
||||
* plus whether a budget cut the walk short. An unreadable level is skipped
|
||||
* (permission errors never fail the whole search).
|
||||
*/
|
||||
export async function searchFiles(root: string, query: string, opts: FsSearchOptions = {}): Promise<FsSearchResult> {
|
||||
const needle = query.trim().toLowerCase()
|
||||
if (needle === '') return { matches: [], truncated: false }
|
||||
const maxMatches = opts.maxMatches ?? DEFAULT_MAX_MATCHES
|
||||
const maxVisited = opts.maxVisited ?? DEFAULT_MAX_VISITED
|
||||
|
||||
const matches: string[] = []
|
||||
let visited = 0
|
||||
let truncated = false
|
||||
|
||||
const walk = async (dir: string): Promise<void> => {
|
||||
if (truncated) return
|
||||
const level = await opendir(dir).catch(() => undefined)
|
||||
if (level === undefined) return
|
||||
for await (const dirent of level) {
|
||||
visited += 1
|
||||
if (visited > maxVisited) {
|
||||
truncated = true
|
||||
return
|
||||
}
|
||||
// .git is VCS-internal noise: never matched, never descended.
|
||||
if (dirent.isDirectory() && dirent.name === '.git') continue
|
||||
if (dirent.name.toLowerCase().includes(needle)) {
|
||||
matches.push(join(relative(root, dir), dirent.name))
|
||||
if (matches.length >= maxMatches) {
|
||||
truncated = true
|
||||
return
|
||||
}
|
||||
}
|
||||
// Descend real directories only: a symlinked directory may point back
|
||||
// up the tree (cycle).
|
||||
if (dirent.isDirectory() && !dirent.isSymbolicLink()) {
|
||||
await walk(join(dir, dirent.name))
|
||||
if (truncated) return
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(root)
|
||||
// '/' separators on every platform: the client joins onto the cwd itself.
|
||||
return { matches: matches.sort().map(path => path.split(sep).join('/')), truncated }
|
||||
}
|
||||
+51
-8
@@ -2,11 +2,13 @@
|
||||
* Single-level directory listing for the sidebar explorer. Streams the level
|
||||
* with opendir, sorts directories first then names (case-insensitive), and
|
||||
* marks POSIX-hidden entries (dot-prefixed) for dimmed display. Symlinks are
|
||||
* reported as files without probing their target — the explorer shows what
|
||||
* dirent says, keeping the read cheap for arbitrarily large levels.
|
||||
* stat'ed once to expose their target kind — a symlink to a directory
|
||||
* expands like a directory — and dangling links are flagged broken. The
|
||||
* probe runs only for entries that are actually symlinks, so levels without
|
||||
* links stay as cheap as before.
|
||||
*/
|
||||
import { opendir } from 'node:fs/promises'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { opendir, stat } from 'node:fs/promises'
|
||||
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
|
||||
import { SidebarError } from './wire.ts'
|
||||
|
||||
/** One explorer row. */
|
||||
@@ -15,6 +17,10 @@ export interface SidebarFsEntry {
|
||||
path: string
|
||||
isDir: boolean
|
||||
hidden: boolean
|
||||
/** Whether the row is a symlink; `isDir` then describes the link's target. */
|
||||
isSymlink: boolean
|
||||
/** For symlinks: the target is missing or unreadable (stat failed). */
|
||||
broken: boolean
|
||||
}
|
||||
|
||||
/** One listed level. */
|
||||
@@ -52,22 +58,53 @@ export async function listDirectory(path: string, maxEntries = 1000): Promise<Si
|
||||
overflow += 1
|
||||
continue
|
||||
}
|
||||
// Platform join: on Windows the level path uses '\' — a hardcoded '/'
|
||||
// would leak mixed separators into every row's path.
|
||||
rows.push({
|
||||
name: dirent.name,
|
||||
// Platform join: on Windows the level path uses '\' — a hardcoded '/'
|
||||
// would leak mixed separators into every row's path.
|
||||
path: join(path, dirent.name),
|
||||
isDir: dirent.isDirectory(),
|
||||
isSymlink: dirent.isSymbolicLink(),
|
||||
broken: false,
|
||||
hidden: dirent.name.startsWith('.'),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
throw new SidebarError('fs-error', `cannot list "${path}": ${messageOf(error)}`, 400)
|
||||
}
|
||||
// Probe symlink targets AFTER the readdir stream closes, with bounded
|
||||
// concurrency: a symlink-heavy level (UNC/network targets) would otherwise
|
||||
// serialize up to maxEntries stat calls and stall the explorer. Non-symlink
|
||||
// rows are skipped by the probe, so levels without links stay as cheap as
|
||||
// before.
|
||||
await probeSymlinkTargets(rows)
|
||||
rows.sort(compareEntries)
|
||||
return { path, entries: rows, truncated: overflow > 0 }
|
||||
}
|
||||
|
||||
/** How many symlink target stats run in flight during one level listing. */
|
||||
const SYMLINK_PROBE_CONCURRENCY = 32
|
||||
|
||||
/** Probe each symlink row's target once (bounded concurrency, order-preserving). */
|
||||
async function probeSymlinkTargets(rows: SidebarFsEntry[], concurrency = SYMLINK_PROBE_CONCURRENCY): Promise<void> {
|
||||
let next = 0
|
||||
const workers = Array.from({ length: Math.min(concurrency, rows.length) }, async () => {
|
||||
for (;;) {
|
||||
const index = next
|
||||
next += 1
|
||||
if (index >= rows.length) return
|
||||
const row = rows[index]!
|
||||
if (!row.isSymlink) continue
|
||||
// stat follows the chain; any failure (missing target, ELOOP, permission)
|
||||
// leaves the row as a broken file-shaped link the editor refuses to read.
|
||||
const info = await stat(row.path).catch(() => undefined)
|
||||
row.isDir = info !== undefined ? info.isDirectory() : row.isDir
|
||||
row.broken = info === undefined
|
||||
}
|
||||
})
|
||||
await Promise.all(workers)
|
||||
}
|
||||
|
||||
/** The root row label of a listing: the last path segment (or the full path at the filesystem root). */
|
||||
export function rootLabel(path: string): string {
|
||||
const base = basename(path)
|
||||
@@ -80,9 +117,15 @@ export function parentOf(path: string): string | undefined {
|
||||
return parent === path ? undefined : parent
|
||||
}
|
||||
|
||||
/** Normalize a caller-supplied path to an absolute, resolved path or throw fs-error. */
|
||||
/**
|
||||
* Normalize a caller-supplied path to an absolute, resolved path or throw
|
||||
* fs-error. `path.isAbsolute()` is the OS's own notion of absolute: POSIX
|
||||
* roots (`/...`), Windows drive letters (`C:\...`) and — on win32 — UNC
|
||||
* network shares (`\\server\share\...`); drive-relative forms (`C:foo`)
|
||||
* stay rejected.
|
||||
*/
|
||||
export function requireAbsolute(path: string): string {
|
||||
if (!path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(path)) {
|
||||
if (!isAbsolute(path)) {
|
||||
throw new SidebarError('fs-error', `"${path}" is not an absolute path`, 400)
|
||||
}
|
||||
return resolve(path)
|
||||
|
||||
+43
-16
@@ -13,6 +13,17 @@
|
||||
* /sidebar/html/S/Users/me/proj/index.html
|
||||
* + ./style.css → /sidebar/html/S/Users/me/proj/style.css
|
||||
* Windows: C:\Users\me\a.html → /sidebar/html/S/C%3A/Users/me/a.html
|
||||
* UNC (\\server\share\... or //server/share/...):
|
||||
* → /sidebar/html/S//server/share/proj/a.html ('//' right after the
|
||||
* sessionId marks the UNC prefix; the WHATWG URL keeps '//' intact so
|
||||
* relative assets still resolve inside the same route)
|
||||
*
|
||||
* The decoder rebuilds the marker as a forward-slash `//server/share/...`
|
||||
* path. That form is intentionally platform-neutral: `node:path` resolves it
|
||||
* to `\\server\share\...` on win32 and `/server/share/...` on POSIX, so the
|
||||
* host's existing requireAbsolute + isWithin fence needs no platform signal
|
||||
* (a leading `//` is a legal POSIX absolute path, so no data is lost on
|
||||
* either platform).
|
||||
*
|
||||
* This module is intentionally dependency-free (no node imports, no wire
|
||||
* helpers) so the client bundle can import `encodeHtmlUrl` without tripping
|
||||
@@ -37,23 +48,24 @@ export const HTML_ROUTE_PREFIX = '/sidebar/html/'
|
||||
|
||||
/** Build the route URL for one absolute file path (client + tests). */
|
||||
export function encodeHtmlUrl(sessionId: string, path: string): string {
|
||||
const unc = /^[\\/]{2}[^\\/]/.test(path)
|
||||
const segments = path.split(/[\\/]+/).filter(segment => segment !== '')
|
||||
return `${HTML_ROUTE_PREFIX}${encodeURIComponent(sessionId)}/${segments.map(encodeURIComponent).join('/')}`
|
||||
return `${HTML_ROUTE_PREFIX}${encodeURIComponent(sessionId)}/${unc ? '/' : ''}${segments.map(encodeURIComponent).join('/')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a route pathname into the session + absolute file path. Rejects
|
||||
* a wrong prefix (404), an empty or double-slash path, malformed percent
|
||||
* encoding, and a missing sessionId or file path (400). The caller still
|
||||
* must bound the decoded path with requireAbsolute + isWithin(cwd) — a
|
||||
* decoded `..` segment resolves outside the cwd and is refused there.
|
||||
* a wrong prefix (404), an empty path, malformed percent encoding, and a
|
||||
* missing sessionId or file path (400). The caller still must bound the
|
||||
* decoded path with requireAbsolute + isWithin(cwd) — a decoded `..`
|
||||
* segment resolves outside the cwd and is refused there.
|
||||
*/
|
||||
export function decodeHtmlUrl(pathname: string): HtmlDecodeResult {
|
||||
if (!pathname.startsWith(HTML_ROUTE_PREFIX)) {
|
||||
return { ok: false, status: 404, message: 'not an html route' }
|
||||
}
|
||||
const rest = pathname.slice(HTML_ROUTE_PREFIX.length)
|
||||
if (rest === '' || rest.includes('//')) {
|
||||
if (rest === '') {
|
||||
return { ok: false, status: 400, message: 'invalid html route path' }
|
||||
}
|
||||
let segments: string[]
|
||||
@@ -63,17 +75,32 @@ export function decodeHtmlUrl(pathname: string): HtmlDecodeResult {
|
||||
return { ok: false, status: 400, message: 'malformed URL encoding' }
|
||||
}
|
||||
const [sessionId, ...pathSegments] = segments
|
||||
if (sessionId === undefined || sessionId === '' || pathSegments.length === 0 || pathSegments.some(segment => segment === '')) {
|
||||
if (sessionId === undefined || sessionId === '') {
|
||||
return { ok: false, status: 400, message: 'sessionId and file path are required' }
|
||||
}
|
||||
// A Windows drive segment ('D:') is the FIRST path segment of an encoded
|
||||
// drive path. Rejoining it with a leading slash would yield '/D:/work/...'
|
||||
// which node's path.resolve() mangles into 'D:\D:\work\...' on Windows —
|
||||
// the html route's isWithin(cwd) fence would then reject every drive path.
|
||||
// Keep the drive form slash-free so requireAbsolute() resolves it verbatim.
|
||||
const first = pathSegments[0] ?? ''
|
||||
const path = /^[A-Za-z]:$/.test(first)
|
||||
? pathSegments.join('/')
|
||||
: `/${pathSegments.join('/')}`
|
||||
// An empty FIRST path segment is the UNC marker (encodeHtmlUrl emits
|
||||
// '<sid>//server/share/...' for UNC paths); the encoder filters empty
|
||||
// segments everywhere else, so an empty segment can only be the marker or
|
||||
// a malformed URL — both handled here.
|
||||
const unc = pathSegments[0] === ''
|
||||
const tail = unc ? pathSegments.slice(1) : pathSegments
|
||||
if (tail.length === 0 || tail.some(segment => segment === '')) {
|
||||
return { ok: false, status: 400, message: 'sessionId and file path are required' }
|
||||
}
|
||||
let path: string
|
||||
if (unc) {
|
||||
// Rebuild the platform-neutral forward-slash form `//server/share/...`;
|
||||
// requireAbsolute() resolves it to the platform's own UNC/POSIX spelling.
|
||||
path = `//${tail.join('/')}`
|
||||
} else if (/^[A-Za-z]:$/.test(tail[0] ?? '')) {
|
||||
// A Windows drive segment ('D:') is the FIRST path segment of an encoded
|
||||
// drive path. Rejoining it with a leading slash would yield '/D:/work/...'
|
||||
// which node's path.resolve() mangles into 'D:\D:\work\...' on Windows —
|
||||
// the html route's isWithin(cwd) fence would then reject every drive path.
|
||||
// Keep the drive form slash-free so requireAbsolute() resolves it verbatim.
|
||||
path = tail.join('/')
|
||||
} else {
|
||||
path = `/${tail.join('/')}`
|
||||
}
|
||||
return { ok: true, ref: { sessionId, path } }
|
||||
}
|
||||
|
||||
+96
-17
@@ -29,6 +29,7 @@ import {
|
||||
type SidebarPrefs,
|
||||
} from './config.ts'
|
||||
import { isWithin, parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
|
||||
import { searchFiles } from './fs-search.ts'
|
||||
import { decodeHtmlUrl } from './html-route.ts'
|
||||
import { extractFrameAncestors } from './browser-probe.ts'
|
||||
import { isTrustedApiRequest, isLoopbackHostname } from './trust-fence.ts'
|
||||
@@ -37,6 +38,12 @@ import * as git from './git.ts'
|
||||
import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts'
|
||||
import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts'
|
||||
import {
|
||||
DSH_NODE_PTY_RANGE,
|
||||
depsStatus,
|
||||
loadNodePty,
|
||||
PTY_DEPS_MISSING,
|
||||
} from './pty-deps.ts'
|
||||
import { registerTools } from './tools.ts'
|
||||
import { buildJobsApi, type SidebarJobsRoutes } from './jobs-routes.ts'
|
||||
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
|
||||
@@ -172,6 +179,14 @@ type ApiMethod = (payload: unknown) => Promise<unknown> | unknown
|
||||
export interface SidebarSettingsFace {
|
||||
/** The current resolved value + revision (undefined while the settings service is absent). */
|
||||
get(): { value?: unknown; revision?: number }
|
||||
/**
|
||||
* Whether the dsh-web-ui family's aionui-panel has been selected as the
|
||||
* right-panel provider (the `aionui-panel` settings namespace resolves
|
||||
* `rightPanel: 'aionui-panel'`). While true the sidebar must not mount —
|
||||
* the two right panels are mutually exclusive. False when the namespace is
|
||||
* absent (no aionui installed) or the provider is anything else.
|
||||
*/
|
||||
externalDisable(): boolean
|
||||
/** Merge a patch (revision-guarded) and return the fresh resolved view. */
|
||||
update(patch: Record<string, unknown>, expectedRevision?: number): Promise<{ value?: unknown; revision?: number }>
|
||||
}
|
||||
@@ -179,8 +194,8 @@ export interface SidebarSettingsFace {
|
||||
/** Build the API method table bound to the plugin context, pty manager, agent pty registry, and resolved config. */
|
||||
function buildApi(
|
||||
ctx: Context,
|
||||
ptyManager: PtyManager,
|
||||
agentPtyRegistry: AgentPtyRegistry,
|
||||
ptyManager: PtyManager | null,
|
||||
agentPtyRegistry: AgentPtyRegistry | null,
|
||||
resolved: ResolvedSidebarConfig,
|
||||
getSettings: () => SidebarSettingsFace | undefined,
|
||||
): Record<string, ApiMethod> {
|
||||
@@ -207,6 +222,14 @@ function buildApi(
|
||||
const target = record.path === undefined ? cwd : requireAbsolute(requireString(payload, 'path'))
|
||||
return listDirectory(target, resolved.listLimit)
|
||||
},
|
||||
'fs.search': async (payload) => {
|
||||
// The editor side panel's global name search: rooted at the session
|
||||
// cwd (not caller-targetable — the walk is unbounded by design and
|
||||
// must never escape the workspace), budgeted inside searchFiles.
|
||||
const { cwd } = cwdOf(payload)
|
||||
const query = requireString(payload, 'query')
|
||||
return searchFiles(cwd, query)
|
||||
},
|
||||
'fs.read': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
// Relative paths are git-derived (status/diff report repo-root-relative
|
||||
@@ -313,7 +336,9 @@ function buildApi(
|
||||
'pty.close': (payload) => {
|
||||
const sessionId = requireString(payload, 'sessionId')
|
||||
const tab = requireString(payload, 'tab')
|
||||
ptyManager.close(`${sessionId}:${tab}`)
|
||||
// Degraded mode (node-pty unavailable): no live pty can exist, so a
|
||||
// no-op ok is the honest answer — never an error the client must show.
|
||||
ptyManager?.close(`${sessionId}:${tab}`)
|
||||
return { ok: true }
|
||||
},
|
||||
// Release an agent terminal by uuid. The WS close frame already does
|
||||
@@ -322,9 +347,14 @@ function buildApi(
|
||||
// tab never leaves a zombie pty behind. Idempotent.
|
||||
'agent-pty.close': (payload) => {
|
||||
const uuid = requireString(payload, 'uuid')
|
||||
agentPtyRegistry.close(uuid)
|
||||
agentPtyRegistry?.close(uuid)
|
||||
return { ok: true }
|
||||
},
|
||||
// Terminal dependency status (issue #140): after a WS close 1011 with
|
||||
// reason `pty-deps-missing` the client fetches the full repair details
|
||||
// here — the close reason itself is capped at 123 bytes, too small for
|
||||
// the pasteable command.
|
||||
'terminal.deps': () => depsStatus(),
|
||||
// Background jobs: read one job's output (a REPLAY of what the model
|
||||
// has read so far, from the owner session's event log — the model's
|
||||
// job_output cursor is never touched, so the human pane can never steal
|
||||
@@ -340,7 +370,9 @@ function buildApi(
|
||||
// silently overwritten (mirror of the settings seam's own guard).
|
||||
'settings.get': () => {
|
||||
const settings = getSettings()
|
||||
return settings?.get() ?? { value: undefined, revision: undefined }
|
||||
return settings === undefined
|
||||
? { value: undefined, revision: undefined, externalDisable: false }
|
||||
: { ...settings.get(), externalDisable: settings.externalDisable() }
|
||||
},
|
||||
'settings.update': async (payload) => {
|
||||
const settings = getSettings()
|
||||
@@ -426,18 +458,35 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
// restore it before any terminal can spawn (idempotent).
|
||||
ensureSpawnHelper()
|
||||
const resolved = resolveSidebarConfig(config)
|
||||
// One shell resolution feeds BOTH terminal surfaces: the UI tabs and the
|
||||
// model-facing terminal_* tools. They must stay in lockstep, otherwise a
|
||||
// configured shell fixes one surface and silently leaves the other on the
|
||||
// platform default.
|
||||
const terminalShell = defaultShell({ explicit: resolved.shell })
|
||||
// The web runtime's bind-derived trust list (boot-sampled LAN literals
|
||||
// plus --trusted-host authorities) — the authoritative source the /api
|
||||
// gateway fence derives its list from. Read per request from the live
|
||||
// service value; a replaced list takes effect without a plugin restart.
|
||||
const fence = (req: SidebarHttpRequest): boolean => isTrustedApiRequest(req, ctx.webRuntime.trustedHosts)
|
||||
const ptyManager = new PtyManager(defaultShell(), resolved.terminalsPerSession)
|
||||
// node-pty is loaded lazily, never at module top level (issue #140): a
|
||||
// missing or broken install must degrade THIS plugin — terminal tab shows
|
||||
// a repair command, agent terminal tools stay unregistered — instead of
|
||||
// failing the plugin load and taking the whole `dsh web` server down.
|
||||
const nodePty = loadNodePty()
|
||||
if (nodePty === null) {
|
||||
const status = depsStatus()
|
||||
const detail = status.ok
|
||||
? 'unknown cause'
|
||||
: `${status.cause}. Repair: ${status.command}`
|
||||
ctx.logger?.warn(`[dsh-better-sidebar] node-pty (${DSH_NODE_PTY_RANGE}) failed to load: ${detail}`)
|
||||
}
|
||||
const ptyManager = nodePty !== null ? new PtyManager(terminalShell, resolved.terminalsPerSession, nodePty) : null
|
||||
// The agent-owned terminal registry: parallel to the UI-tab ptyManager,
|
||||
// keyed by uuid (the model's opaque handle) instead of `${sessionId}:${tabId}`,
|
||||
// uncapped, and torn down with the plugin. The model creates terminals here
|
||||
// through the terminal_create tool; the sidebar view attaches through the
|
||||
// same /sidebar/ws/terminal upgrade with ?uuid=... instead of ?tab=...
|
||||
const agentPtyRegistry = new AgentPtyRegistry(defaultShell())
|
||||
const agentPtyRegistry = nodePty !== null ? new AgentPtyRegistry(terminalShell, nodePty) : null
|
||||
|
||||
// ── User-facing "Side card" preferences ──────────────────────────────────
|
||||
// Register the namespace with the settings provider so the Settings page
|
||||
@@ -456,6 +505,9 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
const syncToolsGate = (scope: { get(): SidebarPrefs }): void => {
|
||||
if (scope.get().agentTerminalTools) {
|
||||
if (toolsDisposers === null) {
|
||||
// Degraded mode (node-pty unavailable): never register the terminal
|
||||
// tools — every one of them would fail at spawn time.
|
||||
if (agentPtyRegistry === null) return
|
||||
toolsDisposers = registerTools(ctx, agentPtyRegistry, (sessionId) => sessionCwdOf(ctx, sessionId))
|
||||
}
|
||||
} else if (toolsDisposers !== null) {
|
||||
@@ -464,7 +516,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
// The feature is off: release every agent terminal the model created
|
||||
// while it was on (they are only reachable through the tools). The
|
||||
// registry change fires the push, so the sidebar reconciles them away.
|
||||
agentPtyRegistry.disposeAll()
|
||||
agentPtyRegistry?.disposeAll()
|
||||
}
|
||||
}
|
||||
ctx.inject(['settings'], (sctx) => {
|
||||
@@ -482,8 +534,20 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
? { value: undefined, revision: undefined }
|
||||
: { value: descriptor.value, revision: descriptor.revision }
|
||||
}
|
||||
// Mutual exclusion with the dsh-web-ui family right panel: the aionui
|
||||
// panel's provider choice (`aionui-panel.rightPanel`) is the authority.
|
||||
// While it resolves to 'aionui-panel', this sidebar must not mount. The
|
||||
// namespace is read through the settings seam like any other registered
|
||||
// section; absent namespace (no aionui installed) = not disabled.
|
||||
const externalDisable = (): boolean => {
|
||||
const descriptor = sctx.settings.describe({ redactSecrets: true })
|
||||
.find(candidate => candidate.ns === 'aionui-panel')
|
||||
const value = descriptor?.value as { rightPanel?: unknown } | undefined
|
||||
return value?.rightPanel === 'aionui-panel'
|
||||
}
|
||||
settingsFace = {
|
||||
get: viewOf,
|
||||
externalDisable,
|
||||
update: async (patch, expectedRevision) => {
|
||||
await sctx.settings.update(ns, patch, expectedRevision)
|
||||
return viewOf()
|
||||
@@ -695,8 +759,8 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
|
||||
ctx.effect(() => () => {
|
||||
toolsDisposers?.()
|
||||
ptyManager.disposeAll()
|
||||
agentPtyRegistry.disposeAll()
|
||||
ptyManager?.disposeAll()
|
||||
agentPtyRegistry?.disposeAll()
|
||||
wss.close()
|
||||
agentListWss.close()
|
||||
}, 'dsh-better-sidebar: teardown')
|
||||
@@ -704,7 +768,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
|
||||
/** Push the live agent-terminal list for one session to a connected sidebar view. */
|
||||
async function attachAgentList(
|
||||
registry: AgentPtyRegistry,
|
||||
registry: AgentPtyRegistry | null,
|
||||
ws: WebSocket,
|
||||
req: SidebarHttpRequest,
|
||||
): Promise<void> {
|
||||
@@ -717,13 +781,15 @@ async function attachAgentList(
|
||||
}
|
||||
const send = (): void => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(registry.list(sessionId)))
|
||||
// Degraded mode (node-pty unavailable): no agent terminal can exist,
|
||||
// so the honest push is the empty list.
|
||||
ws.send(JSON.stringify(registry?.list(sessionId) ?? []))
|
||||
}
|
||||
}
|
||||
send()
|
||||
const unsubscribe = registry.subscribe(send)
|
||||
ws.on('close', () => { unsubscribe() })
|
||||
ws.on('error', () => { unsubscribe() })
|
||||
const unsubscribe = registry?.subscribe(send)
|
||||
ws.on('close', () => { unsubscribe?.() })
|
||||
ws.on('error', () => { unsubscribe?.() })
|
||||
} catch (error) {
|
||||
ws.close(1011, error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
@@ -743,8 +809,8 @@ async function attachAgentList(
|
||||
*/
|
||||
async function attachTerminal(
|
||||
ctx: Context,
|
||||
ptyManager: PtyManager,
|
||||
agentPtyRegistry: AgentPtyRegistry,
|
||||
ptyManager: PtyManager | null,
|
||||
agentPtyRegistry: AgentPtyRegistry | null,
|
||||
ws: WebSocket,
|
||||
req: SidebarHttpRequest,
|
||||
resolved: ResolvedSidebarConfig,
|
||||
@@ -753,6 +819,12 @@ async function attachTerminal(
|
||||
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
||||
const uuid = url.searchParams.get('uuid')
|
||||
if (uuid !== null) {
|
||||
// Degraded mode (node-pty unavailable): no agent terminal can exist,
|
||||
// so the lookup behaves exactly like a missing uuid.
|
||||
if (agentPtyRegistry === null) {
|
||||
ws.close(1011, `agent terminal "${uuid}" not found`)
|
||||
return
|
||||
}
|
||||
const handle = agentPtyRegistry.get(uuid)
|
||||
if (handle === undefined) {
|
||||
ws.close(1011, `agent terminal "${uuid}" not found`)
|
||||
@@ -767,6 +839,13 @@ async function attachTerminal(
|
||||
ws.close(1008, 'either ?uuid or ?sessionId+?tab are required')
|
||||
return
|
||||
}
|
||||
if (ptyManager === null) {
|
||||
// Degraded mode (issue #140): node-pty unavailable. The close reason
|
||||
// is a SHORT marker — a WS close reason is capped at 123 bytes, so the
|
||||
// client fetches the full repair command from /sidebar/api/terminal.deps.
|
||||
ws.close(1011, PTY_DEPS_MISSING)
|
||||
return
|
||||
}
|
||||
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
|
||||
const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24)
|
||||
// Replay the transcript, then follow live output.
|
||||
|
||||
+12
-2
@@ -60,6 +60,15 @@ export interface SidebarPrefs {
|
||||
* own enable switch gates it too (both must be on for the takeover).
|
||||
*/
|
||||
interceptOpenPath: boolean
|
||||
/**
|
||||
* Whether the editor tab runs in merged mode: a path input replaces the
|
||||
* plain header and a toggleable file-tree panel (with a global name
|
||||
* search) docks at the tab's right edge. On by default; also makes brand
|
||||
* new sessions seed an empty editor tab (tree panel open) instead of the
|
||||
* explorer tab. The switch lives under the editor card's gear in the
|
||||
* Side card settings; off restores the pre-merge editor exactly.
|
||||
*/
|
||||
editorExplorer: 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
|
||||
@@ -156,7 +165,7 @@ export interface SidebarPrefs {
|
||||
/** Range contract of {@link SidebarPrefs.defaultWidthPercent}. */
|
||||
export const WIDTH_PERCENT_MIN = 20
|
||||
export const WIDTH_PERCENT_MAX = 60
|
||||
export const WIDTH_PERCENT_DEFAULT = 30
|
||||
export const WIDTH_PERCENT_DEFAULT = 35
|
||||
|
||||
/** Range contract of {@link SidebarPrefs.terminalFontSize}. */
|
||||
export const TERMINAL_FONT_SIZE_MIN = 9
|
||||
@@ -170,7 +179,7 @@ export const TITLE_BAR_STRIP_DEFAULT = 40
|
||||
|
||||
/** Fallback prefs used whenever the settings document is unreachable or malformed. */
|
||||
export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
|
||||
openByDefault: true,
|
||||
openByDefault: false,
|
||||
defaultWidthPercent: WIDTH_PERCENT_DEFAULT,
|
||||
autoOpenSubagent: true,
|
||||
autoOpenJobs: true,
|
||||
@@ -179,6 +188,7 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT,
|
||||
interceptOpenPath: true,
|
||||
editorExplorer: true,
|
||||
titleBarCompat: false,
|
||||
titleBarStripPx: TITLE_BAR_STRIP_DEFAULT,
|
||||
htmlViewerNoSandbox: false,
|
||||
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* node-pty dependency loading for the host half (issue #140, plugin side).
|
||||
*
|
||||
* The terminal surfaces (UI tabs + model-facing terminal_* tools) need
|
||||
* node-pty, but the package must NEVER be imported statically at module
|
||||
* top level: a missing or broken install (pnpm 11's strict-dep-builds
|
||||
* skipping node-pty's install script, a pruned store entry, a failed
|
||||
* prebuilt-binary download…) would then fail the plugin module load and —
|
||||
* because a loader entry apply failure aborts the boot — take the whole
|
||||
* `dsh web` server down with it.
|
||||
*
|
||||
* Instead the host half loads node-pty lazily (synchronously, via
|
||||
* createRequire — the same resolution `ensureSpawnHelper` already uses in
|
||||
* production). When the load fails the plugin stays mounted in a degraded
|
||||
* state: the terminal tab shows a friendly error carrying a pasteable
|
||||
* repair command (see scripts/install.sh / install.ps1 `--repair`), and the
|
||||
* agent terminal tools are simply not registered.
|
||||
*
|
||||
* Version contract: the plugin must stay in sync with DSH core —
|
||||
* `@deepseek-ai/dsh-subprocess-local` declares `"node-pty": "^1.1.0"` in
|
||||
* its `dependencies`. Both sides then resolve the SAME pnpm store entry
|
||||
* (same range, same integrity → one native binding, no drift). Do NOT
|
||||
* switch to a fork (e.g. @lydell/node-pty) or a different range without
|
||||
* re-checking the core declaration.
|
||||
*/
|
||||
import { existsSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { homedir } from 'node:os'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type * as nodePtyNs from 'node-pty'
|
||||
import { SidebarError } from './wire.ts'
|
||||
|
||||
/** The node-pty module surface the registries consume (spawn/kill/resize/…). */
|
||||
export type NodePtyModule = typeof nodePtyNs
|
||||
|
||||
/**
|
||||
* The node-pty version range this plugin ships. MUST stay identical to the
|
||||
* range DSH core declares (`@deepseek-ai/dsh-subprocess-local`): the same
|
||||
* range keeps pnpm resolving both to one physical package.
|
||||
*/
|
||||
export const DSH_NODE_PTY_RANGE = '^1.1.0'
|
||||
|
||||
/**
|
||||
* The WebSocket close-code-1011 reason the host sends when node-pty is
|
||||
* unavailable. The client recognizes this exact marker and fetches the full
|
||||
* repair details from `/sidebar/api/terminal.deps` (a WS close reason is
|
||||
* capped at 123 bytes, so the command itself cannot ride the close frame).
|
||||
*/
|
||||
export const PTY_DEPS_MISSING = 'pty-deps-missing'
|
||||
|
||||
/** A require-compatible loader, injectable for tests. */
|
||||
export type NodePtyRequire = (id: string) => unknown
|
||||
|
||||
const defaultRequire: NodePtyRequire = createRequire(import.meta.url)
|
||||
|
||||
type LoadResult = { ok: true; module: NodePtyModule } | { ok: false; cause: unknown }
|
||||
|
||||
let cached: LoadResult | undefined
|
||||
|
||||
/**
|
||||
* Load node-pty once (synchronously) and cache the outcome. Returns null
|
||||
* when the package or its native binding cannot be loaded; the cause stays
|
||||
* queryable through {@link nodePtyLoadCause}. Never throws.
|
||||
*/
|
||||
export function loadNodePty(requireImpl: NodePtyRequire = defaultRequire): NodePtyModule | null {
|
||||
if (cached === undefined) {
|
||||
try {
|
||||
cached = { ok: true, module: requireImpl('node-pty') as NodePtyModule }
|
||||
} catch (cause) {
|
||||
cached = { ok: false, cause }
|
||||
}
|
||||
}
|
||||
return cached.ok ? cached.module : null
|
||||
}
|
||||
|
||||
/** The recorded load failure (undefined when the load succeeded or never ran). */
|
||||
export function nodePtyLoadCause(): unknown {
|
||||
return cached !== undefined && !cached.ok ? cached.cause : undefined
|
||||
}
|
||||
|
||||
/** Forget the cached outcome (tests only — a real reload is otherwise one-shot). */
|
||||
export function resetNodePtyCache(): void {
|
||||
cached = undefined
|
||||
}
|
||||
|
||||
/** Load node-pty or throw the canonical degraded-mode error (class-constructor default). */
|
||||
export function loadRequiredNodePty(): NodePtyModule {
|
||||
const module = loadNodePty()
|
||||
if (module === null) {
|
||||
const cause = describeCause(nodePtyLoadCause())
|
||||
throw new SidebarError(
|
||||
'pty-deps-missing',
|
||||
`node-pty (${DSH_NODE_PTY_RANGE}) failed to load: ${cause} — run the repair command shown in the terminal tab`,
|
||||
503,
|
||||
)
|
||||
}
|
||||
return module
|
||||
}
|
||||
|
||||
/** Resolve a directory to its physical location (symlinked/link: installs). */
|
||||
function realDir(file: string): string {
|
||||
try {
|
||||
return dirname(realpathSync(file))
|
||||
} catch {
|
||||
return dirname(file)
|
||||
}
|
||||
}
|
||||
|
||||
/** Walk up from `dir` looking for a DSH profile root (package.json + pnpm-workspace.yaml). */
|
||||
function walkUp(dir: string, isRoot: (dir: string) => boolean): string | null {
|
||||
let current = dir
|
||||
for (let depth = 0; depth < 16; depth += 1) {
|
||||
if (isRoot(current)) return current
|
||||
const parent = dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Whether `dir` looks like a DSH profile root (the plugin lives under its node_modules). */
|
||||
function isProfileRoot(dir: string): boolean {
|
||||
return existsSync(join(dir, 'package.json')) && existsSync(join(dir, 'pnpm-workspace.yaml'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the DSH profile directory this plugin is installed into: the
|
||||
* nearest ancestor of the plugin module that carries both `package.json`
|
||||
* and `pnpm-workspace.yaml` (the profile root; the plugin resolves from the
|
||||
* profile's node_modules). Falls back to `$DSH_HOME/profiles/web` (the
|
||||
* standard web profile), then null.
|
||||
*/
|
||||
export function findProfileDir(fromFile: string = fileURLToPath(import.meta.url)): string | null {
|
||||
const detected = walkUp(realDir(fromFile), isProfileRoot)
|
||||
if (detected !== null) return detected
|
||||
const home = process.env.DSH_HOME !== undefined && process.env.DSH_HOME.trim() !== ''
|
||||
? process.env.DSH_HOME
|
||||
: join(homedir(), '.dsh')
|
||||
const web = join(home, 'profiles', 'web')
|
||||
return isProfileRoot(web) ? realpathSync(web) : null
|
||||
}
|
||||
|
||||
/** Whether `dir`'s package.json declares this plugin's name. */
|
||||
function isPluginRoot(dir: string): boolean {
|
||||
const file = join(dir, 'package.json')
|
||||
if (!existsSync(file)) return false
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(file, 'utf8')) as { name?: unknown }
|
||||
return parsed.name === 'dsh-better-sidebar'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** The plugin package root (walk-up from the module; works for lib/ and src/ layouts). */
|
||||
export function findPluginRoot(fromFile: string = fileURLToPath(import.meta.url)): string | null {
|
||||
return walkUp(realDir(fromFile), isPluginRoot)
|
||||
}
|
||||
|
||||
/** Options for {@link buildRepairCommand}. */
|
||||
export interface RepairCommandOptions {
|
||||
/** The plugin package root (where scripts/install.sh / install.ps1 live). */
|
||||
pluginRoot: string | null
|
||||
/** The detected profile directory (null → the standard `web` profile). */
|
||||
profileDir: string | null
|
||||
/** Platform override for tests; defaults to the live process. */
|
||||
platform?: NodeJS.Platform
|
||||
}
|
||||
|
||||
/**
|
||||
* The pasteable repair command for a broken node-pty install: rerun the
|
||||
* plugin's own installer in `--repair` mode (idempotent: it re-writes the
|
||||
* profile's `allowBuilds: node-pty: true` and re-installs/rebuilds the
|
||||
* dependency). Falls back to DSH's plugin command when the scripts are not
|
||||
* shipped (exotic layouts).
|
||||
*/
|
||||
export function buildRepairCommand(options: RepairCommandOptions): { command: string; note?: string } {
|
||||
const { pluginRoot, profileDir } = options
|
||||
const platform = options.platform ?? process.platform
|
||||
const profileName = profileDir !== null ? basename(profileDir) : null
|
||||
const profileArg = profileName !== null
|
||||
? (platform === 'win32' ? ` -Profile "${profileName}"` : ` --profile "${profileName}"`)
|
||||
: ''
|
||||
if (pluginRoot !== null) {
|
||||
if (platform === 'win32') {
|
||||
const script = join(pluginRoot, 'scripts', 'install.ps1')
|
||||
if (existsSync(script)) {
|
||||
return { command: `powershell -ExecutionPolicy Bypass -File "${script}" -Repair${profileArg}` }
|
||||
}
|
||||
} else {
|
||||
const script = join(pluginRoot, 'scripts', 'install.sh')
|
||||
if (existsSync(script)) {
|
||||
return { command: `bash "${script}" --repair${profileArg}` }
|
||||
}
|
||||
}
|
||||
}
|
||||
const name = profileName ?? 'web'
|
||||
return {
|
||||
command: `dsh plugin --profile "${name}" install`,
|
||||
note: 'If pnpm 11 blocked node-pty\'s build script, ensure `allowBuilds: node-pty: true` in the profile\'s pnpm-workspace.yaml (the plugin\'s scripts/install.sh / install.ps1 --repair does this automatically).',
|
||||
}
|
||||
}
|
||||
|
||||
/** One-line human description of the recorded load cause. */
|
||||
function describeCause(cause: unknown): string {
|
||||
if (cause instanceof Error) return cause.message
|
||||
return String(cause)
|
||||
}
|
||||
|
||||
/** Structured status served by the `/sidebar/api/terminal.deps` endpoint. */
|
||||
export type NodePtyDepsStatus =
|
||||
| { ok: true }
|
||||
| {
|
||||
ok: false
|
||||
/** The require-time error message (module missing, native binding broken…). */
|
||||
cause: string
|
||||
/** The pasteable repair command (terminal/cmd). */
|
||||
command: string
|
||||
/** The detected profile name (null when undetected → the command defaults to web). */
|
||||
profile: string | null
|
||||
/** Optional supplementary hint (fallback command only). */
|
||||
note?: string
|
||||
}
|
||||
|
||||
/** Current node-pty dependency status (loaded vs degraded + repair info). */
|
||||
export function depsStatus(options: { fromFile?: string } = {}): NodePtyDepsStatus {
|
||||
const module = loadNodePty()
|
||||
if (module !== null) return { ok: true }
|
||||
const pluginRoot = findPluginRoot(options.fromFile)
|
||||
const profileDir = findProfileDir(options.fromFile)
|
||||
const { command, note } = buildRepairCommand({ pluginRoot, profileDir })
|
||||
return {
|
||||
ok: false,
|
||||
cause: describeCause(nodePtyLoadCause()),
|
||||
command,
|
||||
profile: profileDir !== null ? basename(profileDir) : null,
|
||||
...(note !== undefined ? { note } : {}),
|
||||
}
|
||||
}
|
||||
+92
-14
@@ -10,7 +10,8 @@ import { chmodSync, existsSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import { userInfo } from 'node:os'
|
||||
import * as nodePty from 'node-pty'
|
||||
import type { IPty } from 'node-pty'
|
||||
import { loadRequiredNodePty, type NodePtyModule } from './pty-deps.ts'
|
||||
import { SidebarError } from './wire.ts'
|
||||
|
||||
/** Per-terminal transcript bound (bytes kept for replay). */
|
||||
@@ -52,7 +53,7 @@ export interface SidebarPty {
|
||||
* the page-load hydrate race can attach the real cwd after the first
|
||||
* connect, and a shell in the wrong directory must not linger). */
|
||||
cwd: string
|
||||
pty: nodePty.IPty
|
||||
pty: IPty
|
||||
/** Output accumulated since spawn (bounded; head dropped when over the limit). */
|
||||
transcript: string
|
||||
/** Whether the top-level process exited (transcript stays replayable). */
|
||||
@@ -71,6 +72,8 @@ export class PtyManager {
|
||||
constructor(
|
||||
private readonly shell: string,
|
||||
private readonly maxPerSession: number,
|
||||
/** The loaded node-pty module (injected so a broken install degrades instead of crashing the plugin). */
|
||||
private readonly nodePty: NodePtyModule = loadRequiredNodePty(),
|
||||
) {}
|
||||
|
||||
/** All live terminal keys of one session. */
|
||||
@@ -119,7 +122,7 @@ export class PtyManager {
|
||||
sessionId,
|
||||
tabId,
|
||||
cwd,
|
||||
pty: nodePty.spawn(this.shell, shellSpawnArgs(), {
|
||||
pty: this.nodePty.spawn(this.shell, shellSpawnArgs(), {
|
||||
name: 'xterm-256color',
|
||||
cols: Math.max(2, Math.floor(cols)),
|
||||
rows: Math.max(2, Math.floor(rows)),
|
||||
@@ -193,18 +196,93 @@ export class PtyManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Inputs for {@link defaultShell} resolution. Every field is optional and
|
||||
* defaults to the live process, which keeps the no-argument call sites
|
||||
* working while tests (and exotic embedders) can pin the platform, the
|
||||
* environment, and the existence probe independently — the Windows chain
|
||||
* never executes on the ubuntu CI runners, so it is only testable through
|
||||
* these injection points.
|
||||
*/
|
||||
export function defaultShell(): string {
|
||||
if (process.platform === 'win32') return 'powershell.exe'
|
||||
const envShell = process.env.SHELL
|
||||
if (envShell !== undefined && envShell.trim() !== '') return envShell
|
||||
export interface ShellResolutionOptions {
|
||||
/** Platform override (defaults to `process.platform`). */
|
||||
platform?: NodeJS.Platform
|
||||
/** Environment override; the resolver only reads SHELL, DSH_SIDEBAR_SHELL, PATH, ProgramW6432, ProgramFiles, LOCALAPPDATA. */
|
||||
env?: NodeJS.ProcessEnv
|
||||
/** Explicitly configured shell (the `shell` config field); wins over every automatic source. Empty means unset. */
|
||||
explicit?: string
|
||||
/** File-existence probe override (defaults to `existsSync`). */
|
||||
exists?: (path: string) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidate directories that may contain a `pwsh.exe` on Windows: PATH
|
||||
* entries first, then the well-known machine/user install locations
|
||||
* (including preview channels and per-user MSI/portable layouts). The
|
||||
* machine-scope search reads both `ProgramW6432` and `ProgramFiles` so a
|
||||
* 32-bit Node process — whose `ProgramFiles` points at `(x86)` — still
|
||||
* finds a 64-bit PowerShell 7 install. De-duped while preserving priority
|
||||
* order.
|
||||
*/
|
||||
function windowsPwshCandidateDirs(env: NodeJS.ProcessEnv): string[] {
|
||||
const dirs: string[] = []
|
||||
const pathEntries = env.PATH
|
||||
if (pathEntries !== undefined) {
|
||||
// The win32 branch always uses the Windows PATH separator; hardcoding it
|
||||
// keeps the function testable from POSIX runners without a delimiter
|
||||
// injection point.
|
||||
for (const entry of pathEntries.split(';')) {
|
||||
const trimmed = entry.trim()
|
||||
if (trimmed !== '') dirs.push(trimmed)
|
||||
}
|
||||
}
|
||||
for (const programFiles of [env.ProgramW6432, env.ProgramFiles]) {
|
||||
if (programFiles === undefined || programFiles.trim() === '') continue
|
||||
dirs.push(join(programFiles, 'PowerShell', '7'))
|
||||
dirs.push(join(programFiles, 'PowerShell', '7-preview'))
|
||||
}
|
||||
const localAppData = env.LOCALAPPDATA
|
||||
if (localAppData !== undefined && localAppData.trim() !== '') {
|
||||
dirs.push(join(localAppData, 'Microsoft', 'PowerShell', '7'))
|
||||
dirs.push(join(localAppData, 'Microsoft', 'PowerShell', '7-preview'))
|
||||
dirs.push(join(localAppData, 'Programs', 'PowerShell', '7'))
|
||||
dirs.push(join(localAppData, 'Programs', 'PowerShell', '7-preview'))
|
||||
}
|
||||
return [...new Set(dirs)]
|
||||
}
|
||||
|
||||
/**
|
||||
* The interactive shell for this platform, resolved like a terminal
|
||||
* emulator: an explicitly configured shell (the `shell` config field) wins,
|
||||
* then `$SHELL` on POSIX (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 previously short-circuited to `powershell.exe` (the inbox 5.1)
|
||||
* before any resolution, so PowerShell 7 users always got a legacy shell
|
||||
* without `??`/`?.`/ternary and with poor ANSI/UTF-8 defaults. The Windows
|
||||
* chain is now: explicit shell → `DSH_SIDEBAR_SHELL` env override → first
|
||||
* `pwsh.exe` found on PATH or in a known install directory → the 5.1
|
||||
* fallback (machines without PowerShell 7 keep working).
|
||||
*/
|
||||
export function defaultShell(options: ShellResolutionOptions = {}): string {
|
||||
const platform = options.platform ?? process.platform
|
||||
const env = options.env ?? process.env
|
||||
const exists = options.exists ?? existsSync
|
||||
const explicit = options.explicit
|
||||
if (explicit !== undefined && explicit.trim() !== '') return explicit.trim()
|
||||
if (platform === 'win32') {
|
||||
const envShell = env.DSH_SIDEBAR_SHELL
|
||||
if (envShell !== undefined && envShell.trim() !== '') return envShell.trim()
|
||||
for (const dir of windowsPwshCandidateDirs(env)) {
|
||||
const candidate = join(dir, 'pwsh.exe')
|
||||
if (exists(candidate)) return candidate
|
||||
}
|
||||
return 'powershell.exe'
|
||||
}
|
||||
const envShell = env.SHELL
|
||||
if (envShell !== undefined && envShell.trim() !== '') return envShell.trim()
|
||||
// userInfo() throws when the uid has no passwd entry (rare chroots);
|
||||
// without a login shell there is nothing better than the bash default.
|
||||
try {
|
||||
|
||||
@@ -15,6 +15,7 @@ export type SidebarErrorCode =
|
||||
| 'fs-error'
|
||||
| 'git-error'
|
||||
| 'pty-error'
|
||||
| 'pty-deps-missing'
|
||||
| 'job-error'
|
||||
| 'settings-rejected'
|
||||
| 'settings-conflict'
|
||||
|
||||
@@ -155,7 +155,7 @@ describe('PluginListBody copy click (interactive)', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('both the name button and the jump button open the repo in a NEW browser tab (window.open, not a link)', () => {
|
||||
it('the name and jump buttons open each catalog entry in a NEW browser tab (window.open, not a link)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
const opened: Array<[string, string, string]> = []
|
||||
@@ -171,14 +171,21 @@ describe('PluginListBody copy click (interactive)', () => {
|
||||
act(() => {
|
||||
root.render(createElement(PluginListBody, { service, kind: 'viewer' }))
|
||||
})
|
||||
// Two window.open buttons per catalog entry — the name button and the
|
||||
// jump button. The expected count is catalog-driven so adding or
|
||||
// removing recommended plugins keeps the test honest instead of
|
||||
// pinning a hardcoded total.
|
||||
const jumpButtons = [...container.querySelectorAll('button[aria-label^="Open:"]')]
|
||||
expect(jumpButtons).toHaveLength(2) // the name + the jump button
|
||||
act(() => { (jumpButtons[0] as HTMLButtonElement).click() })
|
||||
act(() => { (jumpButtons[1] as HTMLButtonElement).click() })
|
||||
expect(opened).toEqual([
|
||||
[officeEntry.url, '_blank', 'noopener'],
|
||||
[officeEntry.url, '_blank', 'noopener'],
|
||||
])
|
||||
expect(jumpButtons).toHaveLength(builtinViewerPlugins.length * 2)
|
||||
for (const button of jumpButtons) {
|
||||
act(() => { (button as HTMLButtonElement).click() })
|
||||
}
|
||||
expect(opened).toEqual(
|
||||
builtinViewerPlugins.flatMap((entry) => [
|
||||
[entry.url, '_blank', 'noopener'],
|
||||
[entry.url, '_blank', 'noopener'],
|
||||
]),
|
||||
)
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
allLeaves, makeDefaultState, openTabInActivePane, reconcileAgentTerminals,
|
||||
tabOpenIn, toggleBottomPanel,
|
||||
} from '../src/client/state.ts'
|
||||
|
||||
describe('agent terminal reconciliation', () => {
|
||||
it('adds tabs for new agent terminals', () => {
|
||||
let s = makeDefaultState(400, true)
|
||||
s = reconcileAgentTerminals(s, [
|
||||
{ uuid: 'aaa-111', title: 'dev server' },
|
||||
{ uuid: 'bbb-222', title: 'python repl' },
|
||||
])
|
||||
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
|
||||
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(true)
|
||||
// The titles are preserved.
|
||||
const tabs = allLeaves(s.splits).flatMap(leaf => leaf.tabs)
|
||||
const agentTab = tabs.find(t => t.id === 'agent:aaa-111')
|
||||
expect(agentTab?.title).toBe('dev server')
|
||||
expect(agentTab?.type).toBe('terminal')
|
||||
})
|
||||
|
||||
it('removes tabs for agent terminals that vanished from the server list', () => {
|
||||
let s = makeDefaultState(400, true)
|
||||
s = reconcileAgentTerminals(s, [
|
||||
{ uuid: 'aaa-111', title: 'keep' },
|
||||
{ uuid: 'bbb-222', title: 'remove' },
|
||||
])
|
||||
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(true)
|
||||
// Next push drops bbb-222.
|
||||
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'keep' }])
|
||||
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
|
||||
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(false)
|
||||
})
|
||||
|
||||
it('is a no-op (same reference) when the lists already match', () => {
|
||||
let s = makeDefaultState(400, true)
|
||||
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable' }])
|
||||
const next = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable' }])
|
||||
expect(next).toBe(s)
|
||||
})
|
||||
|
||||
it('does not touch non-agent terminal tabs', () => {
|
||||
let s = makeDefaultState(400, true)
|
||||
s = openTabInActivePane(s, { id: 'terminal:1', type: 'terminal', title: 'UI Tab' })
|
||||
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'agent' }])
|
||||
expect(tabOpenIn(s, 'terminal:1')).toBe(true)
|
||||
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
|
||||
})
|
||||
|
||||
it('handles an empty server list (removes all agent tabs)', () => {
|
||||
let s = makeDefaultState(400, true)
|
||||
s = reconcileAgentTerminals(s, [
|
||||
{ uuid: 'aaa-111', title: 'a' },
|
||||
{ uuid: 'bbb-222', title: 'b' },
|
||||
])
|
||||
s = reconcileAgentTerminals(s, [])
|
||||
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(false)
|
||||
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(false)
|
||||
})
|
||||
|
||||
it('lands new agent terminals in the active tree (bottom panel pane)', () => {
|
||||
let s = makeDefaultState(400, true)
|
||||
s = toggleBottomPanel(s)
|
||||
const bottomPane = (s.bottomSplits as { id: string }).id
|
||||
s = { ...s, activePane: bottomPane }
|
||||
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'dev server' }])
|
||||
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
|
||||
expect(allLeaves(s.bottomSplits).flatMap(l => l.tabs).some(t => t.id === 'agent:aaa-111')).toBe(true)
|
||||
expect(allLeaves(s.splits).flatMap(l => l.tabs).some(t => t.id === 'agent:aaa-111')).toBe(false)
|
||||
})
|
||||
})
|
||||
+22
-6
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Built-in registration tests: the plugin registers 7 tabs and 6 file
|
||||
* Built-in registration tests: the plugin registers 6 tabs and 6 file
|
||||
* viewers through the same service external plugins use (dogfooding);
|
||||
* the catch-all `code` viewer, the NUL-sniffing `binary-download` viewer,
|
||||
* and the html sandbox settings pin the registry's behavior. (Office
|
||||
@@ -24,21 +24,24 @@ function setup(): { service: ReturnType<typeof createBetterSidebarService>; stor
|
||||
}
|
||||
|
||||
describe('built-in tab registrations', () => {
|
||||
it('registers the 7 built-in tabs', () => {
|
||||
it('registers the 6 built-in tabs', () => {
|
||||
const { service } = setup()
|
||||
expect(service.getTabs().map(t => t.id).sort()).toEqual(
|
||||
['browser', 'diff', 'editor', 'explorer', 'git', 'subagent', 'terminal'],
|
||||
['browser', 'diff', 'editor', 'git', 'subagent', 'terminal'],
|
||||
)
|
||||
})
|
||||
|
||||
it('editor and diff are hidden from the + menu (opened by file-open / git view)', () => {
|
||||
it('only diff is hidden from the + menu; editor is the visible files window (order 10)', () => {
|
||||
const { service } = setup()
|
||||
expect(service.getTabs().filter(t => t.hidden).map(t => t.id).sort()).toEqual(['diff', 'editor'])
|
||||
expect(service.getTabs().filter(t => t.hidden).map(t => t.id)).toEqual(['diff'])
|
||||
const editor = service.getTab('editor')
|
||||
expect(editor?.hidden).toBe(false)
|
||||
expect(editor?.order).toBe(10)
|
||||
})
|
||||
|
||||
it('single-instance tabs use the single sugar', () => {
|
||||
const { service } = setup()
|
||||
for (const id of ['explorer', 'git', 'subagent']) {
|
||||
for (const id of ['git', 'subagent']) {
|
||||
expect(service.getTab(id)?.single).toBe(true)
|
||||
}
|
||||
})
|
||||
@@ -49,6 +52,19 @@ describe('built-in tab registrations', () => {
|
||||
expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent', 'autoOpenJobs'])
|
||||
})
|
||||
|
||||
it('the editor tab declares its merged-mode (embedded file tree) setting', () => {
|
||||
const { service } = setup()
|
||||
const toggles = service.getTab('editor')?.settings?.toggles ?? []
|
||||
expect(toggles.map(t => t.key)).toEqual(['editorExplorer'])
|
||||
expect(toggles[0]?.title).toBeDefined()
|
||||
expect(toggles[0]?.desc).toBeDefined()
|
||||
// The merged mode is an iconed select (merged vs separate), not a switch.
|
||||
expect(toggles[0]?.type).toBe('select')
|
||||
const options = toggles[0]?.options ?? []
|
||||
expect(options.map(o => o.value)).toEqual([true, false])
|
||||
expect(options.every(o => o.icon !== undefined && o.title !== undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('the terminal tab declares the model terminal-tools, auto-terminal and custom-font settings', () => {
|
||||
const { service } = setup()
|
||||
const toggles = service.getTab('terminal')?.settings?.toggles ?? []
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Drag-layout lane: the width drag must track the app shell 1:1 — the
|
||||
* regression test for issue #92 ("主会话框左右抖动").
|
||||
*
|
||||
* The layout push squeezes `#root` via `margin-right: var(--dsh-sidebar-width)`
|
||||
* (layout.css), and layout.css disables that margin's transition while a drag
|
||||
* is live via `body[data-dsh-sidebar-dragging]`. If the transition stays
|
||||
* active during the drag (or the conversation lags the panel edge), the
|
||||
* conversation visibly shakes at pointer cadence. This spec drives a real
|
||||
* pointer drag on the width strip while a requestAnimationFrame sampler
|
||||
* records, per frame:
|
||||
*
|
||||
* - the strip's x (the panel edge),
|
||||
* - the conversation column's right edge (`#root`'s margin push lands
|
||||
* exactly there),
|
||||
* - whether `body[data-dsh-sidebar-dragging]` is set,
|
||||
* - `#root`'s computed transition property/duration.
|
||||
*
|
||||
* Then it asserts the drag contract:
|
||||
* 1. the dragging attribute is present during the drag;
|
||||
* 2. `#root`'s transition is disabled (none) during the drag;
|
||||
* 3. the conversation edge follows the panel edge monotonically (no
|
||||
* oscillation) and 1:1 (total travel within a rounding epsilon).
|
||||
*
|
||||
* The server is booted by scripts/e2e-mount.sh; this spec only loads the
|
||||
* page (same contract as mount.e2e.ts, using its own workspace so the two
|
||||
* lanes never race on seeding).
|
||||
*/
|
||||
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)')
|
||||
}
|
||||
|
||||
/** This lane's own workspace (distinct from mount.e2e.ts's, lanes run serially
|
||||
* but against the same server — never share seed paths). */
|
||||
const WORKSPACE_PATH = process.env.DSH_E2E_DRAG_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-drag-workspace')
|
||||
|
||||
let api: APIRequestContext
|
||||
|
||||
/** Seed one workspace + one session through the host's unary RPC surface. */
|
||||
async function seedSession(): Promise<void> {
|
||||
mkdirSync(WORKSPACE_PATH, { recursive: true })
|
||||
writeFileSync(join(WORKSPACE_PATH, 'seed.txt'), 'drag lane\n')
|
||||
const workspace = await api.post(`${BASE_URL}/api/workspace.create`, {
|
||||
data: { type: 'client-request', rpcId: 'e2e-drag-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-drag-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()
|
||||
})
|
||||
|
||||
interface FrameSample {
|
||||
t: number
|
||||
stripX: number
|
||||
convoRight: number
|
||||
dragging: boolean
|
||||
transitionProperty: string
|
||||
transitionDuration: string
|
||||
}
|
||||
|
||||
test('width drag tracks the shell 1:1 with transitions disabled (issue #92)', async ({ page }) => {
|
||||
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 })
|
||||
|
||||
// Dismiss whatever onboarding takeover is present (same dance as the mount
|
||||
// lane), so the pointer can reach the strip without a masking overlay.
|
||||
try {
|
||||
await expect
|
||||
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
|
||||
.toBeGreaterThan(0)
|
||||
} catch {
|
||||
console.warn('[e2e-drag] no onboarding takeover appeared; proceeding')
|
||||
}
|
||||
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 a takeover stacked above; retry in the next round.
|
||||
}
|
||||
}
|
||||
if (!dismissed) break
|
||||
}
|
||||
|
||||
// openByDefault defaults OFF: a fresh session's panel starts collapsed, and
|
||||
// the collapsed layout push still writes `--dsh-sidebar-width: 0px` — so the
|
||||
// geometry checks below are meaningless until the panel is expanded through
|
||||
// the toggle cluster.
|
||||
const expandButton = sidebar.getByRole('button', { name: 'Expand sidebar' })
|
||||
await expect(expandButton, 'the collapsed toggle cluster must offer the expand button').toHaveCount(1)
|
||||
await expandButton.click()
|
||||
|
||||
// The layout push variable becomes live (non-zero) once the panel opens
|
||||
// (session activation lags the shell render).
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const value = await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
|
||||
return value !== '' && value !== '0px'
|
||||
}, { timeout: 90_000 })
|
||||
.toBe(true)
|
||||
|
||||
// The width drag strip is the panel's left-edge hit strip. There is no
|
||||
// dedicated hook (the skinning contract is token-driven), so locate it
|
||||
// semantically among the sidebar's `cursor: col-resize` elements — the files
|
||||
// window's tree-dock handle matches too, but the panel strip is always the
|
||||
// LEFTMOST one (the dock sits at the panel's right edge).
|
||||
const locateStrip = `(() => {
|
||||
const host = document.querySelector('[data-dsh-better-sidebar]')
|
||||
if (host === null) return null
|
||||
const boxes = [...host.querySelectorAll('*')]
|
||||
.filter(el => getComputedStyle(el).cursor === 'col-resize')
|
||||
.map(el => el.getBoundingClientRect())
|
||||
.filter(r => r.width > 0 && r.height > 0)
|
||||
.sort((a, b) => a.x - b.x)
|
||||
const r = boxes[0]
|
||||
if (r === undefined) return null
|
||||
const varWidth = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
|
||||
return {
|
||||
x: r.x, y: r.y, width: r.width, height: r.height,
|
||||
varWidth: Number.isNaN(varWidth) ? 0 : varWidth,
|
||||
innerWidth: window.innerWidth,
|
||||
}
|
||||
})()`
|
||||
type StripBox = { x: number; y: number; width: number; height: number; varWidth: number; innerWidth: number }
|
||||
// The panel SLIDES IN from the right on expand: locating the strip before
|
||||
// the open transition settles captures a mid-animation box at the viewport
|
||||
// edge, and the drag lands off-panel. Wait until the strip sits at the
|
||||
// pushed layout edge (its right edge ≈ innerWidth - the push variable).
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await page.evaluate<StripBox | null>(locateStrip)
|
||||
if (box === null) return false
|
||||
return Math.abs((box.x + box.width) - (box.innerWidth - box.varWidth)) <= 8
|
||||
}, { timeout: 30_000 })
|
||||
.toBe(true)
|
||||
const stripBox = await page.evaluate<StripBox | null>(locateStrip)
|
||||
expect(stripBox, 'the width drag strip must be present (cursor: col-resize)').not.toBeNull()
|
||||
|
||||
// Instrument a per-frame sampler BEFORE the drag begins.
|
||||
await page.evaluate(() => {
|
||||
type Sample = FrameSample
|
||||
const samples: Sample[] = []
|
||||
// Same rule as the strip locator above: the LEFTMOST col-resize element
|
||||
// inside the sidebar host (the tree-dock handle is further right).
|
||||
const host = document.querySelector('[data-dsh-better-sidebar]')
|
||||
const strip = host === null
|
||||
? null
|
||||
: [...host.querySelectorAll<HTMLElement>('*')]
|
||||
.filter(el => getComputedStyle(el).cursor === 'col-resize')
|
||||
.sort((a, b) => a.getBoundingClientRect().x - b.getBoundingClientRect().x)[0]
|
||||
// The conversation column: the grid item the layout push squeezes (the
|
||||
// same nth-child(2) layout.css targets for the vertical push; its right
|
||||
// edge is where the width push lands).
|
||||
const center = document.querySelector('#root > div[data-slot="root"] > div > div:nth-child(2)')
|
||||
const root = document.querySelector('#root') as HTMLElement
|
||||
const loop = (): void => {
|
||||
const s = strip?.getBoundingClientRect() ?? { left: 0 }
|
||||
const c = center?.getBoundingClientRect() ?? { left: 0, right: 0 }
|
||||
const cs = getComputedStyle(root)
|
||||
samples.push({
|
||||
t: performance.now(),
|
||||
stripX: s.left,
|
||||
convoRight: c.right,
|
||||
dragging: document.body.hasAttribute('data-dsh-sidebar-dragging'),
|
||||
transitionProperty: cs.transitionProperty,
|
||||
transitionDuration: cs.transitionDuration,
|
||||
})
|
||||
requestAnimationFrame(loop)
|
||||
}
|
||||
requestAnimationFrame(loop)
|
||||
;(window as unknown as { __dragSamples: Sample[] }).__dragSamples = samples
|
||||
})
|
||||
|
||||
const startX = stripBox!.x + stripBox!.width / 2
|
||||
const startY = stripBox!.y + Math.min(120, stripBox!.height / 2 + 60)
|
||||
|
||||
await page.mouse.move(startX, startY)
|
||||
await page.mouse.down()
|
||||
// Drag LEFT in steps (widens the panel): the conversation edge must move
|
||||
// LEFT in lockstep with the panel edge, monotonically, no oscillation.
|
||||
for (let i = 1; i <= 14; i++) {
|
||||
await page.mouse.move(startX - i * 10, startY, { steps: 2 })
|
||||
await page.waitForTimeout(40)
|
||||
}
|
||||
await page.mouse.up()
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
const samples = await page.evaluate(
|
||||
() => (window as unknown as { __dragSamples: FrameSample[] }).__dragSamples,
|
||||
)
|
||||
expect(samples.length, 'the frame sampler must have collected frames').toBeGreaterThan(20)
|
||||
|
||||
// The drag must actually have moved the panel (sanity: the store committed).
|
||||
const first = samples.find(s => s.dragging)
|
||||
const last = [...samples].reverse().find(s => s.dragging)
|
||||
expect(first, 'the dragging attribute must appear during the drag').toBeDefined()
|
||||
expect(last!.stripX, 'the panel edge must have moved during the drag').toBeLessThan(first!.stripX - 40)
|
||||
// The conversation-column selector must have matched (the push lands on it).
|
||||
expect(last!.convoRight, 'the conversation edge must have moved with the drag').toBeLessThan(first!.convoRight - 40)
|
||||
|
||||
// Contract 1 + 2: while dragging, the body attribute is set and #root's
|
||||
// margin transition is disabled (computed `transition: none` reads as
|
||||
// transition-property "none" with 0s duration; the non-dragging rule would
|
||||
// compute to "margin-right" with the theme duration).
|
||||
const draggingSamples = samples.filter(s => s.dragging)
|
||||
expect(draggingSamples.length).toBeGreaterThan(5)
|
||||
for (const sample of draggingSamples) {
|
||||
expect(sample.transitionProperty, 'the margin transition must be off while dragging').toBe('none')
|
||||
expect(sample.transitionDuration, 'the margin transition must be off while dragging').toBe('0s')
|
||||
}
|
||||
|
||||
// Contract 3: monotonic, 1:1 tracking. During a leftward drag both the
|
||||
// strip x and the conversation right edge decrease; allow one frame of
|
||||
// rAF-batching staleness (0 delta), never a reversal.
|
||||
const tracked = draggingSamples.filter(s => s.t > first!.t)
|
||||
for (let i = 1; i < tracked.length; i++) {
|
||||
const stripDelta = tracked[i]!.stripX - tracked[i - 1]!.stripX
|
||||
const convoDelta = tracked[i]!.convoRight - tracked[i - 1]!.convoRight
|
||||
expect(stripDelta, 'the strip must move left during the drag').toBeLessThanOrEqual(2)
|
||||
expect(
|
||||
convoDelta,
|
||||
`conversation edge reversed while dragging (jitter): strip ${stripDelta}px, conversation ${convoDelta}px`,
|
||||
).toBeLessThanOrEqual(2)
|
||||
}
|
||||
// Total travel in lockstep (rounding + one-frame staleness tolerance).
|
||||
const stripTravel = first!.stripX - last!.stripX
|
||||
const convoTravel = first!.convoRight - last!.convoRight
|
||||
expect(Math.abs(convoTravel - stripTravel), 'conversation must track the panel edge 1:1').toBeLessThanOrEqual(8)
|
||||
})
|
||||
+64
-22
@@ -13,10 +13,13 @@
|
||||
* 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.
|
||||
* 4. expands the collapsed panel (openByDefault defaults off), sweeps every
|
||||
* built-in tab (Files / Source Control / Tasks / Terminal / Browser) —
|
||||
* including the lazily-fetched terminal chunk — and then opens a seeded
|
||||
* file through the Files window's tree (in-place mode: the seeded home
|
||||
* tab itself switches to the file, no new tab), while a response wait
|
||||
* armed before goto proves the lazily-fetched editor chunk
|
||||
* (client-editor.js) loaded.
|
||||
*
|
||||
* 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
|
||||
@@ -35,8 +38,8 @@ if (!BASE_URL) {
|
||||
/** 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. */
|
||||
/** A file seeded into the workspace, opened through the Files window's tree to
|
||||
* exercise the file-open path (editor chunk = client-editor.js). */
|
||||
const SEEDED_FILE = 'hello.txt'
|
||||
|
||||
/**
|
||||
@@ -47,7 +50,7 @@ const SEEDED_FILE = 'hello.txt'
|
||||
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']
|
||||
const BUILTIN_TABS = ['Files', 'Source Control', 'Tasks', 'Terminal', 'Browser']
|
||||
|
||||
let api: APIRequestContext
|
||||
|
||||
@@ -91,6 +94,15 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
|
||||
|
||||
// Load the shell. The app renders into #root; the plugin appends its own
|
||||
// [data-dsh-better-sidebar] host once its client half activates.
|
||||
//
|
||||
// The editor chunk (client-editor.js) loads as soon as ANY files-window tab
|
||||
// renders — the seeded home tab mounts the moment the panel expands, long
|
||||
// before the tree click below — so the response wait must be armed BEFORE
|
||||
// goto, or it misses the fetch and times out.
|
||||
const editorChunk = page.waitForResponse(
|
||||
(response) => response.url().includes('/sidebar/bundle/editor.js'),
|
||||
{ timeout: 120_000 },
|
||||
)
|
||||
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]')
|
||||
@@ -134,6 +146,22 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
|
||||
const tabBar = sidebar.locator('[title]')
|
||||
await expect(tabBar.first()).toBeAttached({ timeout: 90_000 })
|
||||
|
||||
// openByDefault defaults OFF: a fresh session's panel starts collapsed.
|
||||
// Expand it through the toggle cluster before the layout push can apply.
|
||||
const expandButton = sidebar.getByRole('button', { name: 'Expand sidebar' })
|
||||
await expect(expandButton, 'the collapsed toggle cluster must offer the expand button').toHaveCount(1)
|
||||
await expandButton.click()
|
||||
|
||||
// The skinning contract is token-driven (AGENTS.md §8): the panels consume
|
||||
// `--dsw-alias-bg-layer-1`, so switching a skin re-skins the sidebar with
|
||||
// no per-skin code. The layout push variable must be live once the panel
|
||||
// mounts (its absence would mean the panel never opened with the session).
|
||||
await expect
|
||||
.poll(async () => (
|
||||
await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
|
||||
), { timeout: 90_000 })
|
||||
.not.toBe('')
|
||||
|
||||
// Crash-marker assertions shared by every step.
|
||||
const assertNoCrash = async (): Promise<void> => {
|
||||
await expect
|
||||
@@ -169,22 +197,36 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
|
||||
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()
|
||||
// The editor chunk (client-editor.js) only loads when a files-window tab
|
||||
// renders. Exercise the file-open path explicitly through the Files window's
|
||||
// own tree: the seeded home tab ("Files") is already open with its tree
|
||||
// panel pinned — activate it from the tab strip, open the seeded file, and
|
||||
// require the chunk round-trip (armed before goto), so a missing/corrupt
|
||||
// editor chunk fails the lane.
|
||||
// Tab-strip tabs carry `draggable="true"`; the always-mounted (hidden)
|
||||
// bottom panel's empty-pane welcome cards repeat the + menu labels with
|
||||
// `title="Files"`, so a bare `[title="Files"]` match is ambiguous.
|
||||
const filesTab = sidebar.locator('[title="Files"][draggable="true"]').first()
|
||||
await expect(filesTab, 'the seeded files-window home tab must be in the tab strip').toHaveCount(1)
|
||||
await filesTab.click()
|
||||
// Inactive tabs stay mounted (display:none); only the ACTIVE files
|
||||
// window's embedded tree is visible — match the visible row.
|
||||
const fileRow = sidebar.locator(`[role="button"][title$="${SEEDED_FILE}"]:visible`)
|
||||
await expect(fileRow, `the seeded "${SEEDED_FILE}" file must appear in the files window's tree`).toHaveCount(1, { timeout: 30_000 })
|
||||
// Click near the row's LEFT edge: hovering reveals an @-reference button at
|
||||
// the row's right end, and a center click on a narrow dock lands on it
|
||||
// (referencing the file into the composer instead of opening it).
|
||||
await fileRow.click({ position: { x: 8, y: 8 } })
|
||||
await editorChunk
|
||||
// In-place mode (editorExplorer default): the SAME tab switches to the
|
||||
// file — its title is rewritten, no new tab appears. One "Files" tab
|
||||
// remains: the second files window the sweep opened via the + menu.
|
||||
await expect(
|
||||
sidebar.locator('[title="Files"][draggable="true"]'),
|
||||
'in-place mode rewrites the activated home tab instead of opening a new one',
|
||||
).toHaveCount(1)
|
||||
const pathInput = sidebar.locator('input[placeholder^="File path"]:visible')
|
||||
await expect(pathInput, 'the files window header path input shows the opened file').toHaveValue(new RegExp(`${SEEDED_FILE}$`))
|
||||
await page.waitForTimeout(1_500)
|
||||
await assertNoCrash()
|
||||
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* EditorHost (the files window): in merged (in-place) mode a path-less tab
|
||||
* renders the empty-state hint with the tree dock open, and the header's
|
||||
* tree toggle persists its flag through ctx.betterSidebar.updateTab
|
||||
* (meta.treeOpen rides the tab's persisted layout). The editorExplorer pref
|
||||
* controls FILE-OPEN behavior — in-place rewrites the current tab via
|
||||
* updateTab, split opens a per-path dedupe tab via openSidebarFile — and in
|
||||
* split mode a PATH-LESS window becomes the standalone explorer (tree panel
|
||||
* only, no editor chrome); file tabs keep the full chrome in both modes.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createElement, useEffect } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import type { Context } from '../src/context-types.ts'
|
||||
import { EditorHost } from '../src/client/EditorHost.tsx'
|
||||
import { createBetterSidebarService } from '../src/client/service.ts'
|
||||
import { allLeaves, createSidebarStore, type SidebarTab } from '../src/client/state.ts'
|
||||
|
||||
// The act() environment flag (React 18.2 reads it before flushing effects).
|
||||
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
/** A store with the seeded editor-home tab (default prefs: in-place mode). */
|
||||
function setup(): {
|
||||
store: ReturnType<typeof createSidebarStore>
|
||||
ctx: Context
|
||||
homeTab: () => SidebarTab
|
||||
} {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
// The openTab path needs a registered editor descriptor (dedupe by path).
|
||||
service.registerTab({ id: 'editor', title: 'Editor', dedupeKey: (tab) => tab.path, component: () => null })
|
||||
store.setSession('editor-home-session')
|
||||
const homeTab = (): SidebarTab =>
|
||||
allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
.find(tab => tab.type === 'editor' && tab.path === undefined)!
|
||||
// openSidebarFile reads the session cwd from ctx.sessions.
|
||||
const sessionsSnapshot = { byId: { 'editor-home-session': { cwd: '/tmp' } }, current: 'editor-home-session' }
|
||||
const ctx = {
|
||||
betterSidebar: service,
|
||||
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } },
|
||||
} as unknown as Context
|
||||
return { store, ctx, homeTab }
|
||||
}
|
||||
|
||||
/** Mount the host for one tab; returns the container and an unmount helper. */
|
||||
function mountHost(ctx: Context, store: ReturnType<typeof createSidebarStore>, tab: () => SidebarTab): {
|
||||
container: HTMLDivElement
|
||||
rerender: () => void
|
||||
unmount: () => void
|
||||
} {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root = createRoot(container)
|
||||
const render = (): void => {
|
||||
root.render(createElement(EditorHost, {
|
||||
ctx,
|
||||
store,
|
||||
scope: { sessionId: 'editor-home-session' },
|
||||
tab: tab(),
|
||||
expanded: [],
|
||||
onToggleDir: () => {},
|
||||
onReferenceFile: () => {},
|
||||
}))
|
||||
}
|
||||
act(render)
|
||||
return {
|
||||
container,
|
||||
// The real app re-renders the host with the fresh tab on every store
|
||||
// change (Sidebar subscribes); mirror that after mutating the store.
|
||||
rerender: () => { act(render) },
|
||||
unmount: () => {
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Type into the controlled path input (native setter) and press Enter. */
|
||||
function typeAndCommit(input: HTMLInputElement, value: string): void {
|
||||
act(() => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value)
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
act(() => {
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
|
||||
})
|
||||
}
|
||||
|
||||
describe('EditorHost (files window)', () => {
|
||||
it('a path-less tab renders the empty-state hint with the tree panel open', () => {
|
||||
const { store, ctx, homeTab } = setup()
|
||||
const { container, unmount } = mountHost(ctx, store, homeTab)
|
||||
try {
|
||||
const html = container.innerHTML
|
||||
// The empty-state hint renders instead of the viewer loading flow.
|
||||
expect(html).toContain('Pick a file from the tree panel')
|
||||
expect(html).not.toContain('Loading…')
|
||||
// The header carries the path input and the pressed tree toggle; the
|
||||
// docked panel (search box) is open by default for path-less tabs.
|
||||
expect(container.querySelector('input')).not.toBeNull()
|
||||
const toggle = container.querySelector('button[aria-pressed]')
|
||||
expect(toggle?.getAttribute('aria-pressed')).toBe('true')
|
||||
// No cwd: the embedded tree renders its no-session placeholder
|
||||
// instead of touching the network.
|
||||
expect(html).toContain('Select a conversation')
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('the tree toggle persists meta.treeOpen through updateTab', () => {
|
||||
const { store, ctx, homeTab } = setup()
|
||||
expect(homeTab().meta).toEqual({ treeOpen: true })
|
||||
const { container, rerender, unmount } = mountHost(ctx, store, homeTab)
|
||||
try {
|
||||
act(() => {
|
||||
container.querySelector('button[aria-pressed]')!
|
||||
.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
expect(homeTab().meta).toEqual({ treeOpen: false })
|
||||
// The store change re-renders the host with the fresh tab (Sidebar's
|
||||
// subscription in the real app); the second click flips it back.
|
||||
rerender()
|
||||
expect(container.querySelector('button[aria-pressed]')?.getAttribute('aria-pressed')).toBe('false')
|
||||
act(() => {
|
||||
container.querySelector('button[aria-pressed]')!
|
||||
.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
expect(homeTab().meta).toEqual({ treeOpen: true })
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('in-place mode: the path input Enter switches the CURRENT tab (stable id, meta kept)', () => {
|
||||
const { store, ctx, homeTab } = setup()
|
||||
const { container, unmount } = mountHost(ctx, store, homeTab)
|
||||
try {
|
||||
const before = homeTab()
|
||||
typeAndCommit(container.querySelector('input')!, '/tmp/a.ts')
|
||||
// The same tab id now carries the file (homeTab's path-less finder no
|
||||
// longer matches — look the tab up by id).
|
||||
const after = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
.find(tab => tab.id === before.id)!
|
||||
expect(after.id).toBe(before.id)
|
||||
expect(after.path).toBe('/tmp/a.ts')
|
||||
expect(after.title).toBe('a.ts')
|
||||
expect(after.meta).toEqual({ treeOpen: true })
|
||||
// No new tab landed.
|
||||
expect(allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)).toHaveLength(1)
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('split mode: a file tab\'s path input Enter opens a NEW per-path tab; the source tab keeps its path', () => {
|
||||
const { store, ctx } = setup()
|
||||
store.setPrefs({ ...store.getPrefs(), editorExplorer: false })
|
||||
ctx.betterSidebar.openTab({ type: 'editor', title: 'a.ts', path: '/tmp/a.ts', id: 'editor:/tmp/a.ts' })
|
||||
const fileTab = (): SidebarTab =>
|
||||
allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
.find(tab => tab.path === '/tmp/a.ts')!
|
||||
const { container, unmount } = mountHost(ctx, store, fileTab)
|
||||
try {
|
||||
typeAndCommit(container.querySelector('input[placeholder^="File path"]')!, '/tmp/b.ts')
|
||||
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
// home + a.ts + b.ts
|
||||
expect(tabs).toHaveLength(3)
|
||||
expect(fileTab().path).toBe('/tmp/a.ts')
|
||||
const opened = tabs.find(tab => tab.path === '/tmp/b.ts')!
|
||||
expect(opened.type).toBe('editor')
|
||||
expect(opened.title).toBe('b.ts')
|
||||
expect(opened.id).toBe('editor:/tmp/b.ts')
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('split mode: the path-less window is the standalone explorer (tree only, no chrome)', () => {
|
||||
const { store, ctx, homeTab } = setup()
|
||||
store.setPrefs({ ...store.getPrefs(), editorExplorer: false })
|
||||
const { container, unmount } = mountHost(ctx, store, homeTab)
|
||||
try {
|
||||
// No editor chrome: no path input, no tree toggle, no resize handle.
|
||||
expect(container.querySelector('input[placeholder^="File path"]')).toBeNull()
|
||||
expect(container.querySelector('button[aria-pressed]')).toBeNull()
|
||||
expect(container.querySelector('[role="separator"]')).toBeNull()
|
||||
// The tree panel fills the whole window — its search box is the only
|
||||
// input, and (no cwd) the tree shows its no-session placeholder.
|
||||
expect(container.querySelector('input[placeholder^="Search files"]')).not.toBeNull()
|
||||
expect(container.innerHTML).toContain('Select a conversation')
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('split mode: a file tab keeps the full chrome (path input + tree toggle + dock)', () => {
|
||||
const { store, ctx } = setup()
|
||||
store.setPrefs({ ...store.getPrefs(), editorExplorer: false })
|
||||
ctx.betterSidebar.openTab({
|
||||
type: 'editor', title: 'a.ts', path: '/tmp/a.ts', id: 'editor:/tmp/a.ts', meta: { treeOpen: true },
|
||||
})
|
||||
const fileTab = (): SidebarTab =>
|
||||
allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
.find(tab => tab.path === '/tmp/a.ts')!
|
||||
const { container, unmount } = mountHost(ctx, store, fileTab)
|
||||
try {
|
||||
expect(container.querySelector('input[placeholder^="File path"]')).not.toBeNull()
|
||||
expect(container.querySelector('button[aria-pressed]')?.getAttribute('aria-pressed')).toBe('true')
|
||||
expect(container.querySelector('[role="separator"]')).not.toBeNull()
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('dragging the panel edge resizes the dock and persists meta.treeWidth on release', () => {
|
||||
const { store, ctx, homeTab } = setup()
|
||||
const { container, unmount } = mountHost(ctx, store, homeTab)
|
||||
try {
|
||||
const handle = container.querySelector('[role="separator"]')!
|
||||
expect(handle).not.toBeNull()
|
||||
// The dock starts at the default width.
|
||||
const dock = handle.parentElement!
|
||||
expect(dock.style.width).toBe('240px')
|
||||
// Drag the left edge LEFT by 100px → the right-docked panel widens.
|
||||
// Pointer capture keeps move/up on the handle (jsdom: MouseEvent with
|
||||
// pointer* type names; setPointerCapture is absent and skipped).
|
||||
act(() => {
|
||||
handle.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 300 }))
|
||||
handle.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 200 }))
|
||||
})
|
||||
expect(dock.style.width).toBe('340px')
|
||||
// Release: the drag state clears and the width persists on the tab.
|
||||
act(() => { handle.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 200 })) })
|
||||
expect(homeTab().meta).toEqual({ treeOpen: true, treeWidth: 340 })
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('the header hosts the viewer toolbar (mode toggle / dirty dot / save)', () => {
|
||||
const { store, ctx } = setup()
|
||||
const service = ctx.betterSidebar
|
||||
const calls: string[] = []
|
||||
// A viewer with a hoisted toolbar (the TextEditor contract): register
|
||||
// commands and report the state once on mount.
|
||||
service.registerFileViewer({
|
||||
id: 'test:fake',
|
||||
exts: ['fake'],
|
||||
fetchStrategy: 'none',
|
||||
component: (viewerProps) => {
|
||||
useEffect(() => {
|
||||
viewerProps.onToolbarControls?.({
|
||||
setMode: (next) => { calls.push(`mode:${next}`) },
|
||||
save: () => { calls.push('save') },
|
||||
})
|
||||
viewerProps.onToolbarState?.({ modes: true, mode: 'preview', dirty: true, editable: true, saveState: 'idle' })
|
||||
return () => { viewerProps.onToolbarControls?.(null) }
|
||||
}, [])
|
||||
return null
|
||||
},
|
||||
})
|
||||
service.openTab({ type: 'editor', title: 'x.fake', path: '/tmp/x.fake', id: 'editor:/tmp/x.fake' })
|
||||
const fileTab = (): SidebarTab =>
|
||||
allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
.find(tab => tab.path === '/tmp/x.fake')!
|
||||
const { container, unmount } = mountHost(ctx, store, fileTab)
|
||||
try {
|
||||
// Mode toggle + dirty dot + save button sit in the header row.
|
||||
const header = container.querySelector('input')!.parentElement!
|
||||
const buttons = [...header.querySelectorAll('button')]
|
||||
expect(buttons.map(b => b.textContent)).toContain('Preview')
|
||||
expect(buttons.map(b => b.textContent)).toContain('Edit')
|
||||
expect(header.querySelector('button[aria-label="Save"]')).not.toBeNull()
|
||||
expect(header.querySelector('[title="Unsaved"]')).not.toBeNull()
|
||||
// The header commands reach the viewer's registered controls.
|
||||
act(() => { buttons.find(b => b.textContent === 'Edit')!.click() })
|
||||
act(() => { header.querySelector<HTMLButtonElement>('button[aria-label="Save"]')!.click() })
|
||||
expect(calls).toEqual(['mode:edit', 'save'])
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isImageExt } from '../src/client/image-types.ts'
|
||||
import { extOf, languageKeyForExt } from '../src/client/lang.ts'
|
||||
import { isPdfExt } from '../src/client/pdf-types.ts'
|
||||
|
||||
describe('editor language mapping', () => {
|
||||
it('derives extensions from paths', () => {
|
||||
expect(extOf('/a/b/main.tsx')).toBe('tsx')
|
||||
expect(extOf('README.MD')).toBe('md')
|
||||
expect(extOf('/a/b/.gitignore')).toBe('gitignore')
|
||||
expect(extOf('noext')).toBe('')
|
||||
})
|
||||
|
||||
it('maps common extensions to languages and falls back to plain text', () => {
|
||||
expect(languageKeyForExt('tsx')).toBe('tsx')
|
||||
expect(languageKeyForExt('js')).toBe('js')
|
||||
expect(languageKeyForExt('py')).toBe('python')
|
||||
expect(languageKeyForExt('yaml')).toBe('yaml')
|
||||
expect(languageKeyForExt('sh')).toBe('shell')
|
||||
expect(languageKeyForExt('md')).toBe('md')
|
||||
expect(languageKeyForExt('cs')).toBe('csharp')
|
||||
expect(languageKeyForExt('kt')).toBe('kotlin')
|
||||
expect(languageKeyForExt('swift')).toBe('swift')
|
||||
expect(languageKeyForExt('txt')).toBeNull()
|
||||
expect(languageKeyForExt('log')).toBeNull()
|
||||
expect(languageKeyForExt('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pdf preview kind', () => {
|
||||
it('routes only .pdf to the browser-native preview', () => {
|
||||
expect(isPdfExt('.pdf')).toBe(true)
|
||||
expect(isPdfExt('.PDF')).toBe(false)
|
||||
expect(isPdfExt('.docx')).toBe(false)
|
||||
expect(isPdfExt('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('image preview kind', () => {
|
||||
it('routes supported image extensions before binary probing', () => {
|
||||
expect(isImageExt('.png')).toBe(true)
|
||||
expect(isImageExt('.jpg')).toBe(true)
|
||||
expect(isImageExt('.svg')).toBe(true)
|
||||
expect(isImageExt('.avif')).toBe(true)
|
||||
expect(isImageExt('.pdf')).toBe(false)
|
||||
expect(isImageExt('')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* fs-search: the host's recursive file-name search behind the editor side
|
||||
* panel's search box. Matches are case-insensitive name substrings, reported
|
||||
* RELATIVE to the root ('/'-separated); `.git` directories are skipped,
|
||||
* symlinked directories are never descended (cycle safety), and the
|
||||
* maxMatches/maxVisited budgets stop a runaway walk with `truncated: true`.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { searchFiles } from '../src/fs-search.ts'
|
||||
|
||||
/**
|
||||
* Symlink creation needs extra privileges on Windows; the symlink case skips
|
||||
* there rather than fails (mirror of the fs-tree symlink spec).
|
||||
*/
|
||||
const canSymlink = (() => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-search-probe-'))
|
||||
try {
|
||||
symlinkSync('target', join(dir, 'link'))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})()
|
||||
|
||||
/** A scratch tree: nested matches, a .git dir, and unrelated noise. */
|
||||
function makeFixture(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-search-'))
|
||||
mkdirSync(join(dir, 'src'))
|
||||
mkdirSync(join(dir, 'docs'))
|
||||
mkdirSync(join(dir, '.git'))
|
||||
mkdirSync(join(dir, '.git', 'objects'))
|
||||
writeFileSync(join(dir, 'README.md'), 'readme')
|
||||
writeFileSync(join(dir, 'src', 'Index.TS'), 'code')
|
||||
writeFileSync(join(dir, 'src', 'util.ts'), 'code')
|
||||
writeFileSync(join(dir, 'docs', 'guide.md'), 'doc')
|
||||
writeFileSync(join(dir, '.git', 'config'), 'git-internal')
|
||||
writeFileSync(join(dir, '.git', 'objects', 'readme-pack'), 'git-internal')
|
||||
return dir
|
||||
}
|
||||
|
||||
describe('fs-search', () => {
|
||||
it('matches name substrings and reports root-relative /-separated paths', async () => {
|
||||
const dir = makeFixture()
|
||||
try {
|
||||
const result = await searchFiles(dir, 'util')
|
||||
expect(result).toEqual({ matches: ['src/util.ts'], truncated: false })
|
||||
// A multi-level match list is sorted and relative (never absolute).
|
||||
const md = await searchFiles(dir, '.md')
|
||||
expect(md.truncated).toBe(false)
|
||||
expect(md.matches).toEqual(['README.md', 'docs/guide.md'])
|
||||
for (const match of md.matches) {
|
||||
expect(match.startsWith(dir)).toBe(false)
|
||||
expect(match).not.toContain('\\')
|
||||
}
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('matches case-insensitively on the entry name', async () => {
|
||||
const dir = makeFixture()
|
||||
try {
|
||||
expect((await searchFiles(dir, 'index.ts')).matches).toEqual(['src/Index.TS'])
|
||||
expect((await searchFiles(dir, 'INDEX.TS')).matches).toEqual(['src/Index.TS'])
|
||||
// Directory names match too (the client can hint where matches live).
|
||||
expect((await searchFiles(dir, 'SRC')).matches).toEqual(['src'])
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('never descends into .git directories', async () => {
|
||||
const dir = makeFixture()
|
||||
try {
|
||||
// 'readme' would hit .git/objects/readme-pack if the walk entered .git.
|
||||
expect((await searchFiles(dir, 'readme')).matches).toEqual(['README.md'])
|
||||
expect((await searchFiles(dir, 'config')).matches).toEqual([])
|
||||
expect((await searchFiles(dir, '.git')).matches).toEqual([])
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('an empty (or whitespace) query matches nothing without walking', async () => {
|
||||
const dir = makeFixture()
|
||||
try {
|
||||
expect(await searchFiles(dir, '')).toEqual({ matches: [], truncated: false })
|
||||
expect(await searchFiles(dir, ' ')).toEqual({ matches: [], truncated: false })
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(!canSymlink)('does not descend into symlinked directories (cycle safety)', async () => {
|
||||
const dir = makeFixture()
|
||||
try {
|
||||
// A link back to the root would loop forever if descended; a link to
|
||||
// src would duplicate its matches. Neither must be entered.
|
||||
symlinkSync(dir, join(dir, 'loop'))
|
||||
symlinkSync(join(dir, 'src'), join(dir, 'src-link'))
|
||||
const result = await searchFiles(dir, 'util')
|
||||
expect(result).toEqual({ matches: ['src/util.ts'], truncated: false })
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('stops with truncated: true when the match budget is exceeded', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-search-cap-'))
|
||||
try {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
writeFileSync(join(dir, `match-${index}.txt`), 'x')
|
||||
}
|
||||
const result = await searchFiles(dir, 'match', { maxMatches: 2 })
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.matches.length).toBe(2)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('stops with truncated: true when the visited budget is exceeded', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-search-visited-'))
|
||||
try {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
writeFileSync(join(dir, `file-${index}.txt`), 'x')
|
||||
}
|
||||
// The walk visits more entries than the budget allows and gives up.
|
||||
const result = await searchFiles(dir, 'nomatch', { maxVisited: 3 })
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.matches).toEqual([])
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('an unreadable root yields no matches instead of throwing', async () => {
|
||||
const dir = makeFixture()
|
||||
try {
|
||||
const missing = join(dir, 'does-not-exist')
|
||||
expect(await searchFiles(missing, 'x')).toEqual({ matches: [], truncated: false })
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* fs-tree symlink listing: the explorer must show a symlink as what it
|
||||
* points at (a symlink to a directory expands like a directory) and flag
|
||||
* links whose target is missing. The host listing keeps the probe cheap:
|
||||
* only entries that are actually symlinks are stat'ed.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { listDirectory } from '../src/fs-tree.ts'
|
||||
|
||||
/**
|
||||
* Symlink creation needs extra privileges on Windows; skip the suite there
|
||||
* rather than fail (mirror of the smoke spec's platform-tolerant fixtures).
|
||||
*/
|
||||
const canSymlink = (() => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-symlink-probe-'))
|
||||
try {
|
||||
symlinkSync('target', join(dir, 'link'))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})()
|
||||
|
||||
/** A scratch level with real entries plus three symlinks (dir/file/dangling). */
|
||||
function makeFixture(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-symlink-'))
|
||||
mkdirSync(join(dir, 'real-dir'))
|
||||
writeFileSync(join(dir, 'real-file.txt'), 'content')
|
||||
symlinkSync(join(dir, 'real-dir'), join(dir, 'link-to-dir'))
|
||||
symlinkSync(join(dir, 'real-file.txt'), join(dir, 'link-to-file'))
|
||||
symlinkSync(join(dir, 'missing-target'), join(dir, 'broken-link'))
|
||||
return dir
|
||||
}
|
||||
|
||||
describe.skipIf(!canSymlink)('fs-tree symlink listing', () => {
|
||||
it('reports a symlink to a directory as an expandable directory', async () => {
|
||||
const dir = makeFixture()
|
||||
try {
|
||||
const listing = await listDirectory(dir)
|
||||
const row = listing.entries.find(entry => entry.name === 'link-to-dir')
|
||||
expect(row).toMatchObject({ isDir: true, isSymlink: true, broken: false })
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('reports a symlink to a file as a file with a link badge', async () => {
|
||||
const dir = makeFixture()
|
||||
try {
|
||||
const listing = await listDirectory(dir)
|
||||
const row = listing.entries.find(entry => entry.name === 'link-to-file')
|
||||
expect(row).toMatchObject({ isDir: false, isSymlink: true, broken: false })
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('flags a dangling symlink as broken', async () => {
|
||||
const dir = makeFixture()
|
||||
try {
|
||||
const listing = await listDirectory(dir)
|
||||
const row = listing.entries.find(entry => entry.name === 'broken-link')
|
||||
expect(row).toMatchObject({ isDir: false, isSymlink: true, broken: true })
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves regular entries unmarked and sorts symlinked dirs with the directories', async () => {
|
||||
const dir = makeFixture()
|
||||
try {
|
||||
const listing = await listDirectory(dir)
|
||||
const realDir = listing.entries.find(entry => entry.name === 'real-dir')
|
||||
const realFile = listing.entries.find(entry => entry.name === 'real-file.txt')
|
||||
expect(realDir).toMatchObject({ isDir: true, isSymlink: false, broken: false })
|
||||
expect(realFile).toMatchObject({ isDir: false, isSymlink: false, broken: false })
|
||||
// Directory-first ordering counts a symlinked directory as a directory.
|
||||
const dirs = listing.entries.filter(entry => entry.isDir).map(entry => entry.name)
|
||||
expect(dirs).toEqual(['link-to-dir', 'real-dir'])
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('classifies every row correctly across a symlink-heavy level (bounded probe)', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-symlink-many-'))
|
||||
mkdirSync(join(dir, 'real-dir'))
|
||||
writeFileSync(join(dir, 'real-file.txt'), 'content')
|
||||
try {
|
||||
// More links than the probe concurrency cap exercises the worker pool;
|
||||
// every directory link must still classify as a directory and every
|
||||
// file link as a file (order is already checked by the sort assertion).
|
||||
for (let index = 0; index < 48; index += 1) {
|
||||
symlinkSync(join(dir, 'real-dir'), join(dir, `dir-link-${index}`))
|
||||
symlinkSync(join(dir, 'real-file.txt'), join(dir, `file-link-${index}`))
|
||||
}
|
||||
const listing = await listDirectory(dir)
|
||||
for (let index = 0; index < 48; index += 1) {
|
||||
expect(listing.entries.find(entry => entry.name === `dir-link-${index}`))
|
||||
.toMatchObject({ isDir: true, isSymlink: true, broken: false })
|
||||
expect(listing.entries.find(entry => entry.name === `file-link-${index}`))
|
||||
.toMatchObject({ isDir: false, isSymlink: true, broken: false })
|
||||
}
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolve } from 'node:path'
|
||||
import { compareEntries, isWithin, parentOf, requireAbsolute, rootLabel } from '../src/fs-tree.ts'
|
||||
import { isWin32 } from './platform.ts'
|
||||
|
||||
describe('fs-tree', () => {
|
||||
it('sorts directories first, then names case-insensitively', () => {
|
||||
const rows = [
|
||||
{ name: 'b.txt', path: '/x/b.txt', isDir: false, hidden: false, isSymlink: false, broken: false },
|
||||
{ name: 'A', path: '/x/A', isDir: true, hidden: false, isSymlink: false, broken: false },
|
||||
{ name: 'a.txt', path: '/x/a.txt', isDir: false, hidden: false, isSymlink: false, broken: false },
|
||||
{ name: '.hidden', path: '/x/.hidden', isDir: false, hidden: true, isSymlink: false, broken: false },
|
||||
]
|
||||
expect(rows.sort(compareEntries).map(row => row.name)).toEqual(['A', '.hidden', 'a.txt', 'b.txt'])
|
||||
})
|
||||
|
||||
it('derives root labels and parents (POSIX-style)', () => {
|
||||
// POSIX-style inputs behave identically on both platforms (win32 parses '/'
|
||||
// as a separator), so these assertions are platform-independent.
|
||||
expect(rootLabel('/Users/me/code')).toBe('code')
|
||||
expect(rootLabel('/')).toBe('/')
|
||||
expect(parentOf('/Users/me/code')).toBe('/Users/me')
|
||||
expect(parentOf('/')).toBeUndefined()
|
||||
})
|
||||
|
||||
it.skipIf(!isWin32)('derives root labels and parents for Windows drives', () => {
|
||||
expect(rootLabel('C:\\')).toBe('C:\\')
|
||||
expect(parentOf('C:\\')).toBeUndefined()
|
||||
expect(rootLabel('C:\\Users\\me')).toBe('me')
|
||||
expect(parentOf('C:\\Users\\me')).toBe('C:\\Users')
|
||||
})
|
||||
|
||||
it('accepts POSIX absolute paths and rejects relative ones', () => {
|
||||
// resolve() is platform-native: '/a/b' roots to the current drive on win32.
|
||||
expect(requireAbsolute('/a/b')).toBe(resolve('/a/b'))
|
||||
expect(() => requireAbsolute('a/b')).toThrow(/not an absolute path/)
|
||||
expect(() => requireAbsolute('../a')).toThrow(/not an absolute path/)
|
||||
})
|
||||
|
||||
// Windows-only path semantics — skipped (not silently passing) on POSIX:
|
||||
// drive letters and UNC shares are absolute on win32, drive-relative 'C:foo'
|
||||
// is not.
|
||||
describe.skipIf(!isWin32)('win32 path semantics', () => {
|
||||
it('accepts drive letters and normalizes their separators', () => {
|
||||
expect(requireAbsolute('C:/proj')).toBe('C:\\proj')
|
||||
expect(requireAbsolute('C:\\proj')).toBe('C:\\proj')
|
||||
// A drive path resolves against its own drive, never a bare root.
|
||||
expect(resolve('/a/b')).toMatch(/^[A-Za-z]:/)
|
||||
})
|
||||
|
||||
it('accepts UNC network shares in both separator styles', () => {
|
||||
expect(requireAbsolute('\\\\server\\share\\proj')).toBe('\\\\server\\share\\proj')
|
||||
expect(requireAbsolute('//server/share/proj')).toBe('\\\\server\\share\\proj')
|
||||
})
|
||||
|
||||
it('rejects drive-relative paths', () => {
|
||||
expect(() => requireAbsolute('C:proj')).toThrow(/not an absolute path/)
|
||||
})
|
||||
})
|
||||
|
||||
// POSIX-only path semantics — the reverse branch of the win32 suite: forms
|
||||
// the host can never access must be refused loudly instead of mangled.
|
||||
describe.skipIf(isWin32)('POSIX path semantics', () => {
|
||||
it('rejects Windows drive paths', () => {
|
||||
expect(() => requireAbsolute('C:/proj')).toThrow(/not an absolute path/)
|
||||
expect(() => requireAbsolute('C:\\proj')).toThrow(/not an absolute path/)
|
||||
})
|
||||
|
||||
it('rejects backslash UNC paths (not absolute on POSIX)', () => {
|
||||
expect(() => requireAbsolute('\\\\server\\share\\proj')).toThrow(/not an absolute path/)
|
||||
})
|
||||
})
|
||||
|
||||
it('isWithin tolerates separators and (on win32) letter case', () => {
|
||||
expect(isWithin('/work/proj', '/work/proj/src/a.ts')).toBe(true)
|
||||
expect(isWithin('/work/proj', '/work/proj')).toBe(true)
|
||||
expect(isWithin('/work/proj', '/work/proj2/a.ts')).toBe(false)
|
||||
expect(isWithin('/work/proj', '/other/a.ts')).toBe(false)
|
||||
// Mixed separators normalize on every platform.
|
||||
expect(isWithin('C:\\Users\\me', 'C:/Users/me/src/a.ts')).toBe(true)
|
||||
// Case sensitivity follows the platform's filesystem semantics (the
|
||||
// platform parameter makes both branches assertable on any host).
|
||||
expect(isWithin('C:\\Users\\Me', 'c:/users/me/file.png', 'win32')).toBe(true)
|
||||
expect(isWithin('/Users/Me', '/users/me/file.png', 'win32')).toBe(true)
|
||||
expect(isWithin('/Users/Me', '/users/me/file.png', 'linux')).toBe(false)
|
||||
expect(isWithin('/Users/Me', '/users/me/file.png', 'darwin')).toBe(false)
|
||||
// Windows drive-root containment.
|
||||
expect(isWithin('C:\\', 'C:\\Users\\me\\a.png', 'win32')).toBe(true)
|
||||
expect(isWithin('c:\\users', 'C:/USERS/me/b.png', 'win32')).toBe(true)
|
||||
// UNC network-share containment: the '//' share prefix must not defeat
|
||||
// the prefix test, and a sibling share must stay outside. The platform
|
||||
// parameter is injected, so these win32-semantics assertions run on
|
||||
// every host without any platform guard.
|
||||
expect(isWithin('\\\\server\\share\\proj', '\\\\server\\share\\proj\\src\\a.ts', 'win32')).toBe(true)
|
||||
expect(isWithin('\\\\server\\share\\proj', '\\\\server\\share\\proj2\\a.ts', 'win32')).toBe(false)
|
||||
expect(isWithin('\\\\server\\share\\proj', '\\\\other\\share\\a.ts', 'win32')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseUnifiedDiff } from '../src/client/DiffView.tsx'
|
||||
import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
|
||||
|
||||
describe('git parsing', () => {
|
||||
it('parses porcelain -z entries including renames', () => {
|
||||
const output = ['M src/a.ts', ' M src/b.ts', '?? src/c.ts', 'R src/new.ts', 'src/old.ts', ''].join('\0')
|
||||
const entries = parsePorcelainZ(output)
|
||||
expect(entries).toEqual([
|
||||
{ path: 'src/a.ts', xy: 'M ' },
|
||||
{ path: 'src/b.ts', xy: ' M' },
|
||||
{ path: 'src/c.ts', xy: '??' },
|
||||
{ path: 'src/new.ts', xy: 'R ' },
|
||||
])
|
||||
})
|
||||
|
||||
it('parses log rows with unit separators (full hash + refs)', () => {
|
||||
const rows = parseLogLines(
|
||||
'abc1234\x1fFirst subject\x1fAlice\x1f2024-01-01 10:00:00 +0800\x1fabc1234def5678abc1234def5678abc1234def5678\x1fHEAD -> main, origin/main\n'
|
||||
+ 'def5678\x1fSecond subject\x1fBob\x1f2024-01-02 10:00:00 +0800\x1fdef5678abc1234def5678abc1234def5678abc1234\x1f\n',
|
||||
)
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
hash: 'abc1234',
|
||||
subject: 'First subject',
|
||||
author: 'Alice',
|
||||
date: '2024-01-01 10:00:00 +0800',
|
||||
hashFull: 'abc1234def5678abc1234def5678abc1234def5678',
|
||||
refs: 'HEAD -> main, origin/main',
|
||||
},
|
||||
{
|
||||
hash: 'def5678',
|
||||
subject: 'Second subject',
|
||||
author: 'Bob',
|
||||
date: '2024-01-02 10:00:00 +0800',
|
||||
hashFull: 'def5678abc1234def5678abc1234def5678abc1234',
|
||||
refs: '',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('parses a multi-file unified diff with aligned line numbers', () => {
|
||||
const diff = [
|
||||
'diff --git a/src/a.ts b/src/a.ts',
|
||||
'index 1234567..89abcde 100644',
|
||||
'--- a/src/a.ts',
|
||||
'+++ b/src/a.ts',
|
||||
'@@ -1,4 +1,5 @@ section with @@ inside',
|
||||
' line1',
|
||||
'-line2',
|
||||
'+line2b',
|
||||
' context',
|
||||
'+trailing',
|
||||
'diff --git a/README.md b/README.md',
|
||||
'new file mode 100644',
|
||||
'index 0000000..1234567',
|
||||
'--- /dev/null',
|
||||
'+++ b/README.md',
|
||||
'@@ -0,0 +1,2 @@',
|
||||
'+hello',
|
||||
'+world',
|
||||
'',
|
||||
].join('\n')
|
||||
const parsed = parseUnifiedDiff(diff)
|
||||
expect(parsed.files).toHaveLength(2)
|
||||
const first = parsed.files[0]!
|
||||
expect(first.oldPath).toBe('a/src/a.ts')
|
||||
expect(first.newPath).toBe('b/src/a.ts')
|
||||
expect(first.binary).toBe(false)
|
||||
expect(first.hunks).toHaveLength(1)
|
||||
expect(first.hunks[0]!.oldStart).toBe(1)
|
||||
expect(first.hunks[0]!.newStart).toBe(1)
|
||||
expect(first.hunks[0]!.header).toBe(' section with @@ inside')
|
||||
expect(first.hunks[0]!.lines).toEqual([
|
||||
{ kind: 'ctx', text: 'line1', oldNum: 1, newNum: 1 },
|
||||
{ kind: 'del', text: 'line2', oldNum: 2, newNum: null },
|
||||
{ kind: 'add', text: 'line2b', oldNum: null, newNum: 2 },
|
||||
{ kind: 'ctx', text: 'context', oldNum: 3, newNum: 3 },
|
||||
{ kind: 'add', text: 'trailing', oldNum: null, newNum: 4 },
|
||||
])
|
||||
const second = parsed.files[1]!
|
||||
expect(second.oldPath).toBe('/dev/null')
|
||||
expect(second.hunks[0]!.lines[0]).toEqual({ kind: 'add', text: 'hello', oldNum: null, newNum: 1 })
|
||||
expect(second.hunks[0]!.lines[1]).toEqual({ kind: 'add', text: 'world', oldNum: null, newNum: 2 })
|
||||
})
|
||||
|
||||
it('parses binary, deletion and no-newline markers', () => {
|
||||
const diff = [
|
||||
'diff --git a/img.png b/img.png',
|
||||
'index 111..222 100644',
|
||||
'Binary files a/img.png and b/img.png differ',
|
||||
'diff --git a/gone.ts b/gone.ts',
|
||||
'deleted file mode 100644',
|
||||
'--- a/gone.ts',
|
||||
'+++ /dev/null',
|
||||
'@@ -1,2 +0,0 @@',
|
||||
'-one',
|
||||
'-two',
|
||||
'\\ No newline at end of file',
|
||||
'',
|
||||
].join('\n')
|
||||
const parsed = parseUnifiedDiff(diff)
|
||||
expect(parsed.files).toHaveLength(2)
|
||||
expect(parsed.files[0]!.binary).toBe(true)
|
||||
expect(parsed.files[0]!.hunks).toHaveLength(0)
|
||||
const gone = parsed.files[1]!
|
||||
expect(gone.newPath).toBe('/dev/null')
|
||||
expect(gone.hunks[0]!.lines).toEqual([
|
||||
{ kind: 'del', text: 'one', oldNum: 1, newNum: null },
|
||||
{ kind: 'del', text: 'two', oldNum: 2, newNum: null },
|
||||
{ kind: 'meta', text: ' No newline at end of file', oldNum: null, newNum: null },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps mode/rename-only sections hunkless', () => {
|
||||
const parsed = parseUnifiedDiff([
|
||||
'diff --git a/run.sh b/run.sh',
|
||||
'old mode 100644',
|
||||
'new mode 100755',
|
||||
'diff --git a/old.ts b/new.ts',
|
||||
'similarity index 90%',
|
||||
'rename from old.ts',
|
||||
'rename to new.ts',
|
||||
'',
|
||||
].join('\n'))
|
||||
expect(parsed.files).toHaveLength(2)
|
||||
expect(parsed.files[0]!.oldPath).toBe('')
|
||||
expect(parsed.files[0]!.hunks).toHaveLength(0)
|
||||
expect(parsed.files[1]!.hunks).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('parses an empty or junk diff into no files', () => {
|
||||
expect(parseUnifiedDiff('').files).toEqual([])
|
||||
expect(parseUnifiedDiff('no diff here\n').files).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolve } from 'node:path'
|
||||
import { decodeHtmlUrl, encodeHtmlUrl } from '../src/html-route.ts'
|
||||
import { isWin32 } from './platform.ts'
|
||||
|
||||
describe('encodeHtmlUrl', () => {
|
||||
it('encodes a POSIX absolute path into route segments', () => {
|
||||
@@ -20,6 +21,21 @@ describe('encodeHtmlUrl', () => {
|
||||
.toBe('/sidebar/html/sess-1/C%3A/Users/me/a.html')
|
||||
})
|
||||
|
||||
it('encodes a UNC path with the // marker (backslash and forward-slash forms)', () => {
|
||||
expect(encodeHtmlUrl('sess-1', '\\\\server\\share\\proj\\a.html'))
|
||||
.toBe('/sidebar/html/sess-1//server/share/proj/a.html')
|
||||
expect(encodeHtmlUrl('sess-1', '//server/share/proj/a.html'))
|
||||
.toBe('/sidebar/html/sess-1//server/share/proj/a.html')
|
||||
})
|
||||
|
||||
it('keeps a POSIX // path marker-encoded (the marker is platform-neutral)', () => {
|
||||
// The decoder rebuilds '//server/share/...', which node:path resolves to
|
||||
// '/server/share/...' on POSIX and '\\server\share\...' on win32 — so the
|
||||
// leading double slash round-trips without any platform signal.
|
||||
expect(encodeHtmlUrl('s-1', '//server/share/a.html'))
|
||||
.toBe('/sidebar/html/s-1//server/share/a.html')
|
||||
})
|
||||
|
||||
it('percent-encodes special characters in segments', () => {
|
||||
expect(encodeHtmlUrl('s-1', '/a b/中文/100%.html'))
|
||||
.toBe('/sidebar/html/s-1/a%20b/%E4%B8%AD%E6%96%87/100%25.html')
|
||||
@@ -45,13 +61,37 @@ describe('decodeHtmlUrl', () => {
|
||||
ok: true,
|
||||
ref: { sessionId: 'sess-1', path: 'C:/Users/me/a.html' },
|
||||
})
|
||||
})
|
||||
|
||||
it.skipIf(!isWin32)('decoded drive paths resolve back verbatim on win32', () => {
|
||||
// The host runs requireAbsolute() over the decoded path: with the old
|
||||
// '/C:/...' form node's resolve() produced 'C:\C:\Users\...' (drive
|
||||
// doubled) on Windows and the isWithin(cwd) fence rejected every drive
|
||||
// path. The slash-free form must resolve back to the original path.
|
||||
if (process.platform === 'win32') {
|
||||
expect(resolve('C:/Users/me/a.html')).toBe('C:\\Users\\me\\a.html')
|
||||
}
|
||||
expect(resolve('C:/Users/me/a.html')).toBe('C:\\Users\\me\\a.html')
|
||||
})
|
||||
|
||||
it('decodes a UNC round-trip back to the platform-neutral forward-slash form', () => {
|
||||
const url = encodeHtmlUrl('sess-1', '\\\\server\\share\\proj\\a.html')
|
||||
expect(decodeHtmlUrl(url)).toEqual({
|
||||
ok: true,
|
||||
ref: { sessionId: 'sess-1', path: '//server/share/proj/a.html' },
|
||||
})
|
||||
// The host's requireAbsolute resolves that form per-platform:
|
||||
// '\\server\share\proj\a.html' on win32, '/server/share/proj/a.html' on POSIX.
|
||||
})
|
||||
|
||||
it('round-trips a POSIX // path through the same marker', () => {
|
||||
const url = encodeHtmlUrl('s-1', '//server/share/a.html')
|
||||
expect(decodeHtmlUrl(url)).toEqual({
|
||||
ok: true,
|
||||
ref: { sessionId: 's-1', path: '//server/share/a.html' },
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a marker-only UNC URL and stray double slashes (400)', () => {
|
||||
expect(decodeHtmlUrl('/sidebar/html/s//').ok).toBe(false)
|
||||
expect(decodeHtmlUrl('/sidebar/html/s//server//x.html').ok).toBe(false)
|
||||
})
|
||||
|
||||
it('decodes a lowercase-drive Windows path without a leading slash', () => {
|
||||
@@ -122,4 +162,16 @@ describe('relative asset resolution stays in-route', () => {
|
||||
expect(new URL('img/x.png', doc).pathname).toBe('/sidebar/html/s/a/b/img/x.png')
|
||||
expect(new URL('../c.css', doc).pathname).toBe('/sidebar/html/s/a/c.css')
|
||||
})
|
||||
|
||||
it('relative assets of a UNC document stay inside the same route', () => {
|
||||
// The WHATWG URL preserves the '//' marker during relative resolution,
|
||||
// so ./style.css lands back on the route with the UNC prefix intact.
|
||||
const doc = `http://h${encodeHtmlUrl('s', '\\\\server\\share\\proj\\index.html')}`
|
||||
expect(new URL('./style.css', doc).pathname)
|
||||
.toBe('/sidebar/html/s//server/share/proj/style.css')
|
||||
expect(decodeHtmlUrl(new URL('./style.css', doc).pathname)).toEqual({
|
||||
ok: true,
|
||||
ref: { sessionId: 's', path: '//server/share/proj/style.css' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { registerOpenPathInterception } from '../src/client/intercept.tsx'
|
||||
import { wrapOpenPath, type OpenPathInterceptDeps, type OpenPathService } from '../src/client/openpath-intercept.ts'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
import type { Context } from '../src/context-types.ts'
|
||||
|
||||
describe('open-path interception', () => {
|
||||
/** A minimal fake of the workspaces.openPath service method. */
|
||||
const service = (): OpenPathService & { calls: string[]; opened: string[] } => {
|
||||
const fake = {
|
||||
calls: [] as string[],
|
||||
opened: [] as string[],
|
||||
async openPath(path: string): Promise<void> {
|
||||
this.calls.push(path)
|
||||
this.opened.push(path)
|
||||
},
|
||||
}
|
||||
return fake
|
||||
}
|
||||
|
||||
const deps = (overrides: Partial<OpenPathInterceptDeps> = {}): OpenPathInterceptDeps & {
|
||||
sidebar: string[]
|
||||
} => {
|
||||
const sidebar: string[] = []
|
||||
return {
|
||||
sidebar,
|
||||
takeoverEnabled: () => true,
|
||||
currentSessionId: () => 's1',
|
||||
openInSidebar: (path, sessionId) => { sidebar.push(`${sessionId}:${path}`) },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
it('routes an intercepted open into the sidebar and resolves without the original call', async () => {
|
||||
const ws = service()
|
||||
const d = deps()
|
||||
const restore = wrapOpenPath(ws, d)
|
||||
await expect(ws.openPath('/abs/a.ts')).resolves.toBeUndefined()
|
||||
expect(ws.calls).toEqual([])
|
||||
expect(d.sidebar).toEqual(['s1:/abs/a.ts'])
|
||||
restore()
|
||||
})
|
||||
|
||||
it('falls through to the original when the takeover is disabled', async () => {
|
||||
const ws = service()
|
||||
const d = deps({ takeoverEnabled: () => false })
|
||||
const restore = wrapOpenPath(ws, d)
|
||||
await ws.openPath('/abs/a.ts')
|
||||
expect(ws.opened).toEqual(['/abs/a.ts'])
|
||||
expect(d.sidebar).toEqual([])
|
||||
restore()
|
||||
})
|
||||
|
||||
it('falls through when no session is current (nothing to scope the editor load to)', async () => {
|
||||
const ws = service()
|
||||
const d = deps({ currentSessionId: () => undefined })
|
||||
const restore = wrapOpenPath(ws, d)
|
||||
await ws.openPath('/abs/a.ts')
|
||||
expect(ws.opened).toEqual(['/abs/a.ts'])
|
||||
expect(d.sidebar).toEqual([])
|
||||
restore()
|
||||
})
|
||||
|
||||
it('passes the current session into the sidebar opener', async () => {
|
||||
const ws = service()
|
||||
let current = 's1'
|
||||
const d = deps({ currentSessionId: () => current })
|
||||
const restore = wrapOpenPath(ws, d)
|
||||
await ws.openPath('/abs/a.ts')
|
||||
current = 's2'
|
||||
await ws.openPath('/abs/b.ts')
|
||||
expect(d.sidebar).toEqual(['s1:/abs/a.ts', 's2:/abs/b.ts'])
|
||||
restore()
|
||||
})
|
||||
|
||||
it('restores the original method on dispose (HMR-safe)', async () => {
|
||||
const ws = service()
|
||||
const d = deps()
|
||||
const original = ws.openPath
|
||||
const restore = wrapOpenPath(ws, d)
|
||||
expect(ws.openPath).not.toBe(original)
|
||||
restore()
|
||||
expect(ws.openPath).toBe(original)
|
||||
await ws.openPath('/abs/a.ts')
|
||||
expect(ws.opened).toEqual(['/abs/a.ts'])
|
||||
})
|
||||
|
||||
it('treats a rejected promise like the original would (no swallowing)', async () => {
|
||||
const failing: OpenPathService = {
|
||||
async openPath() { throw new Error('host refused') },
|
||||
}
|
||||
const d = deps({ takeoverEnabled: () => false })
|
||||
const restore = wrapOpenPath(failing, d)
|
||||
await expect(failing.openPath('/abs/a.ts')).rejects.toThrow('host refused')
|
||||
restore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('open-path interception wiring', () => {
|
||||
it('registerOpenPathInterception routes chat opens into the editor tab and restores on dispose', async () => {
|
||||
// A realistic client-context fake: the sessions list feed (current + cwd),
|
||||
// the workspaces funnel, and the sidebar service the editor goes through.
|
||||
const opened: Array<Record<string, unknown>> = []
|
||||
const funnel = { openPath: async (): Promise<void> => {} }
|
||||
const ctx = {
|
||||
sessions: {
|
||||
list: { getSnapshot: () => ({ current: 's1', byId: { s1: { cwd: '/w' } } }) },
|
||||
},
|
||||
workspaces: funnel,
|
||||
betterSidebar: { openTab: (seed: unknown) => { opened.push(seed as Record<string, unknown>) } },
|
||||
} as unknown as Context
|
||||
const store = createSidebarStore()
|
||||
const original = ctx.workspaces.openPath
|
||||
const restore = registerOpenPathInterception(ctx, store)
|
||||
|
||||
// Default prefs: the takeover routes the open into the sidebar editor
|
||||
// with the session-scoped absolute path (chat already resolved it).
|
||||
await ctx.workspaces.openPath('/w/src/a.ts')
|
||||
expect(opened).toEqual([{
|
||||
type: 'editor',
|
||||
title: 'a.ts',
|
||||
path: '/w/src/a.ts',
|
||||
id: 'editor:/w/src/a.ts',
|
||||
}])
|
||||
|
||||
// The interceptOpenPath pref off → the original funnel runs untouched.
|
||||
store.setPrefs({ ...store.getPrefs(), interceptOpenPath: false })
|
||||
const calls: string[] = []
|
||||
ctx.workspaces.openPath = async (path: string) => { calls.push(path) }
|
||||
await ctx.workspaces.openPath('/w/src/b.ts')
|
||||
expect(calls).toEqual(['/w/src/b.ts'])
|
||||
expect(opened).toHaveLength(1)
|
||||
|
||||
// The editor tab disabled → falls through too (an editor that cannot
|
||||
// open must not swallow opens).
|
||||
store.setPrefs({ ...store.getPrefs(), interceptOpenPath: true, tabsEnabled: { editor: false } })
|
||||
await ctx.workspaces.openPath('/w/src/c.ts')
|
||||
expect(calls).toEqual(['/w/src/b.ts', '/w/src/c.ts'])
|
||||
|
||||
// Disposal restores the raw original method (HMR-safe).
|
||||
restore()
|
||||
expect(ctx.workspaces.openPath).toBe(original)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isAbsolutePath, relativeTo } from '../src/client/paths.ts'
|
||||
import { resolveSidebarPath } from '../src/client/produced-files.ts'
|
||||
import { htmlUrl } from '../src/client/api.ts'
|
||||
|
||||
describe('path helpers', () => {
|
||||
it('derives relative paths under the cwd (and "." for the cwd itself)', () => {
|
||||
expect(relativeTo('/Users/me/code', '/Users/me/code/src/main.ts')).toBe('src/main.ts')
|
||||
expect(relativeTo('/Users/me/code', '/Users/me/code')).toBe('.')
|
||||
expect(relativeTo('/Users/me/code/', '/Users/me/code/src/a/b.ts')).toBe('src/a/b.ts')
|
||||
})
|
||||
|
||||
it('falls back to the path unchanged when it lies outside the cwd', () => {
|
||||
expect(relativeTo('/Users/me/code', '/Users/other/x.ts')).toBe('/Users/other/x.ts')
|
||||
expect(relativeTo('/Users/me/code', '/Users/me/codex/y.ts')).toBe('/Users/me/codex/y.ts')
|
||||
})
|
||||
|
||||
it('handles windows roots and mixed separators', () => {
|
||||
expect(relativeTo('C:\\Users\\me', 'C:\\Users\\me\\src\\a.ts')).toBe('src/a.ts')
|
||||
expect(relativeTo('C:\\Users\\me', 'C:/Users/me/src/a.ts')).toBe('src/a.ts')
|
||||
expect(relativeTo('C:\\Users\\me\\', 'C:\\Users\\me')).toBe('.')
|
||||
})
|
||||
|
||||
it('containment is case-insensitive (windows/macOS case-insensitive volumes)', () => {
|
||||
expect(relativeTo('C:\\Users\\Me', 'c:/users/me/src/a.ts')).toBe('src/a.ts')
|
||||
expect(relativeTo('/Users/Me/code', '/users/me/code/src/main.ts')).toBe('src/main.ts')
|
||||
// The returned relative text keeps the caller's own casing.
|
||||
expect(relativeTo('C:\\Users\\me', 'C:\\Users\\Me\\SRC\\a.ts')).toBe('SRC/a.ts')
|
||||
})
|
||||
|
||||
it('resolves produced paths against windows cwds', () => {
|
||||
expect(resolveSidebarPath('C:\\work\\proj', 'src/a.ts')).toBe('C:\\work\\proj\\src/a.ts')
|
||||
expect(resolveSidebarPath('C:\\work\\proj', 'C:\\abs\\x.ts')).toBe('C:\\abs\\x.ts')
|
||||
expect(resolveSidebarPath('C:\\work\\proj\\', 'C:\\abs\\x.ts')).toBe('C:\\abs\\x.ts')
|
||||
})
|
||||
|
||||
it('keeps UNC produced paths absolute instead of joining them onto the cwd', () => {
|
||||
// Pure client function: UNC detection is platform-independent, so these
|
||||
// assertions run on every host without a platform guard.
|
||||
expect(resolveSidebarPath('C:\\work\\proj', '\\\\server\\share\\abs\\x.ts'))
|
||||
.toBe('\\\\server\\share\\abs\\x.ts')
|
||||
expect(resolveSidebarPath('C:\\work\\proj', '//server/share/abs/x.ts'))
|
||||
.toBe('//server/share/abs/x.ts')
|
||||
// A relative path under a UNC cwd joins with backslashes.
|
||||
expect(resolveSidebarPath('\\\\server\\share\\proj', 'src/a.ts'))
|
||||
.toBe('\\\\server\\share\\proj\\src/a.ts')
|
||||
})
|
||||
|
||||
it('mirrors the host absolute-path notion without node:path', () => {
|
||||
expect(isAbsolutePath('/abs/x.ts')).toBe(true)
|
||||
expect(isAbsolutePath('C:\\abs\\x.ts')).toBe(true)
|
||||
expect(isAbsolutePath('C:/abs/x.ts')).toBe(true)
|
||||
expect(isAbsolutePath('\\\\server\\share\\x.ts')).toBe(true)
|
||||
expect(isAbsolutePath('//server/share/x.ts')).toBe(true)
|
||||
expect(isAbsolutePath('C:relative.ts')).toBe(false)
|
||||
expect(isAbsolutePath('rel/x.ts')).toBe(false)
|
||||
})
|
||||
|
||||
it('htmlUrl always marks UNC paths (platform-neutral marker)', () => {
|
||||
// The marker is platform-neutral now: the host resolves the decoded
|
||||
// '//server/share/...' form per-platform, so no cwd/OS signal is needed.
|
||||
expect(htmlUrl({ sessionId: 's' }, '\\\\server\\share\\proj\\a.html'))
|
||||
.toBe('/sidebar/html/s//server/share/proj/a.html')
|
||||
expect(htmlUrl({ sessionId: 's', cwd: '/home/me' }, '//server/share/a.html'))
|
||||
.toBe('/sidebar/html/s//server/share/a.html')
|
||||
expect(htmlUrl({ sessionId: 's', cwd: '/home/me' }, '/home/me/index.html'))
|
||||
.toBe('/sidebar/html/s/home/me/index.html')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Shared platform guards for tests. Use vitest's `describe.skipIf` /
|
||||
* `it.skipIf` with these so platform-specific cases are REPORTED as skipped
|
||||
* on other hosts instead of silently passing through an inline `if` — and
|
||||
* so the opposite branch (e.g. POSIX rejecting drive paths) is always
|
||||
* asserted on the matching host.
|
||||
*/
|
||||
|
||||
/** True when running on Windows (drive letters / UNC path semantics apply). */
|
||||
export const isWin32 = process.platform === 'win32'
|
||||
@@ -34,6 +34,13 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
expect(resolved.listLimit).toBe(1000)
|
||||
expect(resolved.terminalsPerSession).toBe(3)
|
||||
expect(resolved.reconnectGraceMs).toBe(30_000)
|
||||
// The terminal shell config defaults to auto-resolution (empty shell =
|
||||
// the platform chain in defaultShell()).
|
||||
expect(resolved.shell).toBe('')
|
||||
const configured = (schema as unknown as {
|
||||
(input: Record<string, unknown> | undefined): Record<string, unknown>
|
||||
})({ shell: 'pwsh.exe' })
|
||||
expect(configured.shell).toBe('pwsh.exe')
|
||||
})
|
||||
|
||||
it('registers the side card preferences schema with the documented defaults', async () => {
|
||||
@@ -42,8 +49,8 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
const resolved = (PrefsSchema as unknown as {
|
||||
(input: Record<string, unknown> | undefined): Record<string, unknown>
|
||||
})(undefined)
|
||||
expect(resolved.openByDefault).toBe(true)
|
||||
expect(resolved.defaultWidthPercent).toBe(30)
|
||||
expect(resolved.openByDefault).toBe(false)
|
||||
expect(resolved.defaultWidthPercent).toBe(35)
|
||||
expect(resolved.autoOpenSubagent).toBe(true)
|
||||
// A new background job auto-opens the Jobs page too.
|
||||
expect(resolved.autoOpenJobs).toBe(true)
|
||||
@@ -61,12 +68,14 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
// The enable-switch maps resolve to {} (everything on) for old documents.
|
||||
expect(resolved.tabsEnabled).toEqual({})
|
||||
expect(resolved.viewersEnabled).toEqual({})
|
||||
// The merged editor-explorer mode defaults ON.
|
||||
expect(resolved.editorExplorer).toBe(true)
|
||||
// A stored overridden value resolves through (the range contract is
|
||||
// enforced by the settings service on write); the new pref keeps its
|
||||
// default when the stored document predates it.
|
||||
const overridden = (PrefsSchema as unknown as {
|
||||
(input: Record<string, unknown> | undefined): Record<string, unknown>
|
||||
})({ openByDefault: false, defaultWidthPercent: 45 })
|
||||
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { loadExternalDisable, loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts'
|
||||
import { allLeaves, createSidebarStore, defaultWidthFor, makeDefaultState } from '../src/client/state.ts'
|
||||
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
|
||||
|
||||
/** A fake settings wire face whose settingsGet resolves to one raw value. */
|
||||
const wire = (value: unknown): SidebarSettingsClient => ({
|
||||
settingsGet: async () => ({ value, revision: 1 }),
|
||||
settingsUpdate: async () => ({ value, revision: 2 }),
|
||||
})
|
||||
|
||||
/** A fake wire carrying an explicit externalDisable flag. */
|
||||
const wireWithDisable = (externalDisable: boolean): SidebarSettingsClient => ({
|
||||
settingsGet: async () => ({ value: {}, revision: 1, externalDisable }),
|
||||
settingsUpdate: async () => ({ value: {}, revision: 2 }),
|
||||
})
|
||||
|
||||
const rejecting = (): SidebarSettingsClient => ({
|
||||
settingsGet: async () => { throw new Error('route rejected') },
|
||||
settingsUpdate: async () => { throw new Error('route rejected') },
|
||||
})
|
||||
|
||||
describe('side card preferences', () => {
|
||||
it('falls back to the defaults when the settings route rejects', async () => {
|
||||
expect(await loadPrefs(rejecting())).toEqual(SIDEBAR_PREFS_DEFAULTS)
|
||||
})
|
||||
|
||||
it('falls back to the defaults when the value is absent or malformed', async () => {
|
||||
expect(await loadPrefs(wire(undefined))).toEqual(SIDEBAR_PREFS_DEFAULTS)
|
||||
expect(await loadPrefs(wire('garbage'))).toEqual(SIDEBAR_PREFS_DEFAULTS)
|
||||
})
|
||||
|
||||
it('parses a valid value and clamps the percent into the contract range', async () => {
|
||||
expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 80, autoOpenSubagent: false, agentTerminalTools: true })))
|
||||
.toEqual({
|
||||
openByDefault: false,
|
||||
defaultWidthPercent: 60,
|
||||
autoOpenSubagent: false,
|
||||
autoOpenJobs: true,
|
||||
agentTerminalTools: true,
|
||||
bottomPanelAutoTerminal: true,
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: 13,
|
||||
interceptOpenPath: true,
|
||||
editorExplorer: true,
|
||||
titleBarCompat: false,
|
||||
titleBarStripPx: 40,
|
||||
htmlViewerNoSandbox: false,
|
||||
htmlViewerDefaultUnsafe: false,
|
||||
browserNoSandbox: false,
|
||||
browserInterceptLinks: true,
|
||||
browserInterceptHttp: true,
|
||||
browserInterceptHttps: false,
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
pluginSettings: {},
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back per-field when a stored field is malformed', async () => {
|
||||
expect(await loadPrefs(wire({ openByDefault: 'yes', defaultWidthPercent: 33, autoOpenSubagent: 'no', agentTerminalTools: 'yes' })))
|
||||
.toEqual({
|
||||
openByDefault: false,
|
||||
defaultWidthPercent: 33,
|
||||
autoOpenSubagent: true,
|
||||
autoOpenJobs: true,
|
||||
agentTerminalTools: false,
|
||||
bottomPanelAutoTerminal: true,
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: 13,
|
||||
interceptOpenPath: true,
|
||||
editorExplorer: true,
|
||||
titleBarCompat: false,
|
||||
titleBarStripPx: 40,
|
||||
htmlViewerNoSandbox: false,
|
||||
htmlViewerDefaultUnsafe: false,
|
||||
browserNoSandbox: false,
|
||||
browserInterceptLinks: true,
|
||||
browserInterceptHttp: true,
|
||||
browserInterceptHttps: false,
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
pluginSettings: {},
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults autoOpenSubagent to true and agentTerminalTools to false when the stored value is absent or malformed', async () => {
|
||||
expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 40 })))
|
||||
.toEqual({
|
||||
openByDefault: false,
|
||||
defaultWidthPercent: 40,
|
||||
autoOpenSubagent: true,
|
||||
autoOpenJobs: true,
|
||||
agentTerminalTools: false,
|
||||
bottomPanelAutoTerminal: true,
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: 13,
|
||||
interceptOpenPath: true,
|
||||
editorExplorer: true,
|
||||
titleBarCompat: false,
|
||||
titleBarStripPx: 40,
|
||||
htmlViewerNoSandbox: false,
|
||||
htmlViewerDefaultUnsafe: false,
|
||||
browserNoSandbox: false,
|
||||
browserInterceptLinks: true,
|
||||
browserInterceptHttp: true,
|
||||
browserInterceptHttps: false,
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
pluginSettings: {},
|
||||
})
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenSubagent: 1 }))).autoOpenSubagent)
|
||||
.toBe(true)
|
||||
// The terminal-tools feature is OFF by default; only an explicit true turns it on.
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40 }))).agentTerminalTools)
|
||||
.toBe(false)
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: 1 }))).agentTerminalTools)
|
||||
.toBe(false)
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: true }))).agentTerminalTools)
|
||||
.toBe(true)
|
||||
// The job auto-open is ON by default; only an explicit false turns it off.
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenJobs: 1 }))).autoOpenJobs)
|
||||
.toBe(true)
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenJobs: false }))).autoOpenJobs)
|
||||
.toBe(false)
|
||||
})
|
||||
|
||||
it('defaults interceptOpenPath to true; only an explicit false turns the takeover off', async () => {
|
||||
// Absent or malformed → on (the takeover is the safe default).
|
||||
expect((await loadPrefs(wire({}))).interceptOpenPath).toBe(true)
|
||||
expect((await loadPrefs(wire({ interceptOpenPath: 'yes' }))).interceptOpenPath).toBe(true)
|
||||
expect((await loadPrefs(wire({ interceptOpenPath: 0 }))).interceptOpenPath).toBe(true)
|
||||
// Explicit booleans survive verbatim.
|
||||
expect((await loadPrefs(wire({ interceptOpenPath: false }))).interceptOpenPath).toBe(false)
|
||||
expect((await loadPrefs(wire({ interceptOpenPath: true }))).interceptOpenPath).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults editorExplorer to true; only an explicit false restores the plain editor', async () => {
|
||||
// Absent or malformed → on (the merged editor-explorer is the default).
|
||||
expect((await loadPrefs(wire({}))).editorExplorer).toBe(true)
|
||||
expect((await loadPrefs(wire({ editorExplorer: 'yes' }))).editorExplorer).toBe(true)
|
||||
expect((await loadPrefs(wire({ editorExplorer: 0 }))).editorExplorer).toBe(true)
|
||||
// Explicit booleans survive verbatim.
|
||||
expect((await loadPrefs(wire({ editorExplorer: false }))).editorExplorer).toBe(false)
|
||||
expect((await loadPrefs(wire({ editorExplorer: true }))).editorExplorer).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults titleBarCompat to false; only an explicit true turns the position-compat mode on', async () => {
|
||||
// Absent or malformed → off (the normal layout is the default).
|
||||
expect((await loadPrefs(wire({}))).titleBarCompat).toBe(false)
|
||||
expect((await loadPrefs(wire({ titleBarCompat: 'yes' }))).titleBarCompat).toBe(false)
|
||||
expect((await loadPrefs(wire({ titleBarCompat: 1 }))).titleBarCompat).toBe(false)
|
||||
// Explicit booleans survive verbatim.
|
||||
expect((await loadPrefs(wire({ titleBarCompat: false }))).titleBarCompat).toBe(false)
|
||||
expect((await loadPrefs(wire({ titleBarCompat: true }))).titleBarCompat).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults titleBarStripPx to 40 and clamps stored values into the contract range', async () => {
|
||||
// Absent or malformed → 40 (the strip default).
|
||||
expect((await loadPrefs(wire({}))).titleBarStripPx).toBe(40)
|
||||
expect((await loadPrefs(wire({ titleBarStripPx: 'yes' }))).titleBarStripPx).toBe(40)
|
||||
// Out-of-range numbers clamp into 0–120.
|
||||
expect((await loadPrefs(wire({ titleBarStripPx: -5 }))).titleBarStripPx).toBe(0)
|
||||
expect((await loadPrefs(wire({ titleBarStripPx: 200 }))).titleBarStripPx).toBe(120)
|
||||
expect((await loadPrefs(wire({ titleBarStripPx: 47.6 }))).titleBarStripPx).toBe(48)
|
||||
// In-range values survive verbatim.
|
||||
expect((await loadPrefs(wire({ titleBarStripPx: 0 }))).titleBarStripPx).toBe(0)
|
||||
expect((await loadPrefs(wire({ titleBarStripPx: 64 }))).titleBarStripPx).toBe(64)
|
||||
})
|
||||
|
||||
it('defaults the link-takeover protocol flags: http on, https off, master on', async () => {
|
||||
// Absent or malformed → the per-protocol defaults.
|
||||
expect((await loadPrefs(wire({}))).browserInterceptLinks).toBe(true)
|
||||
expect((await loadPrefs(wire({}))).browserInterceptHttp).toBe(true)
|
||||
expect((await loadPrefs(wire({}))).browserInterceptHttps).toBe(false)
|
||||
expect((await loadPrefs(wire({ browserInterceptHttp: 'yes' }))).browserInterceptHttp).toBe(true)
|
||||
expect((await loadPrefs(wire({ browserInterceptHttps: 0 }))).browserInterceptHttps).toBe(false)
|
||||
// Explicit booleans survive verbatim.
|
||||
expect((await loadPrefs(wire({ browserInterceptHttp: false }))).browserInterceptHttp).toBe(false)
|
||||
expect((await loadPrefs(wire({ browserInterceptHttps: true }))).browserInterceptHttps).toBe(true)
|
||||
// The master is independent of the protocol flags (an explicit master
|
||||
// false stays "never take over" regardless of the flags).
|
||||
expect((await loadPrefs(wire({ browserInterceptLinks: false, browserInterceptHttp: true, browserInterceptHttps: true }))))
|
||||
.toMatchObject({ browserInterceptLinks: false, browserInterceptHttp: true, browserInterceptHttps: true })
|
||||
})
|
||||
|
||||
it('resolves the terminal font prefs (family passthrough, size clamp)', async () => {
|
||||
// Absent → theme default (empty family) + default size.
|
||||
expect((await loadPrefs(wire({}))).terminalFontFamily).toBe('')
|
||||
expect((await loadPrefs(wire({}))).terminalFontSize).toBe(13)
|
||||
// A custom family survives verbatim; a malformed one falls back.
|
||||
expect((await loadPrefs(wire({ terminalFontFamily: '"JetBrains Mono", monospace' }))).terminalFontFamily)
|
||||
.toBe('"JetBrains Mono", monospace')
|
||||
expect((await loadPrefs(wire({ terminalFontFamily: 42 }))).terminalFontFamily).toBe('')
|
||||
// The size clamps into 9–32 (rounded); non-numbers fall back.
|
||||
expect((await loadPrefs(wire({ terminalFontSize: 5 }))).terminalFontSize).toBe(9)
|
||||
expect((await loadPrefs(wire({ terminalFontSize: 40 }))).terminalFontSize).toBe(32)
|
||||
expect((await loadPrefs(wire({ terminalFontSize: 15.6 }))).terminalFontSize).toBe(16)
|
||||
expect((await loadPrefs(wire({ terminalFontSize: 'big' }))).terminalFontSize).toBe(13)
|
||||
expect((await loadPrefs(wire({ terminalFontSize: 18 }))).terminalFontSize).toBe(18)
|
||||
})
|
||||
|
||||
it('validates the per-tab / per-viewer enable maps (absent keys mean enabled)', async () => {
|
||||
// A non-object map falls back to {} (everything enabled).
|
||||
expect((await loadPrefs(wire({ tabsEnabled: 'nope' }))).tabsEnabled).toEqual({})
|
||||
expect((await loadPrefs(wire({ viewersEnabled: [1, 2] }))).viewersEnabled).toEqual({})
|
||||
// Non-boolean entries are dropped; boolean entries survive verbatim.
|
||||
const parsed = await loadPrefs(wire({
|
||||
tabsEnabled: { git: false, explorer: true, bad: 'yes' },
|
||||
viewersEnabled: { image: false, code: 1 },
|
||||
}))
|
||||
expect(parsed.tabsEnabled).toEqual({ git: false, explorer: true })
|
||||
expect(parsed.viewersEnabled).toEqual({ image: false })
|
||||
})
|
||||
|
||||
it('seeds new-session defaults from the store prefs (open flag + width)', () => {
|
||||
const store = createSidebarStore()
|
||||
// Node environment: no window → the width falls back to PANEL_DEFAULT,
|
||||
// while the open flag still follows the preference.
|
||||
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setSession('fresh-session')
|
||||
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
const snapshot = store.getSnapshot()
|
||||
expect(snapshot.sessionId).toBe('fresh-session')
|
||||
expect(snapshot.state?.panelOpen).toBe(false)
|
||||
expect(snapshot.state?.width).toBe(400)
|
||||
// The default prefs keep the panel closed (openByDefault defaults off).
|
||||
const openStore = createSidebarStore()
|
||||
openStore.setSession('another-fresh')
|
||||
expect(openStore.getSnapshot().state?.panelOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('seeds a brand-new session COLLAPSED on narrow viewports (the panel is a full-screen drawer there)', () => {
|
||||
// Stub a narrow window (the file otherwise runs without one): only
|
||||
// innerWidth is read while seeding a fresh session.
|
||||
const original = (globalThis as Record<string, unknown>).window
|
||||
;(globalThis as Record<string, unknown>).window = {
|
||||
innerWidth: 390,
|
||||
clearTimeout: () => {},
|
||||
setTimeout: (_fn: () => void) => 0,
|
||||
}
|
||||
try {
|
||||
const store = createSidebarStore()
|
||||
// The narrow viewport keeps a fresh session collapsed for the FIRST
|
||||
// seeding only (a later user expansion persists).
|
||||
store.setSession('narrow-fresh')
|
||||
expect(store.getSnapshot().state?.panelOpen).toBe(false)
|
||||
// The width seeding still follows the window (clamped to the floor).
|
||||
expect(store.getSnapshot().state?.width).toBe(280)
|
||||
} finally {
|
||||
if (original === undefined) delete (globalThis as Record<string, unknown>).window
|
||||
else (globalThis as Record<string, unknown>).window = original
|
||||
}
|
||||
})
|
||||
|
||||
it('skips the default seed tab when the editor (files window) type is disabled', () => {
|
||||
const store = createSidebarStore()
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: { editor: false }, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setSession('no-editor')
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
|
||||
expect(tabs).toHaveLength(0)
|
||||
expect(state.splits.kind).toBe('leaf')
|
||||
// Re-enabling seeds the files window (editor home tab) again — in BOTH
|
||||
// editorExplorer modes.
|
||||
for (const editorExplorer of [true, false]) {
|
||||
const openStore = createSidebarStore()
|
||||
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
openStore.setSession(`with-editor-${editorExplorer}`)
|
||||
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
expect(openTabs.map(tab => tab.type)).toEqual(['editor'])
|
||||
}
|
||||
})
|
||||
|
||||
it('seeds the empty editor home tab (files window) in both editorExplorer modes', () => {
|
||||
for (const editorExplorer of [true, false]) {
|
||||
const store = createSidebarStore()
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setSession(`fresh-${editorExplorer}`)
|
||||
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
expect(tabs).toHaveLength(1)
|
||||
expect(tabs[0]!.type).toBe('editor')
|
||||
expect(tabs[0]!.title).toBe('Files')
|
||||
expect(tabs[0]!.path).toBeUndefined()
|
||||
expect(tabs[0]!.meta).toEqual({ treeOpen: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('derives the default width from the window percent with clamps', () => {
|
||||
expect(defaultWidthFor(1440, 30)).toBe(432)
|
||||
expect(defaultWidthFor(800, 30)).toBe(280) // the panel floor
|
||||
expect(defaultWidthFor(1440, 100)).toBe(1440) // the viewport cap
|
||||
})
|
||||
|
||||
it('makeDefaultState honors the open flag', () => {
|
||||
expect(makeDefaultState().panelOpen).toBe(true)
|
||||
expect(makeDefaultState(400, false).panelOpen).toBe(false)
|
||||
expect(makeDefaultState(400, false).width).toBe(400)
|
||||
// The 'none' seed starts with an empty pane (no default tab).
|
||||
expect(makeDefaultState(400, true, 'none').splits.kind).toBe('leaf')
|
||||
expect((makeDefaultState(400, true, 'none').splits as { tabs: unknown[] }).tabs).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('external disable (aionui-panel provider choice)', () => {
|
||||
it('reads true when the host reports the aionui provider active', async () => {
|
||||
expect(await loadExternalDisable(wireWithDisable(true))).toBe(true)
|
||||
})
|
||||
|
||||
it('reads false when the host reports no external disable', async () => {
|
||||
expect(await loadExternalDisable(wireWithDisable(false))).toBe(false)
|
||||
})
|
||||
|
||||
it('reads false when the flag is absent or the wire rejects', async () => {
|
||||
expect(await loadExternalDisable(wire({}))).toBe(false)
|
||||
expect(await loadExternalDisable(rejecting())).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { producedForClosing, resolveSidebarPath, selectProducedFiles } from '../src/client/produced-files.ts'
|
||||
|
||||
describe('produced-files derivation', () => {
|
||||
const diffResult = (path: string) => ({
|
||||
kind: 'tool-result', isError: false, callView: { card: 'diff', locations: [{ path }] },
|
||||
})
|
||||
const editResult = (path: string) => ({
|
||||
kind: 'tool-result', isError: false, callView: { card: 'generic', kind: 'edit', locations: [{ path }] },
|
||||
})
|
||||
|
||||
it('collects diff/edit locations of the closing turn, first-seen order', () => {
|
||||
const nodes = [
|
||||
{ kind: 'assistant', seq: 1, turn: 1 },
|
||||
diffResult('a.ts'),
|
||||
editResult('b.ts'),
|
||||
diffResult('a.ts'),
|
||||
{ kind: 'assistant', seq: 2, turn: 1 },
|
||||
]
|
||||
expect(producedForClosing(nodes, 2)).toEqual(['a.ts', 'b.ts'])
|
||||
})
|
||||
|
||||
it('resets on user messages and turn changes', () => {
|
||||
const nodes = [
|
||||
{ kind: 'assistant', seq: 1, turn: 1 },
|
||||
diffResult('old.ts'),
|
||||
{ kind: 'user' },
|
||||
{ kind: 'assistant', seq: 2, turn: 2 },
|
||||
diffResult('new.ts'),
|
||||
{ kind: 'assistant', seq: 3, turn: 2 },
|
||||
]
|
||||
expect(producedForClosing(nodes, 3)).toEqual(['new.ts'])
|
||||
})
|
||||
|
||||
it('ignores reads, deletes, errors, and unknown cards', () => {
|
||||
const nodes = [
|
||||
{ kind: 'assistant', seq: 1, turn: 1 },
|
||||
{ kind: 'tool-result', isError: true, callView: { card: 'diff', locations: [{ path: 'x.ts' }] } },
|
||||
{ kind: 'tool-result', isError: false, callView: { card: 'read', locations: [{ path: 'r.ts' }] } },
|
||||
{ kind: 'tool-result', isError: false, callView: { card: 'generic', kind: 'delete', locations: [{ path: 'd.ts' }] } },
|
||||
]
|
||||
expect(producedForClosing(nodes, 1)).toEqual([])
|
||||
})
|
||||
|
||||
it('selector claims only when files exist', () => {
|
||||
expect(selectProducedFiles({ nodes: [{ kind: 'assistant', seq: 1, turn: 1 }], seq: 1 })).toBeNull()
|
||||
expect(selectProducedFiles({ nodes: [diffResult('a.ts'), { kind: 'assistant', seq: 1, turn: 1 }], seq: 1 })).toEqual(['a.ts'])
|
||||
expect(selectProducedFiles(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves relative paths against the session cwd', () => {
|
||||
expect(resolveSidebarPath('/work/proj', 'src/a.ts')).toBe('/work/proj/src/a.ts')
|
||||
expect(resolveSidebarPath('/work/proj', '/abs/x.ts')).toBe('/abs/x.ts')
|
||||
expect(resolveSidebarPath(undefined, 'a.ts')).toBe('a.ts')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* pty-deps unit tests (issue #140, plugin side): lazy node-pty loading,
|
||||
* profile/plugin-root discovery, and the pasteable repair command — the
|
||||
* degraded mode the terminal tab enters when node-pty cannot load, instead
|
||||
* of the whole plugin (and `dsh web`) failing to boot.
|
||||
*/
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildRepairCommand,
|
||||
depsStatus,
|
||||
DSH_NODE_PTY_RANGE,
|
||||
findPluginRoot,
|
||||
findProfileDir,
|
||||
loadNodePty,
|
||||
loadRequiredNodePty,
|
||||
nodePtyLoadCause,
|
||||
resetNodePtyCache,
|
||||
} from '../src/pty-deps.ts'
|
||||
import { SidebarError } from '../src/wire.ts'
|
||||
|
||||
/** A throw-only require: node-pty missing or its native binding broken. */
|
||||
const failingRequire = (): never => { throw new Error('Cannot find package node-pty') }
|
||||
|
||||
describe('loadNodePty', () => {
|
||||
beforeEach(() => { resetNodePtyCache() })
|
||||
|
||||
it('loads the real node-pty module in this repository', () => {
|
||||
const mod = loadNodePty()
|
||||
expect(mod).not.toBeNull()
|
||||
expect(typeof mod?.spawn).toBe('function')
|
||||
})
|
||||
|
||||
it('returns null and records the cause when the require throws', () => {
|
||||
expect(loadNodePty(failingRequire)).toBeNull()
|
||||
expect((nodePtyLoadCause() as Error).message).toBe('Cannot find package node-pty')
|
||||
})
|
||||
|
||||
it('caches the first outcome (a later working require does not override)', () => {
|
||||
expect(loadNodePty(failingRequire)).toBeNull()
|
||||
expect(loadNodePty(() => ({ spawn: () => undefined }))).toBeNull()
|
||||
resetNodePtyCache()
|
||||
expect(loadNodePty(() => ({ spawn: () => undefined }))).not.toBeNull()
|
||||
})
|
||||
|
||||
it('loadRequiredNodePty throws the canonical degraded-mode error', () => {
|
||||
loadNodePty(failingRequire)
|
||||
let thrown: unknown
|
||||
try {
|
||||
loadRequiredNodePty()
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(SidebarError)
|
||||
expect((thrown as SidebarError).code).toBe('pty-deps-missing')
|
||||
expect((thrown as SidebarError).status).toBe(503)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRepairCommand', () => {
|
||||
let root: string
|
||||
let pluginRoot: string
|
||||
let profileDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'pty-deps-'))
|
||||
pluginRoot = join(root, 'plugin')
|
||||
profileDir = join(root, 'profiles', 'web')
|
||||
mkdirSync(join(pluginRoot, 'scripts'), { recursive: true })
|
||||
mkdirSync(profileDir, { recursive: true })
|
||||
writeFileSync(join(pluginRoot, 'scripts', 'install.sh'), '#!/usr/bin/env bash\n')
|
||||
writeFileSync(join(pluginRoot, 'scripts', 'install.ps1'), '# ps1\n')
|
||||
})
|
||||
|
||||
afterEach(() => { rmSync(root, { recursive: true, force: true }) })
|
||||
|
||||
it('posix: bash installer with --repair and the detected profile', () => {
|
||||
const { command, note } = buildRepairCommand({ pluginRoot, profileDir, platform: 'darwin' })
|
||||
expect(command).toBe(`bash "${join(pluginRoot, 'scripts', 'install.sh')}" --repair --profile "web"`)
|
||||
expect(note).toBeUndefined()
|
||||
})
|
||||
|
||||
it('posix: omits --profile when the profile could not be detected', () => {
|
||||
const { command } = buildRepairCommand({ pluginRoot, profileDir: null, platform: 'linux' })
|
||||
expect(command).toBe(`bash "${join(pluginRoot, 'scripts', 'install.sh')}" --repair`)
|
||||
})
|
||||
|
||||
it('win32: powershell -ExecutionPolicy Bypass with -Repair and -Profile', () => {
|
||||
const { command } = buildRepairCommand({ pluginRoot, profileDir, platform: 'win32' })
|
||||
const expected = join(pluginRoot, 'scripts', 'install.ps1')
|
||||
expect(command).toBe(`powershell -ExecutionPolicy Bypass -File "${expected}" -Repair -Profile "web"`)
|
||||
})
|
||||
|
||||
it('falls back to the dsh plugin command when the plugin scripts are unavailable', () => {
|
||||
const bareRoot = join(root, 'bare')
|
||||
mkdirSync(bareRoot)
|
||||
const { command, note } = buildRepairCommand({ pluginRoot: bareRoot, profileDir, platform: 'darwin' })
|
||||
expect(command).toBe('dsh plugin --profile "web" install')
|
||||
expect(note).toContain('allowBuilds')
|
||||
})
|
||||
|
||||
it('falls back to the dsh plugin command (default web) when nothing is detectable', () => {
|
||||
const { command } = buildRepairCommand({ pluginRoot: null, profileDir: null, platform: 'darwin' })
|
||||
expect(command).toBe('dsh plugin --profile "web" install')
|
||||
})
|
||||
})
|
||||
|
||||
describe('findProfileDir / findPluginRoot', () => {
|
||||
let root: string
|
||||
let moduleFile: string
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'pty-deps-'))
|
||||
// A fake plugin module deep inside the profile's pnpm node_modules.
|
||||
const pkgRoot = join(root, 'profiles', 'web', 'node_modules', '.pnpm', 'dsh-better-sidebar@0.0.0', 'node_modules', 'dsh-better-sidebar')
|
||||
const libDir = join(pkgRoot, 'lib')
|
||||
mkdirSync(libDir, { recursive: true })
|
||||
writeFileSync(join(root, 'profiles', 'web', 'package.json'), JSON.stringify({ name: 'dsh-profile-web' }))
|
||||
writeFileSync(join(root, 'profiles', 'web', 'pnpm-workspace.yaml'), '')
|
||||
writeFileSync(join(pkgRoot, 'package.json'), JSON.stringify({ name: 'dsh-better-sidebar' }))
|
||||
moduleFile = join(libDir, 'index.js')
|
||||
writeFileSync(moduleFile, '')
|
||||
})
|
||||
|
||||
afterEach(() => { rmSync(root, { recursive: true, force: true }) })
|
||||
|
||||
it('walks up from the plugin module to the profile root (pnpm-workspace.yaml)', () => {
|
||||
expect(findProfileDir(moduleFile)).toBe(realpathSync(join(root, 'profiles', 'web')))
|
||||
})
|
||||
|
||||
it('walks up to the plugin package root by package.json name', () => {
|
||||
expect(findPluginRoot(moduleFile)).toBe(realpathSync(join(root, 'profiles', 'web', 'node_modules', '.pnpm', 'dsh-better-sidebar@0.0.0', 'node_modules', 'dsh-better-sidebar')))
|
||||
})
|
||||
|
||||
it('falls back to $DSH_HOME/profiles/web when no ancestor looks like a profile', () => {
|
||||
const home = join(root, 'home')
|
||||
const web = join(home, 'profiles', 'web')
|
||||
mkdirSync(web, { recursive: true })
|
||||
writeFileSync(join(web, 'package.json'), JSON.stringify({ name: 'dsh-profile-web' }))
|
||||
writeFileSync(join(web, 'pnpm-workspace.yaml'), '')
|
||||
// A bare module path with no profile-like ancestor (the walk-up must miss).
|
||||
const bareModule = join(root, 'elsewhere', 'lib', 'index.js')
|
||||
mkdirSync(dirname(bareModule), { recursive: true })
|
||||
writeFileSync(bareModule, '')
|
||||
const previous = process.env.DSH_HOME
|
||||
process.env.DSH_HOME = home
|
||||
try {
|
||||
expect(findProfileDir(bareModule)).toBe(realpathSync(web))
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.DSH_HOME
|
||||
else process.env.DSH_HOME = previous
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('depsStatus', () => {
|
||||
let root: string
|
||||
let moduleFile: string
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'pty-deps-'))
|
||||
const pkgRoot = join(root, 'profiles', 'web', 'node_modules', '.pnpm', 'dsh-better-sidebar@0.0.0', 'node_modules', 'dsh-better-sidebar')
|
||||
mkdirSync(join(pkgRoot, 'scripts'), { recursive: true })
|
||||
mkdirSync(join(pkgRoot, 'lib'), { recursive: true })
|
||||
writeFileSync(join(root, 'profiles', 'web', 'package.json'), JSON.stringify({ name: 'dsh-profile-web' }))
|
||||
writeFileSync(join(root, 'profiles', 'web', 'pnpm-workspace.yaml'), '')
|
||||
writeFileSync(join(pkgRoot, 'package.json'), JSON.stringify({ name: 'dsh-better-sidebar' }))
|
||||
writeFileSync(join(pkgRoot, 'scripts', 'install.sh'), '#!/usr/bin/env bash\n')
|
||||
moduleFile = join(pkgRoot, 'lib', 'index.js')
|
||||
writeFileSync(moduleFile, '')
|
||||
resetNodePtyCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
resetNodePtyCache()
|
||||
})
|
||||
|
||||
it('reports ok:true when node-pty loads', () => {
|
||||
expect(depsStatus({ fromFile: moduleFile })).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('reports the cause, the repair command and the profile when the load fails', () => {
|
||||
loadNodePty(failingRequire)
|
||||
const status = depsStatus({ fromFile: moduleFile })
|
||||
expect(status.ok).toBe(false)
|
||||
if (status.ok) throw new Error('unreachable')
|
||||
expect(status.cause).toBe('Cannot find package node-pty')
|
||||
const script = realpathSync(join(root, 'profiles', 'web', 'node_modules', '.pnpm', 'dsh-better-sidebar@0.0.0', 'node_modules', 'dsh-better-sidebar', 'scripts', 'install.sh'))
|
||||
expect(status.command).toBe(`bash "${script}" --repair --profile "web"`)
|
||||
expect(status.profile).toBe('web')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DSH sync contract', () => {
|
||||
it('the declared node-pty range stays identical to DSH core (dsh-subprocess-local ^1.1.0)', () => {
|
||||
expect(DSH_NODE_PTY_RANGE).toBe('^1.1.0')
|
||||
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
|
||||
dependencies: Record<string, string>
|
||||
}
|
||||
expect(pkg.dependencies['node-pty']).toBe(DSH_NODE_PTY_RANGE)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
import { 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 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 { resolveSidebarConfig } from '../src/config.ts'
|
||||
import { defaultShell, ensureSpawnHelper } from '../src/pty-manager.ts'
|
||||
|
||||
describe('pty helpers', () => {
|
||||
it('prefers an explicit shell, then SHELL, then the account login shell on POSIX', () => {
|
||||
// The platform is injected so this chain also runs (and is asserted) on
|
||||
// non-POSIX developer machines. The userInfo mock at the top of the file
|
||||
// pins the passwd login shell to /usr/bin/zsh.
|
||||
expect(defaultShell({ platform: 'linux', env: { SHELL: '/explicit/zsh' } })).toBe('/explicit/zsh')
|
||||
// Surrounding whitespace must not leak into the spawned executable path.
|
||||
expect(defaultShell({ platform: 'linux', env: { SHELL: ' /explicit/zsh ' } })).toBe('/explicit/zsh')
|
||||
expect(defaultShell({ platform: 'linux', env: { SHELL: ' ' } })).toBe('/usr/bin/zsh')
|
||||
expect(defaultShell({ platform: 'linux', env: {} })).toBe('/usr/bin/zsh')
|
||||
})
|
||||
|
||||
it('Windows: explicit shell wins, then DSH_SIDEBAR_SHELL, then a probed pwsh.exe, then powershell.exe 5.1', () => {
|
||||
// The explicit shell (the `shell` config field) beats every automatic
|
||||
// source, and whitespace-only values count as unset.
|
||||
expect(defaultShell({ platform: 'win32', explicit: 'C:\\Tools\\pwsh.exe', env: { DSH_SIDEBAR_SHELL: 'pwsh.exe' } }))
|
||||
.toBe('C:\\Tools\\pwsh.exe')
|
||||
expect(defaultShell({ platform: 'win32', explicit: ' ', env: { DSH_SIDEBAR_SHELL: 'pwsh.exe' } }))
|
||||
.toBe('pwsh.exe')
|
||||
|
||||
// PATH is probed entry by entry; the first directory containing pwsh.exe
|
||||
// wins and the returned value is the full resolved path. The candidate is
|
||||
// normalized to forward slashes before comparing so the assertion holds
|
||||
// under BOTH join() implementations: on the ubuntu runners node:path is
|
||||
// POSIX and joins 'C:\\other' + 'pwsh.exe' as 'C:\\other/pwsh.exe'.
|
||||
const fromPath = defaultShell({
|
||||
platform: 'win32',
|
||||
env: { PATH: 'C:\\tools;C:\\other' },
|
||||
exists: path => path.replaceAll('\\', '/') === 'C:/other/pwsh.exe',
|
||||
})
|
||||
expect(fromPath.replaceAll('\\', '/')).toBe('C:/other/pwsh.exe')
|
||||
|
||||
// PATH misses fall through to the known install directories. On 32-bit
|
||||
// Node, ProgramW6432 is the real 64-bit Program Files and is preferred.
|
||||
const fromProgramW6432 = defaultShell({
|
||||
platform: 'win32',
|
||||
env: { ProgramW6432: 'C:\\PF64' },
|
||||
exists: path => path.replaceAll('\\', '/') === 'C:/PF64/PowerShell/7/pwsh.exe',
|
||||
})
|
||||
expect(fromProgramW6432.replaceAll('\\', '/')).toBe('C:/PF64/PowerShell/7/pwsh.exe')
|
||||
const fromProgramFiles = defaultShell({
|
||||
platform: 'win32',
|
||||
env: { ProgramFiles: 'C:\\PF', LOCALAPPDATA: 'C:\\Users\\x\\AppData\\Local' },
|
||||
exists: path => path.replaceAll('\\', '/') === 'C:/PF/PowerShell/7/pwsh.exe',
|
||||
})
|
||||
expect(fromProgramFiles.replaceAll('\\', '/')).toBe('C:/PF/PowerShell/7/pwsh.exe')
|
||||
|
||||
// Nothing installed: keep the inbox 5.1 fallback instead of breaking.
|
||||
expect(defaultShell({ platform: 'win32', env: {}, exists: () => false })).toBe('powershell.exe')
|
||||
})
|
||||
|
||||
it('trims the configured shell and defaults it to auto for old documents', () => {
|
||||
expect(resolveSidebarConfig(undefined).shell).toBe('')
|
||||
expect(resolveSidebarConfig({ shell: ' pwsh.exe ' }).shell).toBe('pwsh.exe')
|
||||
})
|
||||
|
||||
it('restores the spawn-helper executable bit idempotently', () => {
|
||||
// node-pty's spawn-helper is a macOS-only artifact: binding.gyp builds
|
||||
// the executable only for OS=="mac", and no other platform ships one to
|
||||
// restore (linux uses forkpty directly). Skip elsewhere.
|
||||
if (process.platform !== 'darwin') return
|
||||
ensureSpawnHelper()
|
||||
ensureSpawnHelper()
|
||||
const { existsSync, statSync } = require('node:fs') as typeof import('node:fs')
|
||||
const { dirname, join } = require('node:path') as typeof import('node:path')
|
||||
const { createRequire } = require('node:module') as typeof import('node:module')
|
||||
const entry = createRequire(import.meta.url).resolve('node-pty')
|
||||
const root = dirname(dirname(entry))
|
||||
// Prebuilt (tarball) or node-gyp-compiled (build/Release): mirror
|
||||
// ensureSpawnHelper's candidate list — either location is acceptable.
|
||||
const candidates = [
|
||||
join(root, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'),
|
||||
join(root, 'build', 'Release', 'spawn-helper'),
|
||||
]
|
||||
const helper = candidates.find(existsSync)
|
||||
expect(helper).toBeTruthy()
|
||||
expect((statSync(helper!).mode & 0o111) !== 0).toBe(true)
|
||||
})
|
||||
})
|
||||
+33
-9
@@ -21,7 +21,7 @@ if (g.localStorage === undefined) {
|
||||
}
|
||||
|
||||
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, openTabInActivePane, sanitizeState } from '../src/client/state.ts'
|
||||
|
||||
describe('BetterSidebar service', () => {
|
||||
it('registerTab adds to the registry and dispose removes it', () => {
|
||||
@@ -108,8 +108,9 @@ describe('enable switches (declarative settings)', () => {
|
||||
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { explorer: false } })
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'explorer', title: 'Explorer' })
|
||||
// The seeded files-window home tab stays; no EXPLORER tab landed.
|
||||
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs)
|
||||
expect(tabs).toHaveLength(0)
|
||||
expect(tabs.filter(t => t.type === 'explorer')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('matchFileViewer skips a disabled viewer (files fall through)', () => {
|
||||
@@ -352,7 +353,8 @@ describe('service.openTab dedupe', () => {
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'editor')
|
||||
// Find by path: the seeded files-window home tab is an editor tab too.
|
||||
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'editor' && t.path === '/p/main.ts')
|
||||
expect(tab?.title).toBe('main.ts')
|
||||
})
|
||||
|
||||
@@ -643,7 +645,7 @@ describe('service.openTab auto-expand for content opens', () => {
|
||||
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
|
||||
const state = store.getSnapshot().state!
|
||||
expect(state.panelOpen).toBe(true)
|
||||
expect(allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'editor')).toHaveLength(1)
|
||||
expect(allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'editor' && t.path === '/p/main.ts')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -671,7 +673,7 @@ describe('state subscription (v0.12.0)', () => {
|
||||
const snapshot = service.getSnapshot()
|
||||
expect(snapshot.sessionId).toBe('s1')
|
||||
expect(snapshot.state).toBeDefined()
|
||||
expect(snapshot.prefs.openByDefault).toBe(true)
|
||||
expect(snapshot.prefs.openByDefault).toBe(false)
|
||||
})
|
||||
|
||||
it('subscribeState fires on state changes but NOT on registry changes', () => {
|
||||
@@ -739,7 +741,7 @@ describe('targeted openTab (v0.12.0)', () => {
|
||||
service.registerTab({ id: 'notes', title: 'Notes', component: () => null })
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'notes', title: 'Notes', id: 'notes:1' }, { sessionId: 's2' })
|
||||
// The UI snapshot still shows s1, untouched (its default explorer tab
|
||||
// The UI snapshot still shows s1, untouched (its default files-window tab
|
||||
// is the only one — no notes tab landed there).
|
||||
const snapshot = store.getSnapshot()
|
||||
expect(snapshot.sessionId).toBe('s1')
|
||||
@@ -783,7 +785,8 @@ describe('openFile (v0.12.0)', () => {
|
||||
store.setSession('s1')
|
||||
service.openFile({ sessionId: 's1', cwd: '/p' }, '/p/src/main.ts')
|
||||
const state = store.getSnapshot().state!
|
||||
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'editor')
|
||||
// Find by path: the seeded files-window home tab is an editor tab too.
|
||||
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'editor' && t.path !== undefined)
|
||||
expect(tab?.title).toBe('main.ts')
|
||||
expect(tab?.path).toBe('/p/src/main.ts')
|
||||
// Windows separators are handled too.
|
||||
@@ -892,8 +895,9 @@ describe('tab meta (v0.12.0)', () => {
|
||||
})
|
||||
|
||||
it('older persisted tabs (no meta) sanitize unchanged', () => {
|
||||
const state = makeDefaultState(400, true, true)
|
||||
const sanitized = sanitizeState(JSON.parse(JSON.stringify(state)))
|
||||
const state = makeDefaultState(400, true, 'none')
|
||||
const withTab = openTabInActivePane(state, { id: 'tab:old', type: 'git', title: 'Git' })
|
||||
const sanitized = sanitizeState(JSON.parse(JSON.stringify(withTab)))
|
||||
const tabs = allLeaves(sanitized!.splits).flatMap(l => l.tabs)
|
||||
expect(tabs[0]?.meta).toBeUndefined()
|
||||
})
|
||||
@@ -1031,4 +1035,24 @@ describe('independent CR follow-up fixes', () => {
|
||||
{ kind: 'close', cwd: '/work' },
|
||||
])
|
||||
})
|
||||
|
||||
it('SIDEBAR_FEATURES snapshot and service version checks', () => {
|
||||
expect(SIDEBAR_FEATURES).toContain('urlTarget')
|
||||
expect(SIDEBAR_FEATURES).toContain('pluginSettings')
|
||||
expect(SIDEBAR_FEATURES).toContain('tabLifecycle')
|
||||
expect(SIDEBAR_FEATURES).toContain('badge')
|
||||
expect(SIDEBAR_FEATURES).toContain('updateTab')
|
||||
expect(SIDEBAR_FEATURES).toContain('openFile')
|
||||
expect(SIDEBAR_FEATURES).toContain('targetedOpen')
|
||||
expect(SIDEBAR_FEATURES).toContain('stateSubscription')
|
||||
expect(SIDEBAR_FEATURES).toContain('tabMeta')
|
||||
expect(SIDEBAR_SERVICE_VERSION).toMatch(/^\d+\.\d+\.\d+/)
|
||||
})
|
||||
|
||||
it('registerFileViewer throws on duplicate id', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerFileViewer({ id: 'csv', exts: ['csv'], fetchStrategy: 'custom', component: () => null })
|
||||
expect(() => service.registerFileViewer({ id: 'csv', exts: ['csv'], fetchStrategy: 'custom', component: () => null })).toThrow(/already registered/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Settings-navigation icon marker tests (including delayed dialog mount). */
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { registerSettingsNavIcon, SETTINGS_NAV_MARKER } from '../src/client/settings-nav-icon.ts'
|
||||
|
||||
function navButton(label: string): HTMLButtonElement {
|
||||
const button = document.createElement('button')
|
||||
button.innerHTML = `<svg data-fallback="gear"></svg><span>${label}</span>`
|
||||
return button
|
||||
}
|
||||
|
||||
function dialogWith(...buttons: HTMLButtonElement[]): HTMLElement {
|
||||
const dialog = document.createElement('div')
|
||||
dialog.setAttribute('role', 'dialog')
|
||||
const nav = document.createElement('nav')
|
||||
nav.append(...buttons)
|
||||
dialog.append(nav)
|
||||
return dialog
|
||||
}
|
||||
|
||||
async function mutationTick(): Promise<void> {
|
||||
await new Promise<void>(resolve => { setTimeout(resolve, 0) })
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('registerSettingsNavIcon', () => {
|
||||
it('marks only the matching settings row and preserves the shell icon DOM', () => {
|
||||
const general = navButton('General')
|
||||
const sideCard = navButton('Side card')
|
||||
document.body.append(dialogWith(general, sideCard))
|
||||
|
||||
const dispose = registerSettingsNavIcon(() => 'Side card')
|
||||
|
||||
expect(general.hasAttribute(SETTINGS_NAV_MARKER)).toBe(false)
|
||||
expect(sideCard.hasAttribute(SETTINGS_NAV_MARKER)).toBe(true)
|
||||
expect(sideCard.querySelector('[data-fallback="gear"]')).not.toBeNull()
|
||||
dispose()
|
||||
expect(sideCard.hasAttribute(SETTINGS_NAV_MARKER)).toBe(false)
|
||||
})
|
||||
|
||||
it('marks a dialog mounted later and follows a localized label change', async () => {
|
||||
let label = 'Side card'
|
||||
const dispose = registerSettingsNavIcon(() => label)
|
||||
const english = navButton('Side card')
|
||||
const chinese = navButton('侧边卡片')
|
||||
const dialog = dialogWith(english, chinese)
|
||||
document.body.append(dialog)
|
||||
await mutationTick()
|
||||
|
||||
expect(english.hasAttribute(SETTINGS_NAV_MARKER)).toBe(true)
|
||||
expect(chinese.hasAttribute(SETTINGS_NAV_MARKER)).toBe(false)
|
||||
|
||||
label = '侧边卡片'
|
||||
chinese.querySelector('span')!.textContent = '侧边卡片 '
|
||||
await mutationTick()
|
||||
expect(english.hasAttribute(SETTINGS_NAV_MARKER)).toBe(false)
|
||||
expect(chinese.hasAttribute(SETTINGS_NAV_MARKER)).toBe(true)
|
||||
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* Interactive tests for the settings popup's text/number rows: the draft is
|
||||
* local state committed on blur through the parent's onCommit; the parent's
|
||||
* canonical return is adopted (clamped numbers, stored value for invalid
|
||||
* input), and the row remounts when the committed pref value changes (a
|
||||
* failed commit reverts prefs → the stored value reappears).
|
||||
* Interactive tests for the settings popup's text/number/select rows: the
|
||||
* draft is local state committed on blur through the parent's onCommit; the
|
||||
* parent's canonical return is adopted (clamped numbers, stored value for
|
||||
* invalid input), and the row remounts when the committed pref value changes
|
||||
* (a failed commit reverts prefs → the stored value reappears). Select rows
|
||||
* commit the picked option's value (single) or the picked values in options
|
||||
* order (multi) through onSelectValue.
|
||||
*
|
||||
* Rendered with createRoot + act() in jsdom (the SSR specs stay in
|
||||
* side-card-section.spec.tsx; this file exercises the event paths).
|
||||
@@ -173,3 +175,99 @@ describe('FeatureSettingsRows typed rows (interactive)', () => {
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
/** Open one select row's dropdown: click the anchor, the Menu portals its
|
||||
* list into document.body (role="menuitem" rows). */
|
||||
function openSelect(container: HTMLDivElement): HTMLElement[] {
|
||||
const anchor = container.querySelector<HTMLButtonElement>('button[aria-haspopup="listbox"]')!
|
||||
act(() => { anchor.click() })
|
||||
return [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
||||
}
|
||||
|
||||
describe('FeatureSettingsRows select rows (interactive)', () => {
|
||||
const options: NonNullable<SidebarSettingToggle['options']> = [
|
||||
{ value: true, icon: (size: number) => createElement('i', { 'data-size': size }), title: () => 'Merged', desc: () => 'Docked tree' },
|
||||
{ value: false, icon: (size: number) => createElement('i', { 'data-size': size }), title: () => 'Split', desc: () => 'Two tabs' },
|
||||
]
|
||||
|
||||
it('commits the picked option value and closes (iconed single select)', () => {
|
||||
const commits: Array<[string, unknown]> = []
|
||||
const toggle: SidebarSettingToggle = {
|
||||
key: 'editorExplorer',
|
||||
type: 'select',
|
||||
title: () => 'Editor explorer',
|
||||
options,
|
||||
}
|
||||
const { container, unmount } = mount(createElement(FeatureSettingsRows, {
|
||||
toggles: [toggle],
|
||||
prefs,
|
||||
onToggle: () => {},
|
||||
onSelectValue: (t, next) => { commits.push([t.key, next]) },
|
||||
}))
|
||||
// The closed anchor shows the selected option's title (icon variant).
|
||||
expect(container.textContent).toContain('Merged')
|
||||
const items = openSelect(container)
|
||||
// Big-icon cards: title + desc per option.
|
||||
expect(items.map(item => item.textContent)).toEqual(['MergedDocked tree', 'SplitTwo tabs'])
|
||||
act(() => { items[1]!.click() })
|
||||
expect(commits).toEqual([['editorExplorer', false]])
|
||||
// Single-pick closes the dropdown.
|
||||
expect(document.querySelectorAll('[role="menuitem"]')).toHaveLength(0)
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('toggles membership and commits the picked values in options order (multi)', () => {
|
||||
const commits: Array<[string, unknown]> = []
|
||||
const plain: NonNullable<SidebarSettingToggle['options']> = [
|
||||
{ value: 'a', title: () => 'Alpha' },
|
||||
{ value: 'b', title: () => 'Beta' },
|
||||
{ value: 'c', title: () => 'Gamma' },
|
||||
]
|
||||
const toggle: SidebarSettingToggle = {
|
||||
key: 'pluginKey',
|
||||
type: 'select',
|
||||
multi: true,
|
||||
title: () => 'Pick several',
|
||||
options: plain,
|
||||
}
|
||||
const rows = (value: unknown) => createElement(FeatureSettingsRows, {
|
||||
toggles: [toggle],
|
||||
prefs,
|
||||
onToggle: () => {},
|
||||
onSelectValue: (t, next) => { commits.push([t.key, next]) },
|
||||
valueSource: () => value,
|
||||
})
|
||||
const { container, rerender, unmount } = mount(rows(['c', 'a']))
|
||||
// The anchor shows the picked titles; options follow the declared order.
|
||||
const items = openSelect(container)
|
||||
expect(items.map(item => item.textContent)).toEqual(['Alpha', 'Beta', 'Gamma'])
|
||||
// Deselect 'a' → ['c'] is normalized to options order… (['c'] here).
|
||||
act(() => { items[0]!.click() })
|
||||
expect(commits).toEqual([['pluginKey', ['c']]])
|
||||
// The menu stays open under multi; re-render with the committed value.
|
||||
rerender(rows(['c']))
|
||||
const again = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
||||
expect(again).toHaveLength(3)
|
||||
// Select 'b' → committed in OPTIONS order, not pick order.
|
||||
act(() => { again[1]!.click() })
|
||||
expect(commits[1]).toEqual(['pluginKey', ['b', 'c']])
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('shows an em dash when nothing is selected', () => {
|
||||
const toggle: SidebarSettingToggle = {
|
||||
key: 'editorExplorer',
|
||||
type: 'select',
|
||||
title: () => 'Editor explorer',
|
||||
options,
|
||||
}
|
||||
const { container, unmount } = mount(createElement(FeatureSettingsRows, {
|
||||
toggles: [toggle],
|
||||
prefs,
|
||||
onToggle: () => {},
|
||||
valueSource: () => undefined,
|
||||
}))
|
||||
expect(container.querySelector('button[aria-haspopup="listbox"]')!.textContent).toContain('—')
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -80,13 +80,14 @@ describe('SideCardSection declarative inventory', () => {
|
||||
expect(html).toContain('>explorer<')
|
||||
expect(html).toContain('data-icon="subagent"')
|
||||
expect(html).toContain('>Subagents<')
|
||||
// Default prefs: openByDefault + interceptOpenPath switches checked, and
|
||||
// both tabs + the image viewer cards pressed (3 aria-pressed cards).
|
||||
// Default prefs: only the interceptOpenPath switch is checked (openByDefault
|
||||
// now defaults off), and both tabs + the image viewer cards pressed
|
||||
// (3 aria-pressed cards).
|
||||
// The nested auto-open toggle is NOT an inline card (it lives in the popup).
|
||||
expect(pressedCount(html, 'true')).toBe(3)
|
||||
expect(pressedCount(html, 'false')).toBe(0)
|
||||
// The general toggles are custom switches (real checkboxes, checked).
|
||||
expect(html.match(/checked=""/g)?.length).toBe(2)
|
||||
// The general toggles are custom switches (real checkboxes, one checked).
|
||||
expect(html.match(/checked=""/g)?.length).toBe(1)
|
||||
expect(html).not.toContain('Auto-open Subagents')
|
||||
})
|
||||
|
||||
@@ -140,9 +141,9 @@ describe('SideCardSection declarative inventory', () => {
|
||||
expect(html).toContain('>Subagents<')
|
||||
expect(html).toContain('>Image<')
|
||||
expect(pressedCount(html, 'false')).toBe(2)
|
||||
// The explorer card stays pressed; the general switches stay checked.
|
||||
// The explorer card stays pressed; the one default-on general switch stays checked.
|
||||
expect(pressedCount(html, 'true')).toBe(1)
|
||||
expect(html.match(/checked=""/g)?.length).toBe(2)
|
||||
expect(html.match(/checked=""/g)?.length).toBe(1)
|
||||
})
|
||||
|
||||
it('hides the gear of a disabled feature (its related settings are dormant)', () => {
|
||||
@@ -158,11 +159,11 @@ describe('SideCardSection declarative inventory', () => {
|
||||
// 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.
|
||||
// Three general rows now: only interceptOpenPath is checked by default
|
||||
// (openByDefault defaults off, the titleBarCompat row starts UNCHECKED) —
|
||||
// the checked checkbox count is 1 while the total checkbox count is 3.
|
||||
expect(html.match(/type="checkbox"/g)?.length).toBe(3)
|
||||
expect(html.match(/checked=""/g)?.length).toBe(2)
|
||||
expect(html.match(/checked=""/g)?.length).toBe(1)
|
||||
// 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')
|
||||
@@ -170,7 +171,7 @@ describe('SideCardSection declarative inventory', () => {
|
||||
// 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.match(/checked=""/g)?.length).toBe(2)
|
||||
expect(html).toContain('aria-label="Position compatibility mode Feature settings"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,7 +53,8 @@ function mountSidebar(): MountedSidebar {
|
||||
document.body.append(container)
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
// Fresh-session seed: the panel starts OPEN (openByDefault default true).
|
||||
// Fresh-session seed: open the panel explicitly (openByDefault defaults off).
|
||||
store.setPrefs({ ...store.getPrefs(), openByDefault: true })
|
||||
store.setSession('s1')
|
||||
// useSyncExternalStore requires STABLE snapshots across calls (the real DSH
|
||||
// services return stable objects) — a fresh object per call loops forever.
|
||||
|
||||
+57
-10
@@ -12,7 +12,7 @@ import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-setti
|
||||
import { apply, mediaTypeForPath } from '../src/index.ts'
|
||||
import * as git from '../src/git.ts'
|
||||
import { listDirectory } from '../src/fs-tree.ts'
|
||||
import { defaultShell, PtyManager } from '../src/pty-manager.ts'
|
||||
import { defaultShell, PtyManager, type SidebarPty } from '../src/pty-manager.ts'
|
||||
import type { SidebarWebRoute, SidebarWebUpgradeRoute } from '../src/context-types.ts'
|
||||
|
||||
interface FakeContext {
|
||||
@@ -31,6 +31,32 @@ interface FakeContext {
|
||||
get: (key: string) => undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The login-shell test spawns a real pty whose bash may still be writing to
|
||||
* the temp HOME (history files, etc.) when `disposeAll()` returns — `close()`
|
||||
* only requests the kill and the process exit lands asynchronously in
|
||||
* `onExit`. Deleting the directory immediately then races the shell and
|
||||
* fails with ENOTEMPTY on CI. Wait for the spawned handle to report `exited`
|
||||
* (bounded), then remove with a short retry loop as a belt-and-braces
|
||||
* fallback for any straggler fd.
|
||||
*/
|
||||
async function rmTempDirAfterPtyExit(handle: { exited: boolean }, dir: string): Promise<void> {
|
||||
const deadline = Date.now() + 2000
|
||||
while (Date.now() < deadline && !handle.exited) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
return
|
||||
} catch (error) {
|
||||
const busy = (error as NodeJS.ErrnoException).code === 'ENOTEMPTY' || (error as NodeJS.ErrnoException).code === 'EBUSY'
|
||||
if (!busy || attempt >= 4) throw error
|
||||
await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('host plugin smoke', () => {
|
||||
it('serves PDF with the browser-native content type', () => {
|
||||
expect(mediaTypeForPath('/work/report.PDF')).toBe('application/pdf')
|
||||
@@ -191,6 +217,7 @@ 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
|
||||
let handle: SidebarPty | undefined
|
||||
try {
|
||||
// A login bash reads ~/.profile (a non-login interactive bash reads
|
||||
// ~/.bashrc instead), so this marker proves the spawn used a login
|
||||
@@ -199,7 +226,7 @@ describe('host plugin smoke', () => {
|
||||
process.env.HOME = home
|
||||
const manager = new PtyManager('/bin/bash', 3)
|
||||
try {
|
||||
const handle = manager.open('s5', 't1', process.cwd(), 80, 24)
|
||||
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) {
|
||||
@@ -212,7 +239,7 @@ describe('host plugin smoke', () => {
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME
|
||||
else process.env.HOME = previousHome
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
await rmTempDirAfterPtyExit(handle ?? { exited: true }, home)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -449,11 +476,15 @@ describe('session cwd resolution over the API route', () => {
|
||||
|
||||
describe('side card settings routes', () => {
|
||||
/** A minimal settings seam: register/describe/update with the revision guard. */
|
||||
const createFakeSettings = () => { const namespaces = new Map<string, {
|
||||
const createFakeSettings = (pre?: Record<string, Record<string, unknown>>) => {
|
||||
const namespaces = new Map<string, {
|
||||
schema: unknown
|
||||
value: Record<string, unknown> | undefined
|
||||
revision: number
|
||||
}>()
|
||||
for (const [ns, value] of Object.entries(pre ?? {})) {
|
||||
namespaces.set(ns, { schema: (input: unknown) => input, value, revision: 0 })
|
||||
}
|
||||
const resolve = (entry: { schema: unknown; value: Record<string, unknown> | undefined }): unknown => {
|
||||
const schema = entry.schema as (input: unknown) => unknown
|
||||
return entry.value === undefined ? schema(undefined) : schema(entry.value)
|
||||
@@ -530,7 +561,21 @@ describe('side card settings routes', () => {
|
||||
const route = mountWithSettings(undefined)
|
||||
const result = await invoke(route, 'settings.get', {})
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.value).toEqual({ value: undefined, revision: undefined })
|
||||
expect(result.value).toEqual({ value: undefined, revision: undefined, externalDisable: false })
|
||||
})
|
||||
|
||||
it('reports externalDisable false when the aionui namespace is absent', async () => {
|
||||
const route = mountWithSettings(createFakeSettings())
|
||||
const result = await invoke(route, 'settings.get', {})
|
||||
expect(result.ok).toBe(true)
|
||||
expect((result.value as { externalDisable?: boolean }).externalDisable).toBe(false)
|
||||
})
|
||||
|
||||
it('reports externalDisable true while the aionui provider is selected', async () => {
|
||||
const route = mountWithSettings(createFakeSettings({ 'aionui-panel': { rightPanel: 'aionui-panel' } }))
|
||||
const result = await invoke(route, 'settings.get', {})
|
||||
expect(result.ok).toBe(true)
|
||||
expect((result.value as { externalDisable?: boolean }).externalDisable).toBe(true)
|
||||
})
|
||||
|
||||
it('reads the resolved prefs and writes a patch through the seam', async () => {
|
||||
@@ -539,8 +584,8 @@ describe('side card settings routes', () => {
|
||||
expect(read.ok).toBe(true)
|
||||
expect(read.value).toEqual({
|
||||
value: {
|
||||
openByDefault: true,
|
||||
defaultWidthPercent: 30,
|
||||
openByDefault: false,
|
||||
defaultWidthPercent: 35,
|
||||
autoOpenSubagent: true,
|
||||
autoOpenJobs: true,
|
||||
agentTerminalTools: false,
|
||||
@@ -548,6 +593,7 @@ describe('side card settings routes', () => {
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: 13,
|
||||
interceptOpenPath: true,
|
||||
editorExplorer: true,
|
||||
titleBarCompat: false,
|
||||
titleBarStripPx: 40,
|
||||
htmlViewerNoSandbox: false,
|
||||
@@ -563,13 +609,14 @@ describe('side card settings routes', () => {
|
||||
pluginSettings: {},
|
||||
},
|
||||
revision: 0,
|
||||
externalDisable: false,
|
||||
})
|
||||
|
||||
const written = await invoke(route, 'settings.update', { patch: { openByDefault: false } })
|
||||
const written = await invoke(route, 'settings.update', { patch: { openByDefault: true } })
|
||||
expect(written.ok).toBe(true)
|
||||
const view = written.value as { value: { openByDefault: boolean; defaultWidthPercent: number }; revision: number }
|
||||
expect(view.value.openByDefault).toBe(false)
|
||||
expect(view.value.defaultWidthPercent).toBe(30)
|
||||
expect(view.value.openByDefault).toBe(true)
|
||||
expect(view.value.defaultWidthPercent).toBe(35)
|
||||
expect(view.revision).toBe(1)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,875 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
activateTab, allLeaves, BOTTOM_DEFAULT, BOTTOM_MIN, closeTab, createSidebarStore,
|
||||
insertLeafAt, makeDefaultState, migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab,
|
||||
openTabInActivePane, patchTab, resizeSplit, resizeSplitIn, sanitizeState, setBottomHeight,
|
||||
splitPane, tabOpenIn, toggleBottomPanel, toggleExpanded, togglePanel,
|
||||
type SidebarState, type SidebarTab, type SplitNode,
|
||||
} from '../src/client/state.ts'
|
||||
|
||||
describe('sidebar state', () => {
|
||||
const state = (): SidebarState => makeDefaultState()
|
||||
|
||||
it('makeDefaultState seeds per the seed enum (editor-home / none)', () => {
|
||||
// Default and explicit 'editor-home' seed an EMPTY editor tab (the files
|
||||
// window) with the tree panel pinned open.
|
||||
for (const s of [makeDefaultState(), makeDefaultState(400, true, 'editor-home')]) {
|
||||
const leaf = s.splits as { tabs: SidebarTab[]; active: string | null }
|
||||
expect(leaf.tabs).toHaveLength(1)
|
||||
expect(leaf.tabs[0]!.type).toBe('editor')
|
||||
expect(leaf.tabs[0]!.title).toBe('Files')
|
||||
expect(leaf.tabs[0]!.path).toBeUndefined()
|
||||
expect(leaf.tabs[0]!.meta).toEqual({ treeOpen: true })
|
||||
expect(leaf.active).toBe(leaf.tabs[0]!.id)
|
||||
}
|
||||
// The seeded home tab survives the persist round-trip (meta intact).
|
||||
const restored = sanitizeState(JSON.parse(JSON.stringify(makeDefaultState())))
|
||||
const restoredLeaf = restored!.splits as { tabs: SidebarTab[] }
|
||||
expect(restoredLeaf.tabs[0]!.meta).toEqual({ treeOpen: true })
|
||||
// 'none' seeds an empty pane.
|
||||
const bare = makeDefaultState(400, true, 'none')
|
||||
const bareLeaf = bare.splits as { tabs: SidebarTab[]; active: string | null }
|
||||
expect(bareLeaf.tabs).toHaveLength(0)
|
||||
expect(bareLeaf.active).toBeNull()
|
||||
})
|
||||
|
||||
it('sanitizeState migrates persisted explorer tabs to editor home tabs (both trees)', () => {
|
||||
const valid = sanitizeState({
|
||||
panelOpen: true,
|
||||
width: 400,
|
||||
nextTerminal: 1,
|
||||
activePane: 'pane:1',
|
||||
expanded: [],
|
||||
splits: {
|
||||
kind: 'leaf',
|
||||
id: 'pane:1',
|
||||
active: 'ex-right',
|
||||
tabs: [{ id: 'ex-right', type: 'explorer', title: 'Explorer', meta: { treeWidth: 300 } }],
|
||||
},
|
||||
bottomSplits: {
|
||||
kind: 'leaf',
|
||||
id: 'pane:b',
|
||||
active: 'ex-bottom',
|
||||
tabs: [{ id: 'ex-bottom', type: 'explorer', title: 'Explorer' }],
|
||||
},
|
||||
})
|
||||
const right = (valid?.splits as { tabs: SidebarTab[] }).tabs
|
||||
expect(right).toHaveLength(1)
|
||||
// Migrated: editor home tab (no path), tree pinned open, prior meta kept.
|
||||
expect(right[0]).toMatchObject({ id: 'ex-right', type: 'editor', title: 'Files', meta: { treeOpen: true, treeWidth: 300 } })
|
||||
expect(right[0]!.path).toBeUndefined()
|
||||
const bottom = (valid?.bottomSplits as { tabs: SidebarTab[] }).tabs
|
||||
expect(bottom).toHaveLength(1)
|
||||
expect(bottom[0]).toMatchObject({ id: 'ex-bottom', type: 'editor', title: 'Files', meta: { treeOpen: true } })
|
||||
})
|
||||
|
||||
it('opens tabs into the active pane and dedupes by id (safety net)', () => {
|
||||
let s = state()
|
||||
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
|
||||
s = openTabInActivePane(s, gitTab)
|
||||
expect(s.splits.kind).toBe('leaf')
|
||||
expect((s.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
|
||||
// Reopening with the SAME id focuses the existing tab instead of duplicating.
|
||||
const after = openTabInActivePane(s, { id: 'git', type: 'git' as const, title: 'Git' })
|
||||
expect((after.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
|
||||
// A different id opens a new tab (type-level dedupe is the service's job).
|
||||
const after2 = openTabInActivePane(s, { id: 'git2', type: 'git' as const, title: 'Git' })
|
||||
expect((after2.splits as { tabs: unknown[] }).tabs).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('opens multiple editors with distinct ids (path-level dedupe is the service descriptor\'s job)', () => {
|
||||
let s = state()
|
||||
const firstId = (s.splits as { tabs: { id: string }[] }).tabs[0]!.id
|
||||
s = openTabInActivePane(s, { id: 'e1', type: 'editor', title: 'a.ts', path: '/p/a.ts' })
|
||||
const after = openTabInActivePane(s, { id: 'e2', type: 'editor', title: 'a.ts', path: '/p/a.ts' })
|
||||
expect((after.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([firstId, 'e1', 'e2'])
|
||||
})
|
||||
|
||||
const diffTab = (id: string): SidebarTab => ({
|
||||
id,
|
||||
type: 'diff',
|
||||
title: id,
|
||||
diff: { kind: 'worktree', path: 'src/a.ts', staged: false },
|
||||
})
|
||||
|
||||
it('first diff splits the source pane vertically (diff below)', () => {
|
||||
const s = state()
|
||||
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
|
||||
const withGit = openTabInActivePane(s, gitTab)
|
||||
const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id
|
||||
const after = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts'))
|
||||
expect(after.splits.kind).toBe('split')
|
||||
const split = after.splits as { dir: string; children: { kind: string; tabs?: SidebarTab[]; id: string }[] }
|
||||
expect(split.dir).toBe('col')
|
||||
expect(split.children).toHaveLength(2)
|
||||
// The source stays on TOP (first child), the diff lands in the new bottom leaf.
|
||||
expect(split.children[0]!.id).toBe(sourcePane)
|
||||
expect(split.children[1]!.tabs?.map(tab => tab.id)).toEqual(['diff:w:u:src/a.ts'])
|
||||
expect(after.activePane).toBe(split.children[1]!.id)
|
||||
})
|
||||
|
||||
it('reopening the same diff focuses its existing tab', () => {
|
||||
const s = state()
|
||||
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
|
||||
const withGit = openTabInActivePane(s, gitTab)
|
||||
const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id
|
||||
const first = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts'))
|
||||
const second = openDiffTab(first, sourcePane, diffTab('diff:w:u:src/a.ts'))
|
||||
// No new panes, no duplicate tabs.
|
||||
expect(second.splits.kind).toBe('split')
|
||||
const split = second.splits as { children: { kind: string; tabs?: SidebarTab[] }[] }
|
||||
const allTabs = split.children.flatMap(child => child.tabs ?? [])
|
||||
expect(allTabs.filter(tab => tab.type === 'diff')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('subsequent diffs stack into the existing diff pane', () => {
|
||||
const s = state()
|
||||
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
|
||||
const withGit = openTabInActivePane(s, gitTab)
|
||||
const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id
|
||||
const first = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts'))
|
||||
const second = openDiffTab(first, sourcePane, diffTab('diff:c:abc1234def5678abc1234def5678abc1234def5678'))
|
||||
// Still one split: the second diff joins the bottom leaf instead of splitting again.
|
||||
expect(second.splits.kind).toBe('split')
|
||||
const split = second.splits as { children: { kind: string; tabs?: SidebarTab[] }[] }
|
||||
const diffLeaves = split.children.filter(child => child.tabs?.some(tab => tab.type === 'diff'))
|
||||
expect(diffLeaves).toHaveLength(1)
|
||||
expect(diffLeaves[0]!.tabs?.map(tab => tab.id)).toEqual([
|
||||
'diff:w:u:src/a.ts',
|
||||
'diff:c:abc1234def5678abc1234def5678abc1234def5678',
|
||||
])
|
||||
})
|
||||
|
||||
it('openDiffTab degrades to a regular open when the source pane is gone', () => {
|
||||
const s = state()
|
||||
const after = openDiffTab(s, 'pane:gone', diffTab('diff:w:u:src/a.ts'))
|
||||
expect(after.splits.kind).toBe('leaf')
|
||||
expect((after.splits as { tabs: SidebarTab[] }).tabs.map(tab => tab.id)).toContain('diff:w:u:src/a.ts')
|
||||
})
|
||||
|
||||
it('sanitize drops diff tabs (ephemeral, like VSCode diff editors)', () => {
|
||||
const valid = sanitizeState({
|
||||
panelOpen: true,
|
||||
width: 400,
|
||||
nextTerminal: 1,
|
||||
activePane: 'pane:1',
|
||||
expanded: [],
|
||||
splits: {
|
||||
kind: 'leaf',
|
||||
id: 'pane:1',
|
||||
active: 'd1',
|
||||
tabs: [
|
||||
{ id: 'explorer-tab', type: 'explorer', title: 'Explorer' },
|
||||
{ id: 'd1', type: 'diff', title: 'a.ts', diff: { kind: 'worktree', path: 'src/a.ts', staged: false } },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(valid?.splits.kind).toBe('leaf')
|
||||
const tabs = (valid?.splits as { tabs: SidebarTab[] }).tabs
|
||||
expect(tabs.map(tab => tab.id)).toEqual(['explorer-tab'])
|
||||
// The dropped diff tab was the active one: the leaf falls back to a null
|
||||
// active instead of resetting the whole state.
|
||||
expect((valid?.splits as { active: string | null }).active).toBeNull()
|
||||
// A leaf of ONLY diff tabs survives as an empty pane (welcome cards).
|
||||
const onlyDiff = sanitizeState({
|
||||
panelOpen: true,
|
||||
width: 400,
|
||||
nextTerminal: 1,
|
||||
activePane: 'pane:1',
|
||||
expanded: [],
|
||||
splits: {
|
||||
kind: 'leaf',
|
||||
id: 'pane:1',
|
||||
active: 'd1',
|
||||
tabs: [{ id: 'd1', type: 'diff', title: 'a.ts' }],
|
||||
},
|
||||
})
|
||||
expect(onlyDiff?.splits.kind).toBe('leaf')
|
||||
expect((onlyDiff?.splits as { tabs: SidebarTab[] }).tabs).toEqual([])
|
||||
})
|
||||
|
||||
it('dedupes the single-instance subagent tab (focuses instead of duplicating)', () => {
|
||||
let s = state()
|
||||
s = openTabInActivePane(s, { id: 'subagent', type: 'subagent', title: 'Subagents' })
|
||||
expect((s.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
|
||||
// Reopening (e.g. the auto-activation effect) focuses the existing tab.
|
||||
const after = openTabInActivePane(s, { id: 'subagent', type: 'subagent', title: 'Subagents' })
|
||||
expect((after.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
|
||||
const tabs = (after.splits as { tabs: { type: string; id: string }[] }).tabs
|
||||
expect(tabs.filter(tab => tab.type === 'subagent')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('splits panes and moves tabs between them', () => {
|
||||
let s = state()
|
||||
s = splitPane(s, 'row')
|
||||
expect(s.splits.kind).toBe('split')
|
||||
const split = s.splits as Extract<SplitNode, { kind: 'split' }>
|
||||
expect(split.children).toHaveLength(2)
|
||||
const explorerId = (split.children[0] as { id: string }).id
|
||||
const otherId = (split.children[1] as { id: string }).id
|
||||
expect((split.children[1] as { tabs: unknown[] }).tabs).toHaveLength(0)
|
||||
const explorerTab = ((split.children[0] as { tabs: { id: string }[] }).tabs[0]!).id
|
||||
s = moveTab(s, explorerId, explorerTab, otherId)
|
||||
// The source pane emptied and was removed; the target leaf is promoted.
|
||||
expect(s.splits.kind).toBe('leaf')
|
||||
expect((s.splits as { id: string }).id).toBe(otherId)
|
||||
expect((s.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([explorerTab])
|
||||
})
|
||||
|
||||
it('dragging a tab to a pane edge splits the pane with the tab in a fresh leaf', () => {
|
||||
let s = state()
|
||||
s = splitPane(s, 'row')
|
||||
const split = s.splits as Extract<SplitNode, { kind: 'split' }>
|
||||
const paneA = split.children[0] as { id: string; tabs: { id: string }[] }
|
||||
const paneB = split.children[1] as { id: string; tabs: { id: string }[] }
|
||||
const tabId = paneA.tabs[0]!.id
|
||||
// 先给 paneB 一个 tab,然后拖 paneA 的 tab 到 paneB 的 right 边缘。
|
||||
s = openTabInActivePane(s, { id: 't2', type: 'terminal', title: 'T2' })
|
||||
s = moveTabToEdge(s, paneA.id, tabId, paneB.id, 'right')
|
||||
const after = s.splits as Extract<SplitNode, { kind: 'split' }>
|
||||
// paneB 现在是 split(row) [旧leaf, 新leaf(tabId)];其父 split 仍存在。
|
||||
const bSplit = after.children.find(child => child.kind === 'split') as Extract<SplitNode, { kind: 'split' }> | undefined
|
||||
expect(bSplit).toBeDefined()
|
||||
expect(bSplit!.dir).toBe('row')
|
||||
const newLeaf = bSplit!.children[1] as { tabs: { id: string }[] }
|
||||
expect(newLeaf.tabs.map(t => t.id)).toContain(tabId)
|
||||
})
|
||||
|
||||
it('dragging a tab to a pane center merges it into the pane', () => {
|
||||
let s = state()
|
||||
s = splitPane(s, 'col')
|
||||
const split = s.splits as Extract<SplitNode, { kind: 'split' }>
|
||||
const paneA = split.children[0] as { id: string; tabs: { id: string }[] }
|
||||
const paneB = split.children[1] as { id: string; tabs: { id: string }[] }
|
||||
const tabId = paneA.tabs[0]!.id
|
||||
s = moveTabToEdge(s, paneA.id, tabId, paneB.id, 'center')
|
||||
// paneA 空了被移除,树退化为 paneB(含 tab)。
|
||||
expect(s.splits.kind).toBe('leaf')
|
||||
expect((s.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([tabId])
|
||||
})
|
||||
|
||||
it('dragging a tab back onto its own pane center reorders it', () => {
|
||||
let s = state()
|
||||
s = openTabInActivePane(s, { id: 't2', type: 'terminal', title: 'T2' })
|
||||
const leaf = s.splits as { id: string; tabs: { id: string }[] }
|
||||
const first = leaf.tabs[0]!.id
|
||||
s = moveTabToEdge(s, leaf.id, first, leaf.id, 'center')
|
||||
const after = s.splits as { tabs: { id: string }[] }
|
||||
expect(after.tabs[after.tabs.length - 1]!.id).toBe(first)
|
||||
expect(after.tabs).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('closing the last tab removes the pane (promotes the sibling)', () => {
|
||||
let s = state()
|
||||
s = splitPane(s, 'col')
|
||||
const split = s.splits as Extract<SplitNode, { kind: 'split' }>
|
||||
const paneA = split.children[0] as { id: string; tabs: { id: string }[] }
|
||||
const paneB = split.children[1] as { id: string }
|
||||
const explorerId = paneA.tabs[0]!.id
|
||||
// paneA gets a terminal; the explorer moves to paneB; closing the
|
||||
// terminal empties paneA, which is removed, promoting paneB.
|
||||
s = openTabInActivePane(s, { id: 't', type: 'terminal', title: 'Terminal 1' })
|
||||
s = moveTab(s, paneA.id, explorerId, paneB.id)
|
||||
s = activateTab(s, paneA.id, 't')
|
||||
s = closeTab(s, paneA.id, 't')
|
||||
expect(s.splits.kind).toBe('leaf')
|
||||
expect((s.splits as { id: string }).id).toBe(paneB.id)
|
||||
})
|
||||
|
||||
it('resizes splits within the clamp range', () => {
|
||||
let s = state()
|
||||
s = splitPane(s, 'row')
|
||||
const split = s.splits as Extract<SplitNode, { kind: 'split' }>
|
||||
const id = split.id
|
||||
s = { ...s, splits: resizeSplit(s.splits, id, 0, 0.2) }
|
||||
const after = s.splits as Extract<SplitNode, { kind: 'split' }>
|
||||
expect(after.sizes[0]).toBeCloseTo(0.7)
|
||||
expect(after.sizes[1]).toBeCloseTo(0.3)
|
||||
})
|
||||
|
||||
it('tracks explorer expansion and tab activation', () => {
|
||||
let s = state()
|
||||
s = toggleExpanded(s, '/p/a')
|
||||
s = toggleExpanded(s, '/p/b')
|
||||
expect(s.expanded).toEqual(['/p/a', '/p/b'])
|
||||
s = toggleExpanded(s, '/p/a')
|
||||
expect(s.expanded).toEqual(['/p/b'])
|
||||
const leaf = s.splits as { id: string; tabs: { id: string }[]; active: string | null }
|
||||
const tabId = leaf.tabs[0]!.id
|
||||
const after = activateTab(s, leaf.id, tabId)
|
||||
expect((after.splits as { active: string | null }).active).toBe(tabId)
|
||||
})
|
||||
|
||||
it('patchTab updates the title and path of one open tab (browser persistence)', () => {
|
||||
let s = state()
|
||||
const leaf = s.splits as { id: string; tabs: { id: string; type: string; title: string; path?: string }[] }
|
||||
s = openTabInActivePane(s, { id: 'browser:1', type: 'browser', title: 'Browser' })
|
||||
const browserId = 'browser:1'
|
||||
s = patchTab(s, browserId, { path: 'https://example.com/', title: 'example.com' })
|
||||
const tab = (s.splits as { tabs: { id: string; title: string; path?: string }[] }).tabs.find(t => t.id === browserId)
|
||||
expect(tab).toMatchObject({ title: 'example.com', path: 'https://example.com/' })
|
||||
// A partial patch leaves the other field untouched.
|
||||
s = patchTab(s, browserId, { title: 'example.org' })
|
||||
const again = (s.splits as { tabs: { id: string; title: string; path?: string }[] }).tabs.find(t => t.id === browserId)
|
||||
expect(again).toMatchObject({ title: 'example.org', path: 'https://example.com/' })
|
||||
// Other tabs are untouched.
|
||||
expect(leaf.tabs[0]).toBeDefined()
|
||||
})
|
||||
|
||||
it('patchTab is a no-op for a missing tab id', () => {
|
||||
const s = state()
|
||||
const after = patchTab(s, 'nope', { title: 'X', path: 'https://x/' })
|
||||
expect(after).toBe(s)
|
||||
})
|
||||
|
||||
it('sanitize accepts nextBrowser (defaulting a missing/malformed one to 1)', () => {
|
||||
const base = {
|
||||
panelOpen: true,
|
||||
width: 400,
|
||||
nextTerminal: 1,
|
||||
activePane: 'pane:1',
|
||||
expanded: [],
|
||||
splits: {
|
||||
kind: 'leaf',
|
||||
id: 'pane:1',
|
||||
active: null,
|
||||
tabs: [{ id: 't', type: 'explorer', title: 'Explorer' }],
|
||||
},
|
||||
}
|
||||
// Older persisted states lack the field: they must keep loading.
|
||||
expect(sanitizeState(base)?.nextBrowser).toBe(1)
|
||||
// A present valid value survives; a malformed one falls back to 1.
|
||||
expect(sanitizeState({ ...base, nextBrowser: 7 })?.nextBrowser).toBe(7)
|
||||
expect(sanitizeState({ ...base, nextBrowser: 'x' })?.nextBrowser).toBe(1)
|
||||
expect(sanitizeState({ ...base, nextBrowser: 0 })?.nextBrowser).toBe(1)
|
||||
// The default state seeds 1.
|
||||
expect(makeDefaultState().nextBrowser).toBe(1)
|
||||
})
|
||||
|
||||
it('tabOpenIn: a tab is open until it is truly closed, wherever it lives', () => {
|
||||
let s = state()
|
||||
const leaf = s.splits as { id: string; tabs: { id: string }[] }
|
||||
const explorerId = leaf.tabs[0]!.id
|
||||
expect(tabOpenIn(s, explorerId)).toBe(true)
|
||||
// Moving the tab to another pane keeps it open.
|
||||
s = splitPane(s, 'row')
|
||||
const split = s.splits as Extract<SplitNode, { kind: 'split' }>
|
||||
const paneA = split.children[0] as { id: string; tabs: { id: string }[] }
|
||||
const paneB = split.children[1] as { id: string }
|
||||
s = moveTab(s, paneA.id, explorerId, paneB.id)
|
||||
expect(tabOpenIn(s, explorerId)).toBe(true)
|
||||
// Closing it removes it from the whole tree.
|
||||
const target = s.splits as { id: string; tabs: { id: string }[] }
|
||||
s = closeTab(s, target.id, explorerId)
|
||||
expect(tabOpenIn(s, explorerId)).toBe(false)
|
||||
// A terminal tab added later is open too.
|
||||
s = openTabInActivePane(s, { id: 'terminal:9', type: 'terminal', title: 'Terminal 9' })
|
||||
expect(tabOpenIn(s, 'terminal:9')).toBe(true)
|
||||
})
|
||||
|
||||
// ── Bottom panel (the second, independent workbench) ───────────────────
|
||||
|
||||
it('toggleBottomPanel flips the bottom panel independently of the right panel', () => {
|
||||
let s = state()
|
||||
expect(s.bottomOpen).toBe(false)
|
||||
s = toggleBottomPanel(s)
|
||||
expect(s.bottomOpen).toBe(true)
|
||||
// Collapsing the right panel leaves the bottom panel open (independent toggles).
|
||||
s = togglePanel(s)
|
||||
expect(s.panelOpen).toBe(false)
|
||||
expect(s.bottomOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('setBottomHeight clamps to the contract range', () => {
|
||||
expect(setBottomHeight(state(), 50).bottomHeight).toBe(BOTTOM_MIN)
|
||||
const g = globalThis as Record<string, unknown>
|
||||
const previous = g.window
|
||||
g.window = { innerHeight: 800 }
|
||||
try {
|
||||
// The bottom panel must leave the center column at least PANEL_MIN
|
||||
// tall (800 - 280), regardless of the right panel's open state.
|
||||
expect(setBottomHeight(state(), 9999).bottomHeight).toBe(800 - 280)
|
||||
expect(setBottomHeight({ ...state(), panelOpen: false }, 9999).bottomHeight).toBe(800 - 280)
|
||||
} finally {
|
||||
if (previous === undefined) delete g.window
|
||||
else g.window = previous
|
||||
}
|
||||
})
|
||||
|
||||
// ── Narrow-viewport merge (bottom tabs thrown into the right sidebar) ──
|
||||
|
||||
it('migrateBottomTabs throws the bottom tree tabs into the right tree’s FIRST leaf', () => {
|
||||
let s = state()
|
||||
s = toggleBottomPanel(s)
|
||||
const bottomPane = (s.bottomSplits as { id: string }).id
|
||||
// Two bottom tabs in their own pane; the right pane holds explorer.
|
||||
s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:1', type: 'terminal', title: 'T1' })
|
||||
s = openTabInActivePane(s, { id: 'terminal:2', type: 'terminal', title: 'T2' })
|
||||
const migrated = migrateBottomTabs(s)
|
||||
// All tabs now live in the right tree's first leaf, bottom tabs appended.
|
||||
expect((migrated.splits as { tabs: SidebarTab[] }).tabs.map(t => t.id))
|
||||
.toEqual([expect.stringMatching(/^tab:/), 'terminal:1', 'terminal:2'])
|
||||
// The bottom tree is emptied (structure stays), the panel closes, and
|
||||
// new tabs land in the right tree.
|
||||
expect((migrated.bottomSplits as { tabs: SidebarTab[] }).tabs).toHaveLength(0)
|
||||
expect(migrated.bottomOpen).toBe(false)
|
||||
expect(migrated.activePane).toBe((migrated.splits as { id: string }).id)
|
||||
// The migrated tabs are fully functional: closing one works through the
|
||||
// right tree.
|
||||
expect(tabOpenIn(migrated, 'terminal:1')).toBe(true)
|
||||
expect(tabOpenIn(closeTab(migrated, migrated.activePane!, 'terminal:1'), 'terminal:1')).toBe(false)
|
||||
})
|
||||
|
||||
it('migrateBottomTabs appends into the FIRST leaf when the right tree is a split', () => {
|
||||
let s = state()
|
||||
s = splitPane(s, 'row') // splits the active pane into two leaves
|
||||
s = toggleBottomPanel(s)
|
||||
const bottomPane = (s.bottomSplits as { id: string }).id
|
||||
s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:9', type: 'terminal', title: 'T9' })
|
||||
const migrated = migrateBottomTabs(s)
|
||||
// The first (leftmost) leaf carries the bottom tab; the second leaf
|
||||
// keeps its own tabs untouched.
|
||||
const leaves = allLeaves(migrated.splits)
|
||||
expect(leaves[0]!.tabs.map(t => t.id)).toContain('terminal:9')
|
||||
expect(allLeaves(migrated.bottomSplits).flatMap(l => l.tabs)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('migrateBottomTabs is idempotent (same reference) once the bottom tree is empty and closed', () => {
|
||||
const s = state()
|
||||
expect(migrateBottomTabs(s)).toBe(s)
|
||||
// With the panel open but no tabs, the migration only closes the panel.
|
||||
const open = toggleBottomPanel(s)
|
||||
const migrated = migrateBottomTabs(open)
|
||||
expect(migrated).not.toBe(open)
|
||||
expect(migrated.bottomOpen).toBe(false)
|
||||
expect(migrateBottomTabs(migrated)).toBe(migrated)
|
||||
})
|
||||
|
||||
it('migrateBottomTabs repoints an active pane that lives in the bottom tree', () => {
|
||||
let s = state()
|
||||
const bottomPane = (s.bottomSplits as { id: string }).id
|
||||
s = { ...s, activePane: bottomPane } // empty bottom pane, panel closed
|
||||
const migrated = migrateBottomTabs(s)
|
||||
expect(migrated.activePane).toBe((migrated.splits as { id: string }).id)
|
||||
// A tab opened after the migration lands in the VISIBLE right tree.
|
||||
const landed = openTabInActivePane(migrated, { id: 'git', type: 'git' as const, title: 'Git' })
|
||||
expect((landed.splits as { tabs: SidebarTab[] }).tabs.map(t => t.type)).toContain('git')
|
||||
})
|
||||
|
||||
it('openTabInActivePane lands in the bottom tree when the active pane lives there', () => {
|
||||
let s = state()
|
||||
s = toggleBottomPanel(s)
|
||||
const bottomPane = (s.bottomSplits as { id: string }).id
|
||||
s = { ...s, activePane: bottomPane }
|
||||
const tab = { id: 'git', type: 'git' as const, title: 'Git' }
|
||||
s = openTabInActivePane(s, tab)
|
||||
expect((s.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toContain('git')
|
||||
// The right tree is untouched (its seeded files-window home tab stays).
|
||||
expect((s.splits as { tabs: SidebarTab[] }).tabs.map(t => t.type)).toEqual(['editor'])
|
||||
expect(s.activePane).toBe(bottomPane)
|
||||
// The id safety net works across trees: reopening the same id focuses it.
|
||||
const after = openTabInActivePane(s, tab)
|
||||
expect((after.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toEqual(['git'])
|
||||
})
|
||||
|
||||
it('openTabInActivePane falls back to the right tree when the active pane is stale', () => {
|
||||
let s = state()
|
||||
s = toggleBottomPanel(s)
|
||||
s = { ...s, activePane: 'pane:gone' }
|
||||
const after = openTabInActivePane(s, { id: 'git', type: 'git' as const, title: 'Git' })
|
||||
expect((after.splits as { tabs: SidebarTab[] }).tabs.map(t => t.type)).toContain('git')
|
||||
})
|
||||
|
||||
it('closeTab routes to the bottom tree', () => {
|
||||
let s = state()
|
||||
s = toggleBottomPanel(s)
|
||||
const bottomPane = (s.bottomSplits as { id: string }).id
|
||||
s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:1', type: 'terminal', title: 'T1' })
|
||||
expect(tabOpenIn(s, 'terminal:1')).toBe(true)
|
||||
s = closeTab(s, bottomPane, 'terminal:1')
|
||||
expect(tabOpenIn(s, 'terminal:1')).toBe(false)
|
||||
// The right tree is untouched.
|
||||
expect(tabOpenIn(s, (s.splits as { tabs: { id: string }[] }).tabs[0]!.id)).toBe(true)
|
||||
})
|
||||
|
||||
it('moveTabToEdge splits within the bottom tree', () => {
|
||||
let s = state()
|
||||
s = toggleBottomPanel(s)
|
||||
const bottomPane = (s.bottomSplits as { id: string }).id
|
||||
s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:1', type: 'terminal', title: 'T1' })
|
||||
s = moveTabToEdge(s, bottomPane, 'terminal:1', bottomPane, 'right')
|
||||
expect(s.bottomSplits.kind).toBe('split')
|
||||
expect(s.splits.kind).toBe('leaf')
|
||||
expect(tabOpenIn(s, 'terminal:1')).toBe(true)
|
||||
// The fresh leaf (the drop's active pane) differs from the source pane.
|
||||
expect(s.activePane).not.toBe(bottomPane)
|
||||
})
|
||||
|
||||
it('resizeSplitIn routes a divider to its own tree', () => {
|
||||
let s = state()
|
||||
s = toggleBottomPanel(s)
|
||||
const bottomPane = (s.bottomSplits as { id: string }).id
|
||||
s = splitPane({ ...s, activePane: bottomPane }, 'row')
|
||||
const split = s.bottomSplits as Extract<SplitNode, { kind: 'split' }>
|
||||
s = resizeSplitIn(s, split.id, 0, 0.1)
|
||||
const next = s.bottomSplits as Extract<SplitNode, { kind: 'split' }>
|
||||
expect(next.sizes[0]).toBeCloseTo(0.6)
|
||||
expect(s.splits.kind).toBe('leaf')
|
||||
})
|
||||
|
||||
it('sanitize defaults the bottom fields for older persisted states and repairs a broken bottom tree', () => {
|
||||
const base = {
|
||||
panelOpen: true,
|
||||
width: 400,
|
||||
nextTerminal: 1,
|
||||
activePane: 'pane:1',
|
||||
expanded: [],
|
||||
splits: {
|
||||
kind: 'leaf',
|
||||
id: 'pane:1',
|
||||
active: null,
|
||||
tabs: [{ id: 't', type: 'explorer', title: 'Explorer' }],
|
||||
},
|
||||
}
|
||||
// Older persisted states lack the bottom fields: defaults, state kept.
|
||||
const s = sanitizeState(base)
|
||||
expect(s?.bottomOpen).toBe(false)
|
||||
expect(s?.bottomHeight).toBe(BOTTOM_DEFAULT)
|
||||
expect(s?.bottomSplits.kind).toBe('leaf')
|
||||
expect((s?.bottomSplits as { tabs: SidebarTab[] }).tabs).toHaveLength(0)
|
||||
// A malformed bottom tree is replaced with a fresh empty pane.
|
||||
const broken = sanitizeState({ ...base, bottomSplits: 'junk' })
|
||||
expect(broken?.splits).toBeDefined()
|
||||
expect(broken?.bottomSplits.kind).toBe('leaf')
|
||||
// A valid persisted bottom tree survives.
|
||||
const withBottom = sanitizeState({
|
||||
...base,
|
||||
bottomOpen: true,
|
||||
bottomHeight: 300,
|
||||
bottomSplits: {
|
||||
kind: 'leaf',
|
||||
id: 'pane:9',
|
||||
active: 'b1',
|
||||
tabs: [{ id: 'b1', type: 'terminal', title: 'T' }],
|
||||
},
|
||||
})
|
||||
expect(withBottom?.bottomOpen).toBe(true)
|
||||
expect(withBottom?.bottomHeight).toBe(300)
|
||||
expect((withBottom?.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toEqual(['b1'])
|
||||
// Heights are clamped to the contract range.
|
||||
expect(sanitizeState({ ...base, bottomHeight: 10 })?.bottomHeight).toBe(BOTTOM_MIN)
|
||||
// A stale full-height bottom panel must not squeeze the center column
|
||||
// (the agent output area) to zero: the cap leaves it at least PANEL_MIN
|
||||
// tall, regardless of the right panel's open state.
|
||||
const g = globalThis as Record<string, unknown>
|
||||
const previous = g.window
|
||||
g.window = { innerHeight: 800 }
|
||||
try {
|
||||
expect(sanitizeState({ ...base, panelOpen: true, bottomHeight: 9999 })?.bottomHeight).toBe(800 - 280)
|
||||
expect(sanitizeState({ ...base, panelOpen: false, bottomHeight: 9999 })?.bottomHeight).toBe(800 - 280)
|
||||
} finally {
|
||||
if (previous === undefined) delete g.window
|
||||
else g.window = previous
|
||||
}
|
||||
})
|
||||
|
||||
it('tabOpenIn and patchTab see tabs in the bottom tree', () => {
|
||||
let s = state()
|
||||
s = toggleBottomPanel(s)
|
||||
const bottomPane = (s.bottomSplits as { id: string }).id
|
||||
s = openTabInActivePane(
|
||||
{ ...s, activePane: bottomPane },
|
||||
{ id: 'browser:1', type: 'browser', title: 'example.com', path: 'https://example.com' },
|
||||
)
|
||||
expect(tabOpenIn(s, 'browser:1')).toBe(true)
|
||||
s = patchTab(s, 'browser:1', { title: 'other.com', path: 'https://other.com' })
|
||||
const tab = allLeaves(s.bottomSplits).flatMap(leaf => leaf.tabs).find(t => t.id === 'browser:1')
|
||||
expect(tab?.title).toBe('other.com')
|
||||
})
|
||||
|
||||
it('moves a tab across panels (center merge into the other tree)', () => {
|
||||
let s = state()
|
||||
s = toggleBottomPanel(s)
|
||||
const rightPane = (s.splits as { id: string }).id
|
||||
const bottomPane = (s.bottomSplits as { id: string }).id
|
||||
const explorerId = (s.splits as { tabs: { id: string }[] }).tabs[0]!.id
|
||||
// Drag the explorer tab from the right panel into the bottom panel (center).
|
||||
s = moveTabToEdge(s, rightPane, explorerId, bottomPane, 'center')
|
||||
expect((s.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toContain(explorerId)
|
||||
expect((s.splits as { tabs: SidebarTab[] }).tabs).toHaveLength(0)
|
||||
expect(s.activePane).toBe(bottomPane)
|
||||
// And back, inserted at an index.
|
||||
s = moveTab(s, bottomPane, explorerId, rightPane, 0)
|
||||
expect((s.splits as { tabs: SidebarTab[] }).tabs[0]!.id).toBe(explorerId)
|
||||
expect((s.bottomSplits as { tabs: SidebarTab[] }).tabs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('moves a tab across panels by splitting the target pane (edge drop)', () => {
|
||||
let s = state()
|
||||
s = toggleBottomPanel(s)
|
||||
const rightPane = (s.splits as { id: string }).id
|
||||
const bottomPane = (s.bottomSplits as { id: string }).id
|
||||
const explorerId = (s.splits as { tabs: { id: string }[] }).tabs[0]!.id
|
||||
s = moveTabToEdge(s, rightPane, explorerId, bottomPane, 'right')
|
||||
// The source tree empties back to a leaf; the target tree splits.
|
||||
expect(s.splits.kind).toBe('leaf')
|
||||
expect((s.splits as { tabs: SidebarTab[] }).tabs).toHaveLength(0)
|
||||
expect(s.bottomSplits.kind).toBe('split')
|
||||
expect(tabOpenIn(s, explorerId)).toBe(true)
|
||||
const split = s.bottomSplits as Extract<SplitNode, { kind: 'split' }>
|
||||
expect(split.children.some(
|
||||
child => child.kind === 'leaf' && (child as { tabs: SidebarTab[] }).tabs.some(t => t.id === explorerId),
|
||||
)).toBe(true)
|
||||
// The fresh leaf (the drop's active pane) differs from the source pane.
|
||||
expect(s.activePane).not.toBe(rightPane)
|
||||
})
|
||||
|
||||
it('moveTab with a non-existent source or target pane is safe', () => {
|
||||
const s = state()
|
||||
const pane = (s.splits as { id: string }).id
|
||||
// Missing source: returns unchanged state
|
||||
expect(moveTab(s, 'pane:ghost', 'tab:1', pane)).toBe(s)
|
||||
// Missing target: returns unchanged state
|
||||
expect(moveTab(s, pane, 'tab:1', 'pane:ghost')).toBe(s)
|
||||
})
|
||||
|
||||
it('closeTab with non-existent tab or pane returns equivalent state without throwing', () => {
|
||||
const s = state()
|
||||
const pane = (s.splits as { id: string }).id
|
||||
expect(closeTab(s, 'pane:ghost', 'tab:1')).toEqual(s)
|
||||
expect(closeTab(s, pane, 'tab:ghost')).toEqual(s)
|
||||
})
|
||||
|
||||
it('moveTabToEdge with non-existent pane returns unchanged state', () => {
|
||||
const s = state()
|
||||
const pane = (s.splits as { id: string }).id
|
||||
expect(moveTabToEdge(s, 'pane:ghost', 'tab:1', pane, 'right')).toBe(s)
|
||||
expect(moveTabToEdge(s, pane, 'tab:1', 'pane:ghost', 'right')).toBe(s)
|
||||
})
|
||||
})
|
||||
|
||||
describe('persisted state sanitization', () => {
|
||||
it('accepts a well-formed state unchanged (node environment: no width clamp)', () => {
|
||||
const state = makeDefaultState(400)
|
||||
const clean = sanitizeState(JSON.parse(JSON.stringify(state)))
|
||||
expect(clean).toEqual(state)
|
||||
})
|
||||
|
||||
it('accepts a subagent tab as a known type', () => {
|
||||
const raw = JSON.parse(JSON.stringify(makeDefaultState(400)))
|
||||
raw.splits.tabs.push({ id: 'tab:9', type: 'subagent', title: 'Subagents' })
|
||||
raw.splits.active = 'tab:9'
|
||||
const clean = sanitizeState(raw)
|
||||
expect(clean).toBeDefined()
|
||||
const tabs = (clean!.splits as { tabs: { type: string }[] }).tabs
|
||||
expect(tabs.some(tab => tab.type === 'subagent')).toBe(true)
|
||||
})
|
||||
|
||||
it('clamps undersized widths to the panel minimum', () => {
|
||||
const state = { ...makeDefaultState(400), width: 10 }
|
||||
const clean = sanitizeState(JSON.parse(JSON.stringify(state)))
|
||||
expect(clean?.width).toBe(280)
|
||||
})
|
||||
|
||||
it('rejects malformed shapes instead of crashing the panel', () => {
|
||||
expect(sanitizeState(null)).toBeUndefined()
|
||||
expect(sanitizeState('nope')).toBeUndefined()
|
||||
expect(sanitizeState({})).toBeUndefined()
|
||||
expect(sanitizeState({ ...makeDefaultState(400), width: 'wide' })).toBeUndefined()
|
||||
expect(sanitizeState({ ...makeDefaultState(400), panelOpen: 1 })).toBeUndefined()
|
||||
// A split whose sizes do not match its children is rejected.
|
||||
const withSplit = JSON.parse(JSON.stringify(makeDefaultState(400)))
|
||||
withSplit.splits = { kind: 'split', id: 's1', dir: 'row', sizes: [0.5], children: [] }
|
||||
expect(sanitizeState(withSplit)).toBeUndefined()
|
||||
// Unknown tab types (external plugins not yet loaded) are accepted —
|
||||
// they render as <OrphanedTab/> at view time and recover if the plugin
|
||||
// loads later. Only diff tabs are dropped (ephemeral).
|
||||
const withExternalTab = JSON.parse(JSON.stringify(makeDefaultState(400)))
|
||||
withExternalTab.splits.tabs[0].type = 'my-plugin:db'
|
||||
const externalClean = sanitizeState(withExternalTab)
|
||||
expect(externalClean).toBeDefined()
|
||||
if (externalClean !== undefined && externalClean.splits.kind === 'leaf') {
|
||||
expect(externalClean.splits.tabs[0]!.type).toBe('my-plugin:db')
|
||||
}
|
||||
// An active id that no tab carries is rejected.
|
||||
const withBadActive = JSON.parse(JSON.stringify(makeDefaultState(400)))
|
||||
withBadActive.splits.active = 'ghost-tab'
|
||||
expect(sanitizeState(withBadActive)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('re-ids stale duplicate pane/split ids and follows the activePane rename', () => {
|
||||
// The pre-seeding counter reset could mint a fresh "pane:1" beside the
|
||||
// persisted "pane:1": mapLeaf then hit BOTH leaves and every open landed
|
||||
// in both panes. Sanitize must give the repeat a fresh id.
|
||||
const corrupted = JSON.parse(JSON.stringify(makeDefaultState(400)))
|
||||
corrupted.activePane = 'pane:1'
|
||||
corrupted.splits = {
|
||||
kind: 'split',
|
||||
id: 'split:1',
|
||||
dir: 'col',
|
||||
sizes: [0.5, 0.5],
|
||||
children: [
|
||||
{ kind: 'leaf', id: 'pane:1', tabs: [], active: null },
|
||||
{ kind: 'leaf', id: 'pane:1', tabs: [{ id: 'tab:1', type: 'explorer', title: 'Explorer' }], active: 'tab:1' },
|
||||
],
|
||||
}
|
||||
const clean = sanitizeState(corrupted)
|
||||
expect(clean).toBeDefined()
|
||||
const leaves = allLeaves(clean!.splits)
|
||||
// The first occurrence keeps its id; the repeat gets a fresh unique one
|
||||
// (exact suffix depends on the module-level uid counter, so assert shape).
|
||||
expect(leaves[0]!.id).toBe('pane:1')
|
||||
expect(new Set(leaves.map(leaf => leaf.id)).size).toBe(2)
|
||||
expect(clean!.activePane).toBe(leaves[1]!.id)
|
||||
// And an open must land in exactly one pane of the healed tree.
|
||||
const opened = openTabInActivePane(clean!, { id: 'editor:/a.ts', type: 'editor', title: 'a.ts', path: '/a.ts' })
|
||||
const owners = allLeaves(opened.splits).filter(leaf => leaf.tabs.some(tab => tab.path === '/a.ts'))
|
||||
expect(owners).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('falls back from a stale active pane instead of dropping the open', () => {
|
||||
let s = makeDefaultState()
|
||||
const paneA = allLeaves(s.splits)[0]!.id
|
||||
const seededTab = allLeaves(s.splits)[0]!.tabs.find(tab => tab.type === 'editor')!.id
|
||||
s = closeTab(s, paneA, seededTab)
|
||||
s = openTabInActivePane(s, { id: 'editor:/a.ts', type: 'editor', title: 'a.ts', path: '/a.ts' })
|
||||
const split = insertLeafAt(s.splits, paneA, 'col', { id: 'terminal:1', type: 'terminal', title: 'Terminal 1' }, false)
|
||||
s = { ...s, splits: split.node, activePane: paneA }
|
||||
// Closing the editor empties paneA; the pane is removed but activePane
|
||||
// still points at it. The next open must land in the surviving pane.
|
||||
s = closeTab(s, paneA, 'editor:/a.ts')
|
||||
s = openTabInActivePane(s, { id: 'editor:/b.ts', type: 'editor', title: 'b.ts', path: '/b.ts' })
|
||||
const owners = allLeaves(s.splits).filter(leaf => leaf.tabs.some(tab => tab.path === '/b.ts'))
|
||||
expect(owners).toHaveLength(1)
|
||||
expect(owners[0]!.tabs.some(tab => tab.type === 'terminal')).toBe(true)
|
||||
})
|
||||
|
||||
it('handles state with deeply corrupted split children gracefully', () => {
|
||||
const corrupted = {
|
||||
...makeDefaultState(400),
|
||||
splits: {
|
||||
kind: 'split',
|
||||
id: 's1',
|
||||
dir: 'row',
|
||||
sizes: [0.5, 0.5],
|
||||
children: [
|
||||
{ kind: 'leaf', id: 'pane:1', tabs: null, active: null },
|
||||
{ kind: 'leaf', id: 'pane:2', tabs: [{ id: 'tab:1', type: 'explorer', title: 'Explorer' }], active: 'tab:1' },
|
||||
],
|
||||
},
|
||||
}
|
||||
expect(sanitizeState(corrupted)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('v0.12.0 store additions', () => {
|
||||
// These blocks exercise store reduce/reduceFor (which schedule the
|
||||
// localStorage persist through window timers) and sanitizeState (which
|
||||
// reads window.innerHeight). Stub the browser globals ONLY inside this
|
||||
// scope so the earlier describes keep their window-less environment.
|
||||
beforeEach(() => {
|
||||
const g = globalThis as Record<string, unknown>
|
||||
g.window = { clearTimeout: () => {}, setTimeout: () => 0, innerWidth: 1024, innerHeight: 800 }
|
||||
g.localStorage = { getItem: () => null, setItem: () => {} }
|
||||
})
|
||||
afterEach(() => {
|
||||
const g = globalThis as Record<string, unknown>
|
||||
delete g.window
|
||||
delete g.localStorage
|
||||
})
|
||||
|
||||
describe('store.reduceFor (targeted opens, v0.12.0)', () => {
|
||||
it('mutates the target session, persists it, and leaves the active snapshot untouched', () => {
|
||||
const store = createSidebarStore()
|
||||
store.setSession('s1')
|
||||
let calls = 0
|
||||
store.subscribe(() => { calls++ })
|
||||
store.reduceFor('s2', (state) => ({ ...state, expanded: ['/x'] }))
|
||||
// No notify, no snapshot switch.
|
||||
expect(calls).toBe(0)
|
||||
expect(store.getSnapshot().sessionId).toBe('s1')
|
||||
// The target session's state updated and loads back on switch.
|
||||
store.setSession('s2')
|
||||
expect(store.getSnapshot().state?.expanded).toEqual(['/x'])
|
||||
})
|
||||
|
||||
it('loads a fresh state for a never-visited target session', () => {
|
||||
const store = createSidebarStore()
|
||||
store.setSession('s1')
|
||||
store.reduceFor('brand-new', (state) => ({ ...state, panelOpen: false }))
|
||||
store.setSession('brand-new')
|
||||
expect(store.getSnapshot().state?.panelOpen).toBe(false)
|
||||
expect(store.getSnapshot().state?.splits).toBeDefined()
|
||||
})
|
||||
|
||||
it('reduceFor never lowers the shared uid counter below the active session needs (no pane-id collision)', () => {
|
||||
const store = createSidebarStore()
|
||||
// Session 'b' is cached FIRST with a LOW id range (pane:1 / tab:2).
|
||||
store.setSession('b')
|
||||
// Session 'a' then loads and its operations raise the shared counter
|
||||
// well above b's max (default pane, plus fresh pane ids from splits).
|
||||
store.setSession('a')
|
||||
store.reduce(s => splitPane(s, 'row'))
|
||||
const before = allLeaves(store.getSnapshot().state!.splits).map(leaf => leaf.id).sort()
|
||||
// A targeted reduce into the OLD, low-id session must not lower the
|
||||
// counter: the next split in the ACTIVE session would otherwise mint
|
||||
// an id that already exists (mapLeaf visits both leaves → corruption).
|
||||
store.reduceFor('b', (state) => state)
|
||||
store.reduce(s => splitPane(s, 'row'))
|
||||
const after = allLeaves(store.getSnapshot().state!.splits).map(leaf => leaf.id)
|
||||
expect(new Set(after).size).toBe(after.length)
|
||||
// The active session's pre-existing pane ids all survived untouched.
|
||||
for (const id of before) expect(after).toContain(id)
|
||||
})
|
||||
|
||||
it('persists each session independently (per-session debounce timers)', () => {
|
||||
const g = globalThis as Record<string, unknown>
|
||||
let seq = 0
|
||||
const timers = new Map<number, () => void>()
|
||||
const writes: string[] = []
|
||||
g.window = {
|
||||
clearTimeout: (id: number) => { timers.delete(id) },
|
||||
setTimeout: (fn: () => void) => { const id = ++seq; timers.set(id, fn); return id },
|
||||
innerWidth: 1024,
|
||||
innerHeight: 800,
|
||||
}
|
||||
g.localStorage = {
|
||||
getItem: () => null,
|
||||
setItem: (key: string) => { writes.push(key) },
|
||||
}
|
||||
try {
|
||||
const store = createSidebarStore()
|
||||
store.setSession('a')
|
||||
store.reduce(s => ({ ...s, expanded: ['/a'] })) // schedules persist(a)
|
||||
store.reduceFor('b', s => ({ ...s, expanded: ['/b'] })) // schedules persist(b)
|
||||
// A shared timer would have cancelled persist(a) — with per-session
|
||||
// timers BOTH writes are pending and both land when they fire.
|
||||
expect(timers.size).toBe(2)
|
||||
for (const [, fn] of [...timers]) fn()
|
||||
expect(writes).toEqual(['dsh-sidebar:v1:a', 'dsh-sidebar:v1:b'])
|
||||
} finally {
|
||||
delete g.window
|
||||
delete g.localStorage
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('tab meta persistence (v0.12.0)', () => {
|
||||
it('sanitizeState carries plugin meta through a reload round-trip', () => {
|
||||
const store = createSidebarStore()
|
||||
store.setSession('s1')
|
||||
store.reduce(s => ({
|
||||
...s,
|
||||
splits: {
|
||||
kind: 'leaf' as const,
|
||||
id: 'pane:1',
|
||||
tabs: [{ id: 'tab:1', type: 'db', title: 'DB', meta: { q: [1, 2], n: 0 } }],
|
||||
active: 'tab:1',
|
||||
},
|
||||
}))
|
||||
const sanitized = sanitizeState(JSON.parse(JSON.stringify(store.getSnapshot().state!)))
|
||||
const tabs = allLeaves(sanitized!.splits).flatMap(leaf => leaf.tabs)
|
||||
expect(tabs[0]?.meta).toEqual({ q: [1, 2], n: 0 })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { contentText, lastActivity } from '../src/client/subagent-activity.ts'
|
||||
import type { SidebarHistoryEntry } from '../src/context-types.ts'
|
||||
|
||||
describe('subagent activity summary parser', () => {
|
||||
/** One history entry from raw event fields. */
|
||||
const entry = (type: string, data: Record<string, unknown>): SidebarHistoryEntry => ({
|
||||
event: { type, seq: 0, time: 0, data },
|
||||
})
|
||||
|
||||
it('extracts text blocks and skips non-text content', () => {
|
||||
// Text blocks join as paragraphs (newline-separated).
|
||||
expect(contentText([{ type: 'text', text: 'hello' }, { type: 'text', text: ' world' }])).toBe('hello\n world')
|
||||
expect(contentText([{ type: 'tool_use', name: 'bash' }])).toBeUndefined()
|
||||
expect(contentText(undefined)).toBeUndefined()
|
||||
expect(contentText('nope')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('lastActivity returns the LAST text output and the LAST tool call', () => {
|
||||
const live = lastActivity([
|
||||
entry('turn/start', { turn: 1 }),
|
||||
entry('user/message', { content: [{ type: 'text', text: '请检查代码' }] }),
|
||||
entry('tool/call', { callId: 'c1', name: 'bash', arguments: '{"command":"ls -la"}' }),
|
||||
entry('assistant/message', {
|
||||
turn: 1, step: 1,
|
||||
message: { content: [{ type: 'text', text: '检查完毕' }] },
|
||||
}),
|
||||
entry('tool/call', { callId: 'c2', name: 'read', arguments: '{"path":"a.ts"}' }),
|
||||
entry('assistant/message', {
|
||||
turn: 1, step: 2,
|
||||
message: { content: [{ type: 'text', text: '再看一眼' }] },
|
||||
}),
|
||||
])
|
||||
expect(live).toEqual({
|
||||
text: '再看一眼',
|
||||
tool: { name: 'read', args: '{"path":"a.ts"}' },
|
||||
})
|
||||
})
|
||||
|
||||
it('lastActivity keeps only the fields the tail actually has', () => {
|
||||
expect(lastActivity([
|
||||
entry('tool/call', { callId: 'c1', name: 'bash', arguments: '{"command":"ls"}' }),
|
||||
])).toEqual({ tool: { name: 'bash', args: '{"command":"ls"}' } })
|
||||
expect(lastActivity([
|
||||
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'ok' }] } }),
|
||||
])).toEqual({ text: 'ok' })
|
||||
})
|
||||
|
||||
it('lastActivity ignores lifecycle events, chunks, and text-less messages', () => {
|
||||
const live = lastActivity([
|
||||
entry('turn/end', { turn: 1, reason: 'success' }),
|
||||
entry('step/start', { turn: 1, step: 1 }),
|
||||
entry('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text', delta: 'x' } }),
|
||||
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'tool_use', name: 'bash' }] } }),
|
||||
])
|
||||
expect(live).toEqual({})
|
||||
expect(lastActivity([])).toEqual({})
|
||||
})
|
||||
|
||||
it('lastActivity defaults a missing tool name and tolerates non-string arguments', () => {
|
||||
const live = lastActivity([
|
||||
entry('tool/call', { callId: 'c1' }),
|
||||
entry('tool/call', { callId: 'c2', name: 'web', arguments: { url: 'x' } }),
|
||||
])
|
||||
expect(live.tool).toEqual({ name: 'web', args: '' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
collectBranchIds, countSubagentDescendants, detectNewDirectSubagent,
|
||||
directSubagentCount, rootAncestor,
|
||||
} from '../src/client/subagent-detect.ts'
|
||||
import type { SidebarSessionList, SidebarSubagentCatalog } from '../src/context-types.ts'
|
||||
|
||||
describe('subagent detection over the sessions list feed', () => {
|
||||
/** A list snapshot carrying the given direct subagent children of `parent`. */
|
||||
const list = (
|
||||
parent: string,
|
||||
childIds: string[],
|
||||
running: string[] = [],
|
||||
): SidebarSessionList => {
|
||||
const byId: SidebarSessionList['byId'] = { [parent]: { id: parent, displayTitle: 'Parent' } }
|
||||
for (const id of childIds) {
|
||||
byId[id] = {
|
||||
id,
|
||||
displayTitle: `Child ${id}`,
|
||||
origin: 'subagent',
|
||||
parentId: parent,
|
||||
running: running.includes(id),
|
||||
}
|
||||
}
|
||||
return { current: parent, byId }
|
||||
}
|
||||
|
||||
it('counts only the direct subagent children of the given session', () => {
|
||||
const snapshot = list('p1', ['c1', 'c2'])
|
||||
snapshot.byId['other'] = { id: 'other', displayTitle: 'Other', origin: 'subagent', parentId: 'p2' }
|
||||
expect(directSubagentCount(snapshot.byId, 'p1')).toBe(2)
|
||||
expect(directSubagentCount(snapshot.byId, 'p2')).toBe(1)
|
||||
expect(directSubagentCount(snapshot.byId, 'p1-nobody')).toBe(0)
|
||||
})
|
||||
|
||||
it('fires only on the 0 → N transition of the current session', () => {
|
||||
const empty = list('p1', [])
|
||||
const one = list('p1', ['c1'])
|
||||
const two = list('p1', ['c1', 'c2'])
|
||||
expect(detectNewDirectSubagent(empty, empty, 'p1')).toBe(false)
|
||||
expect(detectNewDirectSubagent(empty, one, 'p1')).toBe(true)
|
||||
// Already-present children never re-trigger (session switch, reload).
|
||||
expect(detectNewDirectSubagent(one, two, 'p1')).toBe(false)
|
||||
expect(detectNewDirectSubagent(two, one, 'p1')).toBe(false)
|
||||
// A child arriving under ANOTHER session does not trigger this one.
|
||||
expect(detectNewDirectSubagent(empty, list('p2', ['x']), 'p1')).toBe(false)
|
||||
})
|
||||
|
||||
it('indexes descendants through uninterrupted subagent lineage', () => {
|
||||
// p1 → c1 (subagent) → g1 (subagent child of c1).
|
||||
const byId: SidebarSessionList['byId'] = {
|
||||
p1: { id: 'p1', displayTitle: 'P1' },
|
||||
c1: { id: 'c1', displayTitle: 'C1', origin: 'subagent', parentId: 'p1', running: true },
|
||||
g1: { id: 'g1', displayTitle: 'G1', origin: 'subagent', parentId: 'c1' },
|
||||
}
|
||||
expect(countSubagentDescendants(byId, 'p1')).toEqual({ count: 2, runningCount: 1 })
|
||||
expect(countSubagentDescendants(byId, 'c1')).toEqual({ count: 1, runningCount: 0 })
|
||||
expect(countSubagentDescendants(byId, 'g1')).toEqual({ count: 0, runningCount: 0 })
|
||||
// An ordinary fork between the parent and the subagent cuts that lineage:
|
||||
// c2 is c1's sibling in origin but its parent `fork` is not a subagent,
|
||||
// so it never reaches p1 (only c1 and g1 count under p1).
|
||||
const forked: SidebarSessionList['byId'] = {
|
||||
...byId,
|
||||
fork: { id: 'fork', displayTitle: 'Fork', parentId: 'p1' },
|
||||
c2: { id: 'c2', displayTitle: 'C2', origin: 'subagent', parentId: 'fork' },
|
||||
}
|
||||
expect(countSubagentDescendants(forked, 'p1')).toEqual({ count: 2, runningCount: 1 })
|
||||
expect(countSubagentDescendants(forked, 'fork')).toEqual({ count: 1, runningCount: 0 })
|
||||
// Cycles terminate (fail soft, never hang); both rows in a 2-cycle reach
|
||||
// the queried node once each.
|
||||
const cyclic: SidebarSessionList['byId'] = {
|
||||
a: { id: 'a', displayTitle: 'A', origin: 'subagent', parentId: 'b' },
|
||||
b: { id: 'b', displayTitle: 'B', origin: 'subagent', parentId: 'a' },
|
||||
}
|
||||
expect(countSubagentDescendants(cyclic, 'a')).toEqual({ count: 2, runningCount: 0 })
|
||||
})
|
||||
|
||||
it('resolves the main-agent root of the current session tree', () => {
|
||||
const byId: SidebarSessionList['byId'] = {
|
||||
main: { id: 'main', displayTitle: 'Main' },
|
||||
child: { id: 'child', displayTitle: 'Child', origin: 'subagent', parentId: 'main' },
|
||||
grand: { id: 'grand', displayTitle: 'Grand', origin: 'subagent', parentId: 'child' },
|
||||
}
|
||||
// An ordinary session is its own root; a deep subagent walks up to the main agent.
|
||||
expect(rootAncestor(byId, 'main')).toBe('main')
|
||||
expect(rootAncestor(byId, 'child')).toBe('main')
|
||||
expect(rootAncestor(byId, 'grand')).toBe('main')
|
||||
// A broken chain (parent not in the mirror) degrades to the session itself.
|
||||
expect(rootAncestor(byId, 'orphan')).toBe('orphan')
|
||||
// A hydrating session row degrades to the session itself.
|
||||
expect(rootAncestor(byId, 'not-listed')).toBe('not-listed')
|
||||
expect(rootAncestor(byId, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('collects every catalog branch of the topology, cycle-safe', () => {
|
||||
const child = (id: string, hasChildren: boolean): SidebarSubagentCatalog['entries'][number] => ({
|
||||
kind: 'child', id, activity: 'inactive', hasChildren, mode: 'one-shot',
|
||||
})
|
||||
const catalogs: Record<string, SidebarSubagentCatalog> = {
|
||||
root: { entries: [child('a', true), child('b', false)], parentAvailable: true, state: 'ready', error: null },
|
||||
a: { entries: [child('c', false)], parentAvailable: true, state: 'ready', error: null },
|
||||
}
|
||||
expect(collectBranchIds(catalogs, 'root')).toEqual(['a'])
|
||||
expect(collectBranchIds(catalogs, undefined)).toEqual([])
|
||||
// A cycle terminates (each branch id collected at most once, no hang).
|
||||
const cyclic: Record<string, SidebarSubagentCatalog> = {
|
||||
root: { entries: [child('a', true)], parentAvailable: true, state: 'ready', error: null },
|
||||
a: { entries: [child('root', true)], parentAvailable: true, state: 'ready', error: null },
|
||||
}
|
||||
expect(collectBranchIds(cyclic, 'root')).toEqual(['a', 'root'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* TerminalDepsBanner tests (issue #140): the degraded-mode banner the
|
||||
* terminal tab shows when node-pty fails to load. It must render the
|
||||
* failure title, the PASTEABLE repair command (bash / cmd / PowerShell),
|
||||
* a copy button that writes the command to the clipboard with a transient
|
||||
* "Copied" feedback, and a retry button.
|
||||
*
|
||||
* The banner is tested directly (like PluginListBody): it is a pure
|
||||
* presentational component, so server-side renderToString covers the
|
||||
* static content and createRoot + act() covers the copy click.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { TerminalDepsBanner } from '../src/client/TerminalView.tsx'
|
||||
|
||||
// The act() environment flag (React 18.2 reads it before flushing effects).
|
||||
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
const deps = {
|
||||
ok: false as const,
|
||||
cause: 'Cannot find package node-pty',
|
||||
command: 'bash "/Users/u/.dsh/profiles/web/node_modules/.pnpm/dsh-better-sidebar@0.0.0/node_modules/dsh-better-sidebar/scripts/install.sh" --repair --profile "web"',
|
||||
profile: 'web',
|
||||
}
|
||||
|
||||
describe('TerminalDepsBanner (render)', () => {
|
||||
it('renders the failure title, the pasteable command and the actions', () => {
|
||||
const html = renderToString(createElement(TerminalDepsBanner, { deps, onRetry: () => {} }))
|
||||
// The jsdom locale is en-US, so the en dictionary applies.
|
||||
expect(html).toContain('Terminal dependency node-pty failed to load')
|
||||
// renderToString HTML-escapes the quotes inside the command block.
|
||||
expect(html).toContain(deps.command.replaceAll('"', '"'))
|
||||
expect(html).toContain('Copy')
|
||||
expect(html).toContain('Retry')
|
||||
// The profile the command targets is shown next to the hint.
|
||||
expect(html).toContain('detected profile: web')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TerminalDepsBanner copy click (interactive)', () => {
|
||||
it('clicking Copy writes the repair command and flashes "Copied"', async () => {
|
||||
vi.spyOn(primitives, 'writeClipboard').mockResolvedValue(true)
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root = createRoot(container)
|
||||
const onRetry = vi.fn()
|
||||
act(() => {
|
||||
root.render(createElement(TerminalDepsBanner, { deps, onRetry }))
|
||||
})
|
||||
const copyButton = container.querySelector('button[aria-label="Copy"]') as HTMLButtonElement
|
||||
expect(copyButton).not.toBeNull()
|
||||
expect(copyButton.textContent).toBe('Copy')
|
||||
await act(async () => { copyButton.click() })
|
||||
expect(primitives.writeClipboard).toHaveBeenCalledWith(deps.command)
|
||||
expect(copyButton.textContent).toBe('Copied')
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('clicking Retry fires the callback (the view reconnects)', async () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root = createRoot(container)
|
||||
const onRetry = vi.fn()
|
||||
act(() => {
|
||||
root.render(createElement(TerminalDepsBanner, { deps, onRetry }))
|
||||
})
|
||||
const buttons = [...container.querySelectorAll('button')] as HTMLButtonElement[]
|
||||
const retry = buttons.find(button => button.textContent === 'Retry')
|
||||
expect(retry).toBeDefined()
|
||||
await act(async () => { retry!.click() })
|
||||
expect(onRetry).toHaveBeenCalledTimes(1)
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Live theme-token access tests — issue #90's guard.
|
||||
*
|
||||
* `effectiveTokenValue` must treat visually inert values (CSS reset
|
||||
* keywords, `transparent`, and any color below the opacity floor) as UNSET
|
||||
* so callers' `|| fallback` chains fire. Skin systems set global tokens like
|
||||
* `--dsw-alias-bg-base` to `transparent` or translucent glass values (the
|
||||
* dsh-web-ui skins use rgba 0.16–0.7); without this guard a
|
||||
* truthy-but-inert value would leave the terminal/editor see-through over
|
||||
* the skin's backdrop. Effectively opaque values (>= 0.9 alpha, including a
|
||||
* skin's scoped 0.96 porcelain) pass through so the skin still controls the
|
||||
* surface.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { colorAlpha, effectiveTokenValue, tokenValue } from '../src/client/theme.ts'
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeAttribute('style')
|
||||
})
|
||||
|
||||
describe('colorAlpha', () => {
|
||||
it('parses the hex family', () => {
|
||||
expect(colorAlpha('#fff')).toBe(1)
|
||||
expect(colorAlpha('#fff8')).toBeCloseTo(0x88 / 255)
|
||||
expect(colorAlpha('#112233')).toBe(1)
|
||||
expect(colorAlpha('#11223344')).toBeCloseTo(0x44 / 255)
|
||||
})
|
||||
|
||||
it('parses rgb()/rgba() in comma and space syntax', () => {
|
||||
expect(colorAlpha('rgb(1, 2, 3)')).toBe(1)
|
||||
expect(colorAlpha('rgba(255, 255, 255, 0.45)')).toBeCloseTo(0.45)
|
||||
expect(colorAlpha('rgb(255 255 255 / 0.7)')).toBeCloseTo(0.7)
|
||||
})
|
||||
|
||||
it('parses hsl()/hsla()', () => {
|
||||
expect(colorAlpha('hsl(200, 50%, 50%)')).toBe(1)
|
||||
expect(colorAlpha('hsla(200, 50%, 50%, 0.3)')).toBeCloseTo(0.3)
|
||||
})
|
||||
|
||||
it('treats unparseable formats as opaque', () => {
|
||||
expect(colorAlpha('white')).toBeNull()
|
||||
expect(colorAlpha('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('effectiveTokenValue', () => {
|
||||
it('passes through real opaque paint values verbatim', () => {
|
||||
document.body.style.setProperty('--probe', '#112233')
|
||||
expect(effectiveTokenValue('--probe')).toBe('#112233')
|
||||
// Effectively opaque translucency (a skin's scoped 0.96 porcelain glass)
|
||||
// is a deliberate surface choice — it passes through.
|
||||
document.body.style.setProperty('--probe', 'rgba(10, 22, 54, 0.96)')
|
||||
expect(effectiveTokenValue('--probe')).toBe('rgba(10, 22, 54, 0.96)')
|
||||
})
|
||||
|
||||
it('treats transparent and CSS reset keywords as unset', () => {
|
||||
for (const inert of ['transparent', 'initial', 'inherit', 'unset']) {
|
||||
document.body.style.setProperty('--probe', inert)
|
||||
expect(effectiveTokenValue('--probe'), inert).toBe('')
|
||||
}
|
||||
})
|
||||
|
||||
it('treats translucent glass values below the opacity floor as unset', () => {
|
||||
// The dsh-web-ui skins set bg-base to rgba 0.16–0.7 for glass panels;
|
||||
// the terminal must fall back to an opaque background there.
|
||||
for (const glass of ['rgba(255, 255, 255, 0.45)', 'rgba(20, 26, 46, 0.7)', '#11223344']) {
|
||||
document.body.style.setProperty('--probe', glass)
|
||||
expect(effectiveTokenValue('--probe'), glass).toBe('')
|
||||
}
|
||||
})
|
||||
|
||||
it('treats a missing token as unset', () => {
|
||||
expect(effectiveTokenValue('--never-defined')).toBe('')
|
||||
})
|
||||
|
||||
it('tokenValue still returns the raw value', () => {
|
||||
document.body.style.setProperty('--probe', 'transparent')
|
||||
expect(tokenValue('--probe')).toBe('transparent')
|
||||
expect(effectiveTokenValue('--probe')).toBe('')
|
||||
})
|
||||
})
|
||||
-1813
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -248,7 +248,7 @@ function makeCssPlugin(pluginId: string): BuildPlugin {
|
||||
resolveId(source: string, importer: string | undefined) {
|
||||
if (!source.endsWith('.css')) return null
|
||||
// Relative/absolute paths resolve against the importer; bare
|
||||
// specifiers (e.g. 'xterm/css/xterm.css') resolve from the package.
|
||||
// specifiers (e.g. '@xterm/xterm/css/xterm.css') resolve from the package.
|
||||
let abs: string
|
||||
if (source.startsWith('.') || source.startsWith('/') || /^[A-Za-z]:[\\/]/.test(source)) {
|
||||
abs = importer === undefined ? source : resolvePath(dirname(importer), source)
|
||||
|
||||
Reference in New Issue
Block a user