diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48c6295..1fe97a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,14 @@ on: push: branches: [main] pull_request: + workflow_dispatch: + +# Cancel a superseded PR run (it is on a stale commit); never cancel a +# push run — it is already producing the post-merge signal. The mount job +# boots a real DSH + Chromium, so the savings matter there most. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: ci: @@ -41,3 +49,57 @@ jobs: # shipped lib/types. Runs AFTER build (it reads the built output). - name: Check consumer type surface run: pnpm check:consumer-types + + # Pack the plugin as an npm tarball, mount it into a REAL DSH instance + # through the official `dsh plugin --profile web add` channel, and render + # the DSH web UI headlessly (Playwright Chromium) to prove the plugin + # mounts without crashing the shell. Drives `pnpm test:mount`, which boots + # a keyless `dsh web` against a scratch profile and sweeps every built-in + # sidebar tab. No API key needed — the whole lane is deterministic. + plugin-mount: + runs-on: ubuntu-latest + timeout-minutes: 30 + # CI runs must never report to the production telemetry endpoint baked + # into the DSH composition (same guard DSH's own CI uses). + env: + DSH_TELEMETRY_DISABLED: '1' + steps: + - uses: actions/checkout@v7 + + - uses: pnpm/action-setup@v6 + with: + version: 11 + + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Pin the DSH CLI the lane mounts into; the profile's bundle stack + # (dsh-base / dsh-web-app) resolves from this installation. Keep in + # step with the plugin's @deepseek-ai/* peer range when bumping. + - name: Install dsh CLI (pinned) + run: npm install -g @deepseek-ai/dsh@0.1.0-rc.6 + + # lib/ is gitignored; the tarball is produced from this build. + - name: Build and pack the npm tarball + run: pnpm build && pnpm pack + + - name: Install Playwright Chromium + run: pnpm exec playwright install --with-deps chromium + + - name: Mount + headless-render smoke + run: pnpm test:mount + + - name: Upload Playwright report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + playwright-report/ + test-results/ + if-no-files-found: ignore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d5c2f36 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore index b38fa09..4cbe7d3 100644 --- a/.gitignore +++ b/.gitignore @@ -149,3 +149,8 @@ registry/ # stray npm cache scratch dir .npm-cache-tmp/ + +# Playwright headless-render lane (tests/e2e) artifacts +playwright-report/ +test-results/ + diff --git a/AGENTS.md b/AGENTS.md index fe8474f..54c6b46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,9 +9,29 @@ better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context ## 0. 仓库硬约束(所有本仓库插件必须遵守) - **禁止修改 DeepSeek Harness (DSH) 源码**:对官方源码 checkout(`~/.dsh/source/current`)零写入——不得改 harness 包、不得把 harness 改动提交到它的分支。 +- **代码改动必须走 PR**:功能 / 修复 / 测试等非文档改动一律在分支上开发(`feat/*` / `fix/*`),用 `gh pr create` 发起 PR,review 合并后才进 main;**仅纯文档类改动**(README / AGENTS.md / docs/ 等)允许直接推送到 main。 - **挂载只走 `cordis.patch.yml` + profile 机制**(`~/.dsh/profiles//`),插件永远作为独立包被 profile 引用,不反向侵入 DSH。 - 需要 harness 没有的能力时,用 DSH **现成的只读/公开 API** 或插件自有路由实现(参考 §7 的 `jobs.output` 事件回放:读会话事件日志而非动注册表);如果确实做不到,先向用户说明取舍,而不是直接改 DSH。 +### CI 挂载冒烟(`plugin-mount` job / `pnpm test:mount`) + +仓库的 CI 有一条「npm 打包 → 真实挂载 → 无头渲染」门禁(`.github/workflows/ci.yml` 的 `plugin-mount` job),证明**打包产物**在真实 DSH 上挂载后无头渲染不会 crash: + +1. `pnpm build && pnpm pack` 产出 tarball(与发布产物一致)。 +2. `scripts/e2e-mount.sh` 用官方 CLI 把它装进一个**全新 scratch profile**(`dsh plugin --profile web add file:`,触发 `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 错误,显式展开面板(`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. 服务定位 @@ -111,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 @@ -125,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 会求值两次,抛错会向外传播 /** @@ -134,24 +154,38 @@ interface TabDescriptor { * 省略时用默认 { id, type, title } + seed 里的 path/diff。 */ createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial } | null + /** + * 外链点击目标认领(v0.13.0+):聊天/界面外链被接管(`browserInterceptLinks` + * 总闸 + URL 对应协议开关均开)时,第一个 `urlTarget(url)` 命中且未被设置 + * 禁用的注册 tab 类型以 `openTab({ type, url, title: hostname })` 打开—— + * URL 即全部载荷(tab 从 `tab.path` 读取)。注册顺序先到先得;谓词抛错被 + * 吞掉(跳过该类型)。内置 browser 不声明 urlTarget,永远是隐式兜底,不会 + * 遮蔽插件声明。要同时容纳多个 URL 需用 createTab 铸造 per-URL id(browser + * 同款模式);否则同类型二次点击被 id 安全网聚焦,新 URL 不会覆写既有 path。 + */ + urlTarget?: (url: URL) => boolean /** * 声明式设置(v0.4.1+):每个注册的 tab 都会在 Side card 设置页获得一行 * 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套设置行, * 绑定 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') */ + /** 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 行的提交钳制上限。 */ @@ -160,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[]`——不需要宿主 PrefsSchema 字段。 */ @@ -167,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、 @@ -221,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 @@ -278,12 +328,29 @@ ctx.effect(() => ) ``` +**认领外链点击**(v0.13.0+,`features.includes('urlTarget')` gate):聊天/界面里被拦截的 HTTP(S) 外链(协议开关开启时)路由到第一个 `urlTarget` 命中的 tab 类型,URL 作为 seed 预填 `tab.path`;内置浏览器是隐式兜底。多 URL 并存需用 `createTab` 铸造 per-URL id(browser 同款模式),否则同类型二次点击聚焦既有 tab、不覆写 path: +```ts +ctx.effect(() => { + if (!ctx.betterSidebar.features.includes('urlTarget')) return + return ctx.betterSidebar.registerTab({ + id: 'my-plugin:web-docs', + title: () => 'Docs', + order: 80, + urlTarget: (url) => url.hostname === 'docs.my-site.com', // 只有该域名被点击时认领 + createTab: (state) => ({ + tab: { id: `my-plugin:web-docs:${state.nextBrowser}`, type: 'my-plugin:web-docs', title: 'Docs' }, + patch: { nextBrowser: state.nextBrowser + 1 }, + }), + component: ({ tab, scope }) => , + }) +}) +``` + ### 3.4 内置 tab(不可重复注册) | 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 自增) | @@ -338,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 } ``` @@ -462,7 +534,7 @@ interface BetterSidebarService { readonly version: string /** 单调能力清单(只增不删):'badge' | 'tabLifecycle' | 'updateTab' | * 'openFile' | 'targetedOpen' | 'stateSubscription' | 'tabMeta' | - * 'pluginSettings'——消费插件用 `features.includes('xxx')` 按能力 gate。 */ + * 'pluginSettings' | 'urlTarget' | 'settingSelect'——消费插件用 `features.includes('xxx')` 按能力 gate。 */ readonly features: readonly string[] /** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。 * session 未激活时 state/sessionId 为 undefined。 */ @@ -475,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 } @@ -493,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`)。**v0.12.0 起设置 seam 已开放**:外部插件用 `settings.pluginToggles`(同款行控件,key 插件局部)或 `settings.render`(自定义面板)声明自己的设置,值持久化在 prefs 文档的 `pluginSettings[]`(开放 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[]`(开放 map,宿主 schema 已有字段,无需注册)——齿轮弹窗对 tab 与 viewer 都可用(viewer 卡片 v0.12.0 起也有齿轮)。 --- @@ -521,7 +596,26 @@ interface OpenTabSeed { --- -## 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]` 内即可,避免全局改写影响宿主。 +- **布局变量**(写在 `` 上,面板打开时有效):`--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` 文件预览器。 @@ -597,16 +691,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 的真实用法。 diff --git a/README.md b/README.md index f76a986..63a82bd 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,9 @@
一个服务化的侧边栏框架,一套开箱即用的完整工作台

- 文件管理 编辑预览 内嵌浏览器 真实终端 Git 面板 后台任务 插件接入

+ License: MIT + dshfind

+ 文件管理 编辑预览 内嵌浏览器 真实终端 Git 面板 后台任务 插件接入

右侧栏 + 底部面板双工作台,并把 ctx.betterSidebar 服务开放给所有插件——
通过 registerTab / registerFileViewer 注册新的侧边栏页面与文件预览器。
@@ -19,8 +21,8 @@ ## ✨ 功能一览 -- **🗂️ 文件工作台**:资源管理器(懒加载目录树)+ CodeMirror 编辑器;图片 / Markdown / HTML / PDF / Office 内联预览 -- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe,外链默认在侧边栏打开 +- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown / HTML / PDF / Office 内联预览 +- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整) - **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具 - **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原 - **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止) @@ -34,110 +36,75 @@ ## 🆕 最近更新 -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 预览盘符路径 | | +### 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 半更新时需要重启)。 -
-指定版本 / 装完自动重启(可选) - -```sh -# macOS / Linux -curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash -s 0.12.1 --restart - -# Windows PowerShell -& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.12.1 -Restart -``` - -不确定的话,可先加 `--dry-run`(PowerShell 用 `-DryRun`)预览步骤再执行。 - -
- -
-手动安装(逐步命令,想看清每一步) - -与一键脚本等价。**第 ③ 步可重复执行;①② 只需做一次。** - -**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.1) -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 -``` - -
- -
-脚本内部做了什么(技术细节) - -一键脚本自动完成 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.1` 发布,通过 `dsh.bundle.patch`(随包的 `cordis.patch.yml`)由官方 CLI 自动挂载,**不修改 DSH 源码**。 - -
-
更新 ```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)。
@@ -146,12 +113,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`。 | @@ -172,7 +140,7 @@ dsh plugin --profile web add dsh-better-sidebar 5. 硬刷新浏览器(Cmd/Ctrl+Shift+R)即可看到效果(client 改动无需重启 DSH;host 半改动才需重启) ``` -更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.12.1"` 再 `pnpm install`。 +更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.12.3"` 再 `pnpm install`。 @@ -266,3 +234,4 @@ Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元 - [dsh-tianshu-tui](https://github.com/huiliyi37/dsh-tianshu-tui):DeepSeek Harness 交互式终端 UI 插件(渲染核心由自研 harness agent Tianshu-Tui 演进而来),在官方基础上增加 TDD 与证据门等工作流 - [dsh-TUI](https://github.com/ccch1mneyyy/dsh-TUI):Claude Code 风格全屏交互终端插件——像素鲸鱼顶栏、实时工作状态行、思考流式展开、双击 Esc 回滚、上下文进度条 + TPS 仪表,npm 一键安装 - [dshfind 插件超市](https://dshfind.com/zh/plugins):三方插件市场——GitHub topic `dsh-plugin` 下的公开仓库清单,每日同步 star、贡献者与增长数据 +- [DeepSeek Harness Desktop](https://github.com/anywhere-labs/deepseek-harness-desktop):为 DeepSeek Harness 生态打造的现代化桌面端——无需配置 Node.js 或执行命令即可启动和管理本地 Harness 服务;[官网](https://www.dshdesktop.cn) diff --git a/README_EN.md b/README_EN.md index 6512dde..9e354b1 100644 --- a/README_EN.md +++ b/README_EN.md @@ -3,7 +3,9 @@
A service-oriented sidebar framework, and a complete workbench out of the box

- File management Edit & preview Embedded browser Real terminal Git panel Background tasks Plugin integration

+ License: MIT + dshfind

+ File management Edit & preview Embedded browser Real terminal Git panel Background tasks Plugin integration

A dual workbench (right sidebar + bottom panel) that opens its ctx.betterSidebar service to every plugin —
register new sidebar pages and file viewers via registerTab / registerFileViewer.
@@ -19,8 +21,8 @@ ## ✨ Features -- **🗂️ File Workbench**: file explorer (lazy-loading tree) + CodeMirror editor; inline preview for images / Markdown / HTML / PDF / Office -- **🌐 Embedded Browser**: multiple web tabs with back / forward / refresh; content runs in a sandboxed iframe; external links open in the sidebar by default +- **🗂️ 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 - **🧩 Background Tasks**: agent topology + background tasks (exit codes / live output / force-kill) @@ -34,110 +36,75 @@ ## 🆕 Recent Updates -v0.12.1 +
+ Service API base screenshot + Add Plugins screenshot +
-| Feature | Description | Screenshot | -|---|---|---| -| 🔌 Service API base | Complete type exports + `version`/`features` capability detection, state subscription (`getSnapshot`/`subscribeState`), tab `badge`, `onOpen`/`onActivate`/`onClose` lifecycle callbacks, `updateTab`/`activateTab`/`openFile`, targeted open, `meta` persisted across reloads, plugin-owned settings (`pluginToggles`/`render`) | Service API base screenshot | -| ➕ Add Plugins | Recommended plugin catalog in settings + one-click copy install command; built-in Office preview moved to the recommended plugin | Add Plugins screenshot | -| 🖱️ 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.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). -
-Pin a version / auto-restart (optional) - -```sh -# macOS / Linux -curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash -s 0.12.1 --restart - -# Windows PowerShell -& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.12.1 -Restart -``` - -Not sure? Add `--dry-run` (`-DryRun` in PowerShell) to preview before running. - -
- -
-Manual install (step by step) - -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.1) -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 -``` - -
- -
-What the script does (technical details) - -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.1` and mounts via `dsh.bundle.patch` (the shipped `cordis.patch.yml`), so the DSH source is never modified. - -
-
Updating ```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.1"`) 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.12.3"`) and run `pnpm install`. Then hard-refresh the browser (Cmd/Ctrl+Shift+R) — client changes do not need a DSH restart.
@@ -146,12 +113,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`. | @@ -172,7 +140,7 @@ To debug local changes or track the dev branch, point the dependency at a local 5. Restart DSH and hard-refresh ``` -Update: `git pull && pnpm install && pnpm build` → just hard-refresh the browser (client changes hot-reload; only host-half changes need a DSH restart). To switch back to the npm channel, restore `"dsh-better-sidebar": "^0.12.1"` and re-run `pnpm install`. +Update: `git pull && pnpm install && pnpm build` → just hard-refresh the browser (client changes hot-reload; only host-half changes need a DSH restart). To switch back to the npm channel, restore `"dsh-better-sidebar": "^0.12.3"` and re-run `pnpm install`. @@ -266,3 +234,4 @@ Windows / Linux / macOS (macOS validated daily; the rest covered by unit tests); - [dsh-tianshu-tui](https://github.com/huiliyi37/dsh-tianshu-tui): an interactive terminal UI plugin for DeepSeek Harness (its rendering core evolved from the self-developed harness agent Tianshu-Tui), adding TDD and evidence-gate workflows on top of the official harness - [dsh-TUI](https://github.com/ccch1mneyyy/dsh-TUI): a Claude Code-style fullscreen interactive TUI plugin — pixel-whale top bar, live working-status row, streaming thought expansion, double-Esc rollback, context progress bar + TPS meter; one-command npm install - [dshfind Plugin Market](https://dshfind.com/zh/plugins): a third-party plugin marketplace — a listing of public repos under the GitHub topic `dsh-plugin`, with stars, contributors and growth data synced daily +- [DeepSeek Harness Desktop](https://github.com/anywhere-labs/deepseek-harness-desktop): a modern desktop client for the DeepSeek Harness ecosystem — start and manage a local Harness service without configuring Node.js or running commands; [official site](https://www.dshdesktop.cn) diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md index 6f28f87..cb3cea7 100644 --- a/docs/external-plugin-guide.md +++ b/docs/external-plugin-guide.md @@ -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 只读 + 写操作 | diff --git a/docs/plans/2026-08-10-office-preview-design.md b/docs/plans/2026-08-10-office-preview-design.md index 4fa33ed..5bef38b 100644 --- a/docs/plans/2026-08-10-office-preview-design.md +++ b/docs/plans/2026-08-10-office-preview-design.md @@ -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 调整 diff --git a/docs/plans/2026-08-11-html-browser-builtins-design.md b/docs/plans/2026-08-11-html-browser-builtins-design.md index 3626c33..17e4888 100644 --- a/docs/plans/2026-08-11-html-browser-builtins-design.md +++ b/docs/plans/2026-08-11-html-browser-builtins-design.md @@ -110,6 +110,7 @@ URL 采用**路径编码**(无 query)——WHATSG URL 解析对纯路径相 - 设计初期考虑过 srcdoc + `` 注入以实现"预览反映未保存草稿",因 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 内) diff --git a/docs/plans/2026-08-14-issue-42-verify-design.md b/docs/plans/2026-08-14-issue-42-verify-design.md index c6b073d..6badb71 100644 --- a/docs/plans/2026-08-14-issue-42-verify-design.md +++ b/docs/plans/2026-08-14-issue-42-verify-design.md @@ -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 例); diff --git a/docs/plans/2026-08-15-skin-compat-design.md b/docs/plans/2026-08-15-skin-compat-design.md new file mode 100644 index 0000000..237664c --- /dev/null +++ b/docs/plans/2026-08-15-skin-compat-design.md @@ -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-`),皮肤中心通过 `cordis.patch.yml` 切换激活;皮肤插件 client half 在 `document.body` 上打 `data-dsh-` 属性并注入 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 不依赖具体颜色)。 diff --git a/docs/plans/2026-08-15-url-target-browser-settings-design.md b/docs/plans/2026-08-15-url-target-browser-settings-design.md new file mode 100644 index 0000000..681c3ff --- /dev/null +++ b/docs/plans/2026-08-15-url-target-browser-settings-design.md @@ -0,0 +1,89 @@ +# 外链点击路由到插件 tab(urlTarget)+ 浏览器 HTTP/HTTPS 二级设置 + +**日期**:2026-08-15 +**状态**:已实施(本文档含实施偏差记录) +**目标版本**:v0.13.0(API 兼容层,版本号未随本变更 bump,见 §7) + +## 1. 目标 + +基于 [PR #74](https://github.com/omdsh-dev/DSH-better-sidebar/pull/74)(聊天区外链开关颗粒度讨论)向前兼容地拓展服务化 API: + +1. **插件声明「点击打开的类型」**:`TabDescriptor` 新增可选 `urlTarget?: (url: URL) => boolean`——聊天区外链被拦截时,路由到第一个声明的、命中的、未被设置禁用的插件 tab 类型(`openTab({ type, url, title: hostname })`,URL 即全部载荷),内置浏览器 tab 退居隐式兜底。 +2. **浏览器链接设置改为二级菜单**(浏览器卡片齿轮弹窗内): + - 侧边打开 HTTP 网页(默认开) + - 侧边打开 HTTPS 网页(默认关) + - `browserInterceptLinks` 保留为**总闸**(默认开,零迁移:旧文档显式 false 语义完整保留)。 + +## 2. 非目标 + +- 不实施 PR #74 的「外链开关提升到常规区」(用户确认:二级设置留在浏览器卡片齿轮弹窗,常规区不加行)。 +- 不做 per-host / per-domain 路由规则(只有 per-protocol 开关 + 插件谓词)。 +- 不改 + 菜单、不改手动打开的浏览器 tab、不改同源/修饰键点击放行规则。 +- 不改 host 半逻辑(仅 PrefsSchema 加字段);不修改 DSH 源码。 +- 不做版本号 bump(发布时决定;向前兼容机制 = `SIDEBAR_FEATURES` 追加 `'urlTarget'`)。 + +## 3. 现状回顾 + +- `link-intercept.ts` 的 `registerLinkInterception` 无参 `takeoverEnabled()` 只做布尔门;`index.tsx` 硬编码 `openTab({ type: 'browser', url, title: hostname })`。 +- `browserInterceptLinks`(默认 true)与 `browserNoSandbox` 并列于浏览器卡片的 `settings.toggles`;拦截 gating = 该 pref && `tabsEnabled['browser']`。 +- `openTab` seed 已有 `url` 字段(新建 tab 的 path 预填),插件 tab 无需新渲染管道。 + +## 4. 设计 + +### 4.1 Prefs(`prefs-shared.ts` / `config.ts` / `client/prefs.ts`) + +```ts +interface SidebarPrefs { + // … + browserInterceptLinks: boolean // 总闸,默认 true(保留,零迁移) + browserInterceptHttp: boolean // HTTP 外链侧边栏接管,默认 true + browserInterceptHttps: boolean // HTTPS 外链侧边栏接管,默认 false +} +``` + +三处同步维护(类型 + schemastery 默认 + parsePrefs 校验),旧文档无需迁移:总闸保留使显式关闭过的用户升级后仍不拦截。 + +### 4.2 服务 API(`service.ts`) + +```ts +interface TabDescriptor { + // … + urlTarget?: (url: URL) => boolean +} + +/** 注册顺序先到先得;谓词抛错吞掉(console.error)继续;返回 undefined 由调用方回退 browser。 */ +export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDescriptor | undefined +``` + +- 匹配函数为**模块级纯函数**(不进 `BetterSidebarService` 接口,KISS:不扩公开面);启用态过滤由调用方(`index.tsx`)按 `tabsEnabled` 过滤后传入。 +- 内置 browser 不声明 `urlTarget`,永远不可能遮蔽插件声明;插件多 URL 并存需用 `createTab` 铸造 per-URL id(browser 同款模式),否则 id 安全网聚焦既有 tab 不覆写 path。 +- `SIDEBAR_FEATURES` 追加 `'urlTarget'`(消费者按能力 gate)。 + +### 4.3 拦截管线(`link-intercept.ts` / `index.tsx`) + +- `shouldInterceptLink` 签名不变(协议/同源纯判定);`takeoverEnabled` 改为 `(url: URL) => boolean`,onClick 顺序:纯判定 → gate(持 URL)→ preventDefault → open。 +- `index.tsx` gate:总闸 `browserInterceptLinks` → 协议开关(https 读 `browserInterceptHttps`,否则读 `browserInterceptHttp`)→ 目标可开(插件命中即目标,否则 browser 的 `tabsEnabled`)。open 时再次解析目标并 `openTab({ type, url, title: hostname })`。 + +### 4.4 设置 UI(`builtins/tabs.tsx` / `locales.ts`) + +浏览器卡片 `settings.toggles` 收敛为四行:`browserNoSandbox` / `browserInterceptLinks`(总闸)/ `browserInterceptHttp` / `browserInterceptHttps`;新增 zh/en 文案(HTTP 默认开、HTTPS 默认关及理由)。 + +## 5. 行为变化 + +- 默认设置下:HTTP 外链 → 侧边栏(不变);**HTTPS 外链 → 系统浏览器新窗口(变化,符合「默认关闭」)**。 +- 声明 `urlTarget` 的插件 tab 认领被拦截的链接(优先于浏览器兜底);插件被禁用/谓词抛错时跳过。 +- 总闸关闭 → 一律不拦截(旧文档语义保留)。 + +## 6. 测试 + +- `tests/builtins.spec.ts`:浏览器 toggles 断言收敛为四行 + 各行 title/desc 存在。 +- `tests/service.spec.ts`:`matchUrlTarget`(无声明 → undefined / 注册顺序 / 抛错吞掉 / browser 永不命中)。 +- `tests/link-intercept.spec.ts`(新增 jsdom):`takeoverEnabled(url)` 收到解析后的 URL、gate 决定是否接管、同源/非 http(s) 不 consult gate、修饰键绕过。 +- `tests/unit.spec.ts`:parsePrefs 新字段默认值(http true / https false / 非法回退);总闸与协议开关相互独立。 +- `tests/plugin-shape.spec.ts` / `tests/smoke.spec.ts`:全默认字面量补两个字段。 + +## 7. 实施偏差记录 + +- 计划曾考虑把 `matchUrlTarget` 作为 `BetterSidebarService` 接口方法(镜像 `matchFileViewer`);按 KISS 收敛为**模块级导出纯函数**,启用态过滤由调用方完成(匹配函数不感知 prefs),公开 API 面只剩 `urlTarget` 字段 + `'urlTarget'` feature 字符串。 +- 版本号未 bump(保持 0.12.1):`SIDEBAR_SERVICE_VERSION` 与 `package.json` 的配对断言继续成立;`'urlTarget'` feature 已入 `SIDEBAR_FEATURES`,发布时再决定版本。 +- PR #74 的 diff(常规区移动 + 其测试改动)被本方案取代,未合入。 diff --git a/dsh.plugin.json b/dsh.plugin.json index 8380720..16298fa 100644 --- a/dsh.plugin.json +++ b/dsh.plugin.json @@ -1,6 +1,6 @@ { "id": "dsh-external/dsh-better-sidebar", - "version": "0.12.1", + "version": "0.12.3", "main": "./lib/index.js", "description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器", "engines": { diff --git a/package.json b/package.json index 040f7da..365826d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-better-sidebar", - "version": "0.12.1", + "version": "0.12.3", "description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.", "type": "module", "repository": { @@ -77,6 +77,7 @@ "watch": "tsdown --watch", "prepare": "tsdown", "test": "vitest run", + "test:mount": "bash scripts/e2e-mount.sh", "check:consumer-types": "bash scripts/check-consumer-types.sh" }, "license": "MIT", @@ -128,15 +129,14 @@ "@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", "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6", "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6", @@ -152,11 +152,13 @@ "@deepseek-ai/dsh-session": "^0.1.0-rc.6", "@deepseek-ai/dsh-settings": "^0.1.0-rc.6", "@deepseek-ai/dsh-tools": "^0.1.0-rc.6", + "@playwright/test": "^1.62.1", "@types/node": "^24.0.0", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.1", "@types/ws": "^8.5.10", - "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@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", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..7073e4f --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,39 @@ +/** + * Playwright config for the headless-render mount lane (tests/e2e). + * + * This lane does NOT spawn the server itself: `scripts/e2e-mount.sh` boots a + * real `dsh web` instance (with the npm-packed plugin mounted through the + * official `dsh plugin add` channel) and injects the base URL through + * `DSH_E2E_URL`. The spec's job is to render that URL in a headless Chromium + * and prove the plugin mounts without crashing the shell. + * + * Specs are named `*.e2e.ts` (not `*.spec.ts`) so vitest's default include + * never collects them; vitest.config.ts excludes the directory as well. + */ +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: './tests/e2e', + // The lane's specs are named *.e2e.ts (so vitest's default include never + // collects them); tell Playwright to pick exactly that shape. + testMatch: '**/*.e2e.ts', + // A deterministic gate: no retries, one worker, one browser. The whole lane + // is a crash probe against one server instance, so serial execution keeps + // the assertions meaningful (a crash would trip the very next check). + retries: 0, + workers: 1, + fullyParallel: false, + timeout: 120_000, + reporter: [ + ['list'], + // Local debugging artifact; CI uploads the report dir on failure. + ['html', { open: 'never' }], + ], + use: { + browserName: 'chromium', + headless: true, + viewport: { width: 1440, height: 900 }, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3b55b8..8aa50f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 @@ -144,6 +138,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: ^0.1.0-rc.6 version: 0.1.0-rc.6(f8724372086ccc1457fc84e7becee2e0) + '@playwright/test': + specifier: ^1.62.1 + version: 1.62.1 '@types/node': specifier: ^24.0.0 version: 24.13.3 @@ -156,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) @@ -974,6 +977,11 @@ packages: '@oxc-project/types@0.144.0': resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==} + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} @@ -1178,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==} @@ -1473,6 +1479,11 @@ packages: fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1789,6 +1800,16 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + postcss@8.5.26: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} @@ -2143,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==} @@ -3184,6 +3201,10 @@ snapshots: '@oxc-project/types@0.144.0': {} + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + '@quansync/fs@1.0.0': dependencies: quansync: 1.0.0 @@ -3363,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 @@ -3542,6 +3561,9 @@ snapshots: fflate@0.8.3: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -4051,6 +4073,14 @@ snapshots: picomatch@4.0.5: {} + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + postcss@8.5.26: dependencies: nanoid: 3.3.18 @@ -4347,8 +4377,6 @@ snapshots: xmlchars@2.2.0: {} - xterm@5.3.0: {} - yuku-ast@0.8.5: dependencies: '@yuku-toolchain/types': 0.8.5 diff --git a/scripts/e2e-mount.sh b/scripts/e2e-mount.sh new file mode 100755 index 0000000..ba12c7d --- /dev/null +++ b/scripts/e2e-mount.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# ============================================================================= +# dsh-better-sidebar 挂载冒烟编排(CI + 本地): +# +# 1. 用官方 CLI 把 npm 打包产物(tarball)真实挂载进一个全新 scratch +# profile(`dsh plugin --profile web add file:`,触发 +# dsh.profile.bundles 协调,与用户安装路径一致); +# 2. 启动真实 `dsh web`(keyless,--port 0 取 OS 分配端口); +# 3. 运行 tests/e2e 无头渲染 lane(Playwright Chromium):断言外壳与 +# 插件挂载、无崩溃标记,并驱动内置 tab 深扫。 +# +# 用法: +# bash scripts/e2e-mount.sh [--grep ] +# +# 环境变量(均可省略): +# DSH_CMD dsh 命令;缺省 PATH 上的 `dsh`,回退 npx 拉官方包 +# TARBALL 插件 tarball;缺省仓库根 dsh-better-sidebar-*.tgz(须已 pack) +# PORT 固定端口(默认 0 = OS 分配,从日志解析 URL) +# DSH_HOME_BASE 覆盖 scratch 根目录(默认 mktemp -d) +# KEEP_HOME 非空时保留 scratch home(调试用) +# +# 退出码 = playwright 的退出码;服务器与 scratch 目录由 trap 兜底清理。 +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +DSH_CMD="${DSH_CMD:-dsh}" +PORT="${PORT:-0}" +TARBALL="${TARBALL:-}" +GREP_FILTER="" +if [ "${1:-}" = "--grep" ]; then GREP_FILTER="${2:?--grep 需要参数}"; fi + +say() { printf '\033[32m[e2e-mount]\033[0m %s\n' "$*"; } +warn() { printf '\033[33m[e2e-mount]\033[0m %s\n' "$*" >&2; } +die() { printf '\033[31m[e2e-mount]\033[0m %s\n' "$*" >&2; exit 1; } + +command -v node >/dev/null 2>&1 || die "未找到 node(DSH 运行需要 Node.js >= 20)" +command -v pnpm >/dev/null 2>&1 || die "未找到 pnpm(dsh plugin 转发给 pnpm)" + +# dsh CLI 解析:PATH 上的 dsh 优先,否则 npx 拉官方包(同 scripts/install.sh) +if ! command -v "$DSH_CMD" >/dev/null 2>&1; then + if command -v npx >/dev/null 2>&1; then + say "PATH 上无 $DSH_CMD,回退 npx -y --package @deepseek-ai/dsh" + DSH_CMD="npx -y --package @deepseek-ai/dsh dsh" + else + die "未找到 $DSH_CMD 或 npx;请先安装 DSH CLI(npm i -g @deepseek-ai/dsh)或用 DSH_CMD 指定" + fi +fi + +# tarball 解析 +if [ -z "$TARBALL" ]; then + TARBALL="$(ls "$ROOT"/dsh-better-sidebar-*.tgz 2>/dev/null | head -1 || true)" +fi +[ -n "$TARBALL" ] && [ -f "$TARBALL" ] || die "找不到 tarball(TARBALL 或 \$ROOT/dsh-better-sidebar-*.tgz)——先运行 pnpm build && pnpm pack" +TARBALL="$(cd "$(dirname "$TARBALL")" && pwd)/$(basename "$TARBALL")" +say "tarball: $TARBALL" + +# scratch home(每次全新,绝不触碰真实 ~/.dsh) +SCRATCH="${DSH_HOME_BASE:-$(mktemp -d /tmp/dsh-e2e-mount.XXXXXX)}" +export DSH_HOME="$SCRATCH/home" +WORKSPACE_DIR="$SCRATCH/workspace" +LOG_DIR="$SCRATCH" +WEB_LOG="$LOG_DIR/web.log" +mkdir -p "$DSH_HOME/profiles/web" "$WORKSPACE_DIR" +say "scratch home: ${DSH_HOME}(DSH_HOME=${DSH_HOME})" + +SERVER_PID="" +cleanup() { + local code=$? + if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + if [ -z "${KEEP_HOME:-}" ]; then + rm -rf "$SCRATCH" + else + warn "KEEP_HOME 已设置,保留 $SCRATCH" + fi + exit "$code" +} +trap cleanup EXIT + +# 步骤 1:引导 scratch profile(web 模板,镜像 dsh initProfile;先写 +# pnpm-workspace.yaml 的 allowBuilds / minimumReleaseAgeExclude,避免 pnpm 11 +# strict-dep-builds 拦截 node-pty/protobufjs 或拒绝 <24h 新版本——同 install.sh) +PROFILE_DIR="$DSH_HOME/profiles/web" +cat > "$PROFILE_DIR/package.json" < "$PROFILE_DIR/cordis.patch.yml" +cat > "$PROFILE_DIR/pnpm-workspace.yaml" <<'EOF' +packages: + - . + +nodeLinker: hoisted +autoInstallPeers: false + +allowBuilds: + node-pty: true + protobufjs: true + +minimumReleaseAgeExclude: + - dsh-better-sidebar +EOF + +# 步骤 2:官方 CLI 安装 tarball + bundle 协调(真实挂载路径) +say "执行 dsh plugin --profile web add file:$TARBALL ..." +$DSH_CMD plugin --profile web add "file:$TARBALL" + +# 步骤 3:校验挂载生效(dsh.profile.bundles 含 dsh-better-sidebar) +if ! node -e ' + const fs = require("fs"); + const p = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + const bundles = p.dsh?.profile?.bundles ?? []; + process.exit(bundles.includes("dsh-better-sidebar") ? 0 : 1); +' "$PROFILE_DIR/package.json"; then + warn "dsh-better-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册" + cat "$PROFILE_DIR/package.json" + exit 1 +fi +say "挂载已注册:dsh.profile.bundles 包含 dsh-better-sidebar" + +# 步骤 4:启动 dsh web(--port 0 = OS 分配,避免端口冲突;keyless 可起) +say "启动 dsh web(port=${PORT})..." +$DSH_CMD web --port "$PORT" > "$WEB_LOG" 2>&1 & +SERVER_PID=$! + +URL="" +for _ in $(seq 1 120); do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "=== dsh web 提前退出,日志尾部 ===" >&2 + tail -30 "$WEB_LOG" >&2 || true + exit 1 + fi + if URL="$(grep -oE 'dsh web: http://127\.0\.0\.1:[0-9]+' "$WEB_LOG" | head -1 | awk '{print $3}')" && [ -n "$URL" ]; then + break + fi + sleep 1 +done +[ -n "$URL" ] || { echo "=== 120s 内未等到 dsh web 就绪,日志尾部 ===" >&2; tail -40 "$WEB_LOG" >&2 || true; exit 1; } +say "dsh web 就绪:${URL}(pid ${SERVER_PID})" + +# 步骤 5:运行无头渲染 lane +say "运行 Playwright 无头渲染 lane..." +DSH_E2E_URL="$URL" DSH_E2E_WORKSPACE="$WORKSPACE_DIR" \ + pnpm exec playwright test ${GREP_FILTER:+--grep "$GREP_FILTER"} + +say "通过:插件挂载到真实 DSH 后无头渲染未崩溃" diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 70e14e0..e07c333 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -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 { $_ } diff --git a/scripts/install.sh b/scripts/install.sh index 4a76d4c..f43672d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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。" diff --git a/src/agent-pty.ts b/src/agent-pty.ts index 5f94231..32555b2 100644 --- a/src/agent-pty.ts +++ b/src/agent-pty.ts @@ -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 { ensureSpawnHelper } from './pty-manager.ts' +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() 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, [], { + const pty = this.nodePty.spawn(this.shell, shellSpawnArgs(), { name: 'xterm-256color', cols: dims.cols, rows: dims.rows, diff --git a/src/client/DiffTab.tsx b/src/client/DiffTab.tsx index 72827f3..715edb8 100644 --- a/src/client/DiffTab.tsx +++ b/src/client/DiffTab.tsx @@ -90,7 +90,7 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif title={t('refresh')} onClick={refresh} > - + {loading &&
{t('loading')}
} diff --git a/src/client/EditorHost.tsx b/src/client/EditorHost.tsx index 0296ac6..984a1d7 100644 --- a/src/client/EditorHost.tsx +++ b/src/client/EditorHost.tsx @@ -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 { + return tab.meta !== null && typeof tab.meta === 'object' && !Array.isArray(tab.meta) + ? tab.meta as Record + : {} +} + +/** 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): 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({ 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(null) + const controlsRef = useRef(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(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 ( +
+ +
+ ) + } return (
- {title} + + {toolbar?.modes === true && ( +
+ + +
+ )} + {toolbar?.dirty === true && } + {toolbar?.editable === true && ( + + )} + {saveLabel !== '' && ( + {saveLabel} + )} + +
+
+
+ {showEmpty &&
{t('editorEmptyHint')}
} + {!showEmpty && load.status === 'loading' &&
{t('loading')}
} + {!showEmpty && load.status === 'error' &&
{load.message}
} + {!showEmpty && load.status === 'binary' && } + {!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, + })} +
+ {treeOpen && ( +
+
+ +
+ )}
- {load.status === 'loading' &&
{t('loading')}
} - {load.status === 'error' &&
{load.message}
} - {load.status === 'binary' && } - {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 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 ( + { setValue(event.target.value) }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + commit() + } else if (event.key === 'Escape') { + setValue(display) + } + }} + onBlur={() => { setValue(display) }} + /> + ) +} diff --git a/src/client/ExplorerView.tsx b/src/client/FileTree.tsx similarity index 65% rename from src/client/ExplorerView.tsx rename to src/client/FileTree.tsx index d0eb593..7e85a94 100644 --- a/src/client/ExplorerView.tsx +++ b/src/client/FileTree.tsx @@ -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 `@` 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 `@` 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>({}) 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(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 ? : } {entry.name} + {entry.isSymlink && } {rowActions(entry)}
{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: { > {entry.name} + {entry.isSymlink && } {rowActions(entry)} ) @@ -195,64 +217,53 @@ export function ExplorerView(props: { } return ( -
-
- {root === undefined ? t('noSession') : baseName(root)} - -
-
- {root === undefined ? ( -
{t('noSession')}
- ) : ( - <> -
{ openRowMenu(event, root, true) }} - > - - {baseName(root)} - {copiedPath === root - ? {t('copied')} - : ( - - )} -
- {data[root] !== undefined && renderLevel(root, 1)} - - )} -
+
+ {root === undefined ? ( +
{t('noSession')}
+ ) : ( + <> +
{ openRowMenu(event, root, true) }} + > + + {baseName(root)} + {copiedPath === root + ? {t('copied')} + : ( + + )} +
+ {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). */} { 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: }] + : []), + ...(rowMenu?.isDir === false && onOpenFileSide !== undefined + ? [{ id: 'open-side', label: t('openFileSide'), icon: }] + : []), // Download applies to files only (the host route refuses directories). ...(rowMenu?.isDir === false ? [{ id: 'download', label: t('download'), icon: }] @@ -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 diff --git a/src/client/GitView.tsx b/src/client/GitView.tsx index 272476f..4075079 100644 --- a/src/client/GitView.tsx +++ b/src/client/GitView.tsx @@ -297,7 +297,7 @@ export function GitView(props: { title={t('refresh')} onClick={() => { void refresh() }} > - +
diff --git a/src/client/SideCardSection.module.css b/src/client/SideCardSection.module.css index 18f9823..ae47045 100644 --- a/src/client/SideCardSection.module.css +++ b/src/client/SideCardSection.module.css @@ -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). */ @@ -126,7 +125,8 @@ } .cardMain:focus-visible, -.cardGear:focus-visible { +.cardGear:focus-visible, +.rowGear:focus-visible { outline: 2px solid var(--dsw-alias-border-l4); outline-offset: 2px; } @@ -266,6 +266,29 @@ background: var(--dsw-alias-interactive-bg-hover); } +/* The gear icon button on a general settings ROW (the position-compat + row's "customize the strip height" entry): the same 16px icon-button + recipe as the card gear, sitting in the row's control area. */ +.rowGear { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: none; + border-radius: 50%; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; + flex: none; +} + +.rowGear:hover { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-hover); +} + /* Settings-row recipe (the DSH General section rows): title/desc left, control right, hairline separators (stripped on the last row). */ .row { @@ -380,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; @@ -393,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%; } @@ -405,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 diff --git a/src/client/SideCardSection.tsx b/src/client/SideCardSection.tsx index c0e4950..1da35d3 100644 --- a/src/client/SideCardSection.tsx +++ b/src/client/SideCardSection.tsx @@ -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' @@ -52,6 +54,8 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { clampWidthPercent, + TITLE_BAR_STRIP_MAX, + TITLE_BAR_STRIP_MIN, WIDTH_PERCENT_MAX, WIDTH_PERCENT_MIN, type SidebarPrefs, @@ -207,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) @@ -214,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)[key]) return (
{toggles.map(toggle => { const title = textOf(toggle.title) + if (toggle.type === 'select') { + return ( + + ) + } if ((toggle.type ?? 'switch') === 'switch') { return (
@@ -300,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 = ( + + ) + + return ( +
+ + {title} + {textOf(toggle.desc) !== '' && {textOf(toggle.desc)}} + + + ({ + id: String(index), + label: hasIcons + ? ( + + {iconOf(option.icon, 24)} + + {textOf(option.title)} + {textOf(option.desc) !== '' && {textOf(option.desc)}} + + + ) + : 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 + /> + +
+ ) +} + /** * The secondary settings popup body of one feature (tab or viewer): * - `settings.render` (custom panel) when declared — rendered with the @@ -316,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 ( @@ -354,6 +468,7 @@ export function SettingsBody(props: { prefs={prefs} onToggle={onToggle} onCommit={onCommit} + onSelectValue={onSelectValue} /> )} {pluginToggles.length > 0 && ( @@ -362,6 +477,7 @@ export function SettingsBody(props: { prefs={prefs} onToggle={onPluginToggle} onCommit={onPluginCommit} + onSelectValue={onPluginSelectValue} valueSource={(key) => pluginBlob[key]} /> )} @@ -380,6 +496,8 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { const [error, setError] = useState(null) // Which feature's secondary settings popup is open (null = closed). const [settingsFor, setSettingsFor] = useState(null) + // Whether the position-compat strip popup (the gear on the 常规 row) is open. + const [stripSettingsOpen, setStripSettingsOpen] = useState(false) // Whether the "add plugin" modal (a dashed card at the end of the // 侧边栏内容 / 文件预览 grids) is open, and for which extension point // (null = closed). @@ -489,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 @@ -660,6 +784,36 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { onChange={(next) => { applyPref({ interceptOpenPath: next }) }} />
+
+ + {t('settingsTitleBarTitle')} + {t('settingsTitleBarDesc')} + + + {/* + The position-compat row's gear (same popup pattern as the + feature cards): opens a Modal with the strip-height number row. + Hidden while the mode is off — its related setting is dormant + then (the feature-card convention). + */} + {prefs.titleBarCompat && ( + + )} + { applyPref({ titleBarCompat: next }) }} + /> + +
{/* 侧边栏内容: one small card per registered tab type in a responsive @@ -773,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} @@ -783,6 +939,41 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { )} + {/* The position-compat strip popup (opened by the gear on the 常规 + row): one number row for the reserved strip height in px. Same + Modal chrome and row machinery as the feature popups — mounted + only while open (the Modal SSR rule above). */} + {stripSettingsOpen && ( + { setStripSettingsOpen(false) }} + title={t('settingsTitleBarTitle')} + description={t('settingsPopupDesc', { feature: t('settingsTitleBarTitle') })} + closeLabel={t('close')} + className={css.popupDialog} + footer={( + + )} + > + t('settingsTitleBarStripTitle'), + desc: () => t('settingsTitleBarStripDesc'), + min: TITLE_BAR_STRIP_MIN, + max: TITLE_BAR_STRIP_MAX, + unit: 'px', + }]} + prefs={prefs} + onToggle={onToggleSetting} + onCommit={onCommitSetting} + /> + + )} + {/* The "add plugin" modal (opened by the dashed cards above): declares the extension point of the clicked kind, opens the GitHub topic, and lists the matching recommended plugin catalog with per-entry diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 2cafd83..8f5b055 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -167,6 +167,33 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { return () => { document.body.removeAttribute('data-dsh-sidebar-collapsed') } }, [collapsed]) + // Position compatibility mode (titleBarCompat pref): Windows frameless + // windows draw the native title bar (minimize/maximize/close) at the + // window's top-right corner, OVER the web content. When the user enables + // the pref, the body attribute lets sidebar.module.css drop the toggle + // cluster below the strip and push the right panel's content below it. + // The strip height is user-tunable (titleBarStripPx) and rides a CSS + // variable so the rules stay declarative. The attribute rides the + // snapshot's prefs, so flipping the setting re-renders and re-applies + // immediately; the cleanup removes both on unmount/boundary swap so a + // crashed sidebar never leaves them behind. + const titleBarCompat = snapshot.prefs.titleBarCompat + const titleBarStrip = snapshot.prefs.titleBarStripPx + useEffect(() => { + const root = document.documentElement + if (titleBarCompat) { + document.body.setAttribute('data-dsh-title-bar-compat', '') + root.style.setProperty('--dsh-title-bar-strip', `${titleBarStrip}px`) + } else { + document.body.removeAttribute('data-dsh-title-bar-compat') + root.style.removeProperty('--dsh-title-bar-strip') + } + return () => { + document.body.removeAttribute('data-dsh-title-bar-compat') + root.style.removeProperty('--dsh-title-bar-strip') + } + }, [titleBarCompat, titleBarStrip]) + /** * Bottom-panel merge on narrow viewports: whenever a session is current * while narrow (mount, session switch, or a desktop→narrow transition), @@ -435,7 +462,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // pointer up (clamping + persistence). const panelRef = useRef(null) const bottomRef = useRef(null) - const cornerRef = useRef(null) const widthDrag = useRef({ startX: 0, startWidth: 0 }) const [draggingWidth, setDraggingWidth] = useState(false) const bottomDrag = useRef({ startY: 0, startHeight: 0 }) @@ -480,10 +506,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // until the store commit on release snaps it back. document.documentElement.style.setProperty('--dsh-sidebar-width', `${state?.panelOpen === true ? width : 0}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 @@ -768,11 +794,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 && (
{ event.preventDefault() event.currentTarget.setPointerCapture(event.pointerId) @@ -807,6 +835,49 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { getTabBadge={tabBadgeOf} />
+ {/* + 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 && ( +
{ + 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) + }} + /> + )}
{/* The bottom panel: a second, independent workbench. It squeezes ONLY @@ -835,10 +906,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} >
{ event.preventDefault() event.currentTarget.setPointerCapture(event.pointerId) @@ -889,50 +962,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
)} - {/* - 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 && ( -
{ - 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) - }} - /> - )} ) } diff --git a/src/client/TerminalView.tsx b/src/client/TerminalView.tsx index d4ec208..2fcbdfa 100644 --- a/src/client/TerminalView.tsx +++ b/src/client/TerminalView.tsx @@ -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 + /** * 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 = { /** 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(null) const [connected, setConnected] = useState(false) const [fatal, setFatal] = useState(null) + const [depsFatal, setDepsFatal] = useState(null) const [lastUrl, setLastUrl] = useState(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 (
+ {depsFatal !== null && ( + { setDepsFatal(null); connectRef.current?.() }} /> + )} {fatal !== null && (
{t('terminalError')}: {fatal} @@ -270,8 +311,48 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
)} - {fatal === null && !connected &&
{t('disconnected')}
} + {fatal === null && depsFatal === null && !connected &&
{t('disconnected')}
}
) } + +/** + * 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 => { + const written = await writeClipboard(deps.command) + if (written) { + setCopied(true) + window.setTimeout(() => setCopied(false), 2000) + } + } + return ( +
+
{t('terminalDepsFailed')}
+
+ {t('terminalDepsHint')} + {deps.profile !== null ? t('terminalDepsProfile', { profile: deps.profile }) : ''} +
+
+
{deps.command}
+ +
+ {deps.note !== undefined &&
{deps.note}
} +
+ +
+
+ ) +} diff --git a/src/client/TextEditor.tsx b/src/client/TextEditor.tsx index 5594e35..50f893c 100644 --- a/src/client/TextEditor.tsx +++ b/src/client/TextEditor.tsx @@ -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 && (
{(markdown || html) && (
@@ -313,6 +340,7 @@ export function TextEditor(props: FileViewerProps) { )} {saveLabel !== '' && {saveLabel}}
+ )} {editable && ( <> {truncated === true && mode === 'edit' &&
{t('truncation')}
} diff --git a/src/client/TreePanel.tsx b/src/client/TreePanel.tsx new file mode 100644 index 0000000..0f7830b --- /dev/null +++ b/src/client/TreePanel.tsx @@ -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(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 ( +
+
+ { setQuery(event.target.value) }} + /> + +
+ {needle === '' ? ( + + ) : ( +
+ {error !== null &&
{error}
} + {error === null && results === null &&
{t('loading')}
} + {error === null && results !== null && results.matches.length === 0 && ( +
{t('editorSearchNoResults')}
+ )} + {error === null && results !== null && results.matches.map(rel => ( + + ))} + {error === null && results?.truncated === true && ( +
{t('editorSearchTruncated')}
+ )} +
+ )} +
+ ) +} diff --git a/src/client/api.ts b/src/client/api.ts index 619b60a..e0ae279 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -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(method: string, payload: Record, signal?: AbortSignal): Promise { 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('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('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 @@ -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) } diff --git a/src/client/builtins/tabs.tsx b/src/client/builtins/tabs.tsx index 943d593..e48212d 100644 --- a/src/client/builtins/tabs.tsx +++ b/src/client/builtins/tabs.tsx @@ -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:` ids through `createTab`; the browser mints - * `browser:` the same way (no quota). + * `browser:` 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) => , - order: -1, - hidden: true, - dedupeKey: (tab) => tab.path, - component: ({ ctx, store, scope, tab }) => ( - - ), - }, - { - 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) => , order: 10, - single: true, - component: ({ ctx, store, scope, expanded, onToggleDir, onReferenceFile }) => ( - 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) => , + title: () => t('editorExplorerMerged'), + desc: () => t('editorExplorerMergedDesc'), + }, + { + value: false, + icon: (size: number) => , + title: () => t('editorExplorerSplit'), + desc: () => t('editorExplorerSplitDesc'), + }, + ], + }], + }, + component: ({ ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile }) => ( + { /* no-op */ })} - onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }} + onToggleDir={onToggleDir ?? (() => { /* no-op */ })} onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })} /> ), @@ -187,9 +206,10 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] { title: () => t('browser'), icon: (size: number) => , order: 50, - // Declarative settings: the sandbox escape hatch and the link - // takeover render under this tab's row in the Side card settings - // page (the sandbox one is warned on). + // Declarative settings: the sandbox escape hatch, the link-takeover + // MASTER switch, and the per-protocol takeover switches (http on / + // https off by default) render under this tab's row in the Side card + // settings page (the sandbox one is warned on). settings: { toggles: [{ key: 'browserNoSandbox', @@ -199,6 +219,14 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] { key: 'browserInterceptLinks', title: () => t('settingsBrowserLinksTitle'), desc: () => t('settingsBrowserLinksDesc'), + }, { + key: 'browserInterceptHttp', + title: () => t('settingsBrowserHttpTitle'), + desc: () => t('settingsBrowserHttpDesc'), + }, { + key: 'browserInterceptHttps', + title: () => t('settingsBrowserHttpsTitle'), + desc: () => t('settingsBrowserHttpsDesc'), }], }, createTab: (state) => ({ diff --git a/src/client/index.tsx b/src/client/index.tsx index e0950f3..216fdcc 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -12,7 +12,7 @@ import { createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import type { Context } from '../context-types.ts' import { createSidebarStore } from './state.ts' -import { createBetterSidebarService } from './service.ts' +import { createBetterSidebarService, matchUrlTarget } from './service.ts' import { resetChunks } from './chunk-loader.ts' import { registerBuiltins } from './builtins/index.ts' import { Sidebar } from './Sidebar.tsx' @@ -20,6 +20,7 @@ import { RenderBoundary } from './RenderBoundary.tsx' import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx' import { registerLinkInterception } from './link-intercept.ts' import { registerImeGuard } from './ime-guard.ts' +import { registerSettingsNavIcon } from './settings-nav-icon.ts' import { loadPrefs } from './prefs.ts' import { SideCardSection } from './SideCardSection.tsx' import { api } from './api.ts' @@ -153,16 +154,35 @@ export function apply(ctx: Context): void { ctx.effect( () => { try { - // External http(s) links in the chat/GUI open the sidebar browser - // instead of a new window (gated on the browserInterceptLinks pref - // AND the browser tab's enable switch; Ctrl/Cmd+click bypasses). + // External http(s) links in the chat/GUI open the sidebar instead + // of a new window. Gated on the browserInterceptLinks MASTER pref, + // the URL's protocol flag (browserInterceptHttp / Https — https + // defaults OFF: most https sites refuse iframe embedding), and the + // target tab's enable switch; Ctrl/Cmd+click always bypasses. The + // target is the first registered tab whose `urlTarget` claims the + // URL (enabled tabs only), else the built-in browser tab. + const urlTargetOf = (url: URL): string | undefined => { + const prefs = sidebarStore.getPrefs() + const enabled = service.getTabs().filter(tab => prefs.tabsEnabled[tab.id] !== false) + return matchUrlTarget(enabled, url)?.id + } return registerLinkInterception({ - takeoverEnabled: () => sidebarStore.getPrefs().browserInterceptLinks !== false - && sidebarStore.getPrefs().tabsEnabled['browser'] !== false, + takeoverEnabled: (url) => { + const prefs = sidebarStore.getPrefs() + if (prefs.browserInterceptLinks === false) return false + const protocolOn = url.protocol === 'https:' + ? prefs.browserInterceptHttps !== false + : prefs.browserInterceptHttp !== false + if (!protocolOn) return false + // A plugin claim is the target (already enabled-filtered); + // otherwise the built-in browser must be enabled. + return urlTargetOf(url) !== undefined || prefs.tabsEnabled['browser'] !== false + }, openInSidebar: (url) => { let title: string | undefined try { title = new URL(url).hostname } catch { /* keep the default title */ } - ctx.betterSidebar?.openTab({ type: 'browser', url, title }) + const type = urlTargetOf(new URL(url)) ?? 'browser' + ctx.betterSidebar?.openTab({ type, url, title }) }, selfOrigin: window.location.origin, }) @@ -194,6 +214,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 diff --git a/src/client/lang.ts b/src/client/lang.ts index e9460ca..8e7dc40 100644 --- a/src/client/lang.ts +++ b/src/client/lang.ts @@ -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 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(), diff --git a/src/client/layout.css b/src/client/layout.css index 4b3a677..b1c483f 100644 --- a/src/client/layout.css +++ b/src/client/layout.css @@ -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) { diff --git a/src/client/link-intercept.ts b/src/client/link-intercept.ts index af782d6..3d50c4a 100644 --- a/src/client/link-intercept.ts +++ b/src/client/link-intercept.ts @@ -1,10 +1,12 @@ /** * Chat/GUI external-link interception: clicking an http(s) link that points * OUTSIDE the GUI (chat messages, tool rows, prose mentions) opens the - * sidebar browser instead of a new browser tab. Gated by BOTH the - * `browserInterceptLinks` pref and the browser tab's enable switch; a - * Ctrl/Cmd/Shift/Alt-modified click always bypasses the takeover so the - * user can still force a real browser tab. + * sidebar instead of a new browser tab. Gated by the caller through + * `takeoverEnabled(url)` — the `browserInterceptLinks` master, the URL's + * protocol flag (`browserInterceptHttp` / `browserInterceptHttps`) and the + * target tab's enable switch — and a Ctrl/Cmd/Shift/Alt-modified click + * always bypasses the takeover so the user can still force a real browser + * tab. * * Only the GUI's OWN document is watched — links inside the browser tab's * sandboxed iframe live in another document and never bubble here (and @@ -13,7 +15,10 @@ /** The pure decision: the URL to open in the sidebar, or null to let the * click fall through. Extracted so the policy is unit-testable without a - * DOM. `anchorHref` must be the ABSOLUTE href (`.href` already is). */ + * DOM. `anchorHref` must be the ABSOLUTE href (`.href` already is). + * The protocol/same-origin policy lives HERE; the prefs gates (master + + * protocol flags + target enablement) live in the caller's + * `takeoverEnabled(url)` callback. */ export function shouldInterceptLink(anchorHref: string, selfOrigin: string): string | null { let url: URL try { @@ -23,7 +28,7 @@ export function shouldInterceptLink(anchorHref: string, selfOrigin: string): str } if (url.protocol !== 'http:' && url.protocol !== 'https:') return null // Same-origin links are GUI-internal navigation (settings pages, tool - // docs) — never routed into the sidebar browser. + // docs) — never routed into the sidebar. try { if (url.origin === new URL(selfOrigin).origin) return null } catch { @@ -39,11 +44,13 @@ export function isPlainLeftClick(event: { button: number; metaKey: boolean; ctrl /** * Register the document-level click capture that funnels external links - * into the sidebar browser. Returns the disposer (HMR-safe). + * into the sidebar. Returns the disposer (HMR-safe). */ export function registerLinkInterception(opts: { - takeoverEnabled: () => boolean - /** Open the sidebar browser tab at `url` (the caller resolves the session). */ + /** Whether the takeover may happen for THIS url (the caller's prefs + * gates: master switch, protocol flag, target enablement). */ + takeoverEnabled: (url: URL) => boolean + /** Open the sidebar tab at `url` (the caller resolves the target type). */ openInSidebar: (url: string) => void /** The GUI's own origin (window.location.origin at registration). */ selfOrigin: string @@ -51,13 +58,13 @@ export function registerLinkInterception(opts: { const onClick = (event: MouseEvent): void => { if (!isPlainLeftClick(event)) return if (event.defaultPrevented) return - if (!opts.takeoverEnabled()) return const target = event.target if (target === null || typeof (target as Element).closest !== 'function') return const anchor = (target as Element).closest('a[href]') as HTMLAnchorElement | null if (anchor === null) return const url = shouldInterceptLink(anchor.href, opts.selfOrigin) if (url === null) return + if (!opts.takeoverEnabled(new URL(url))) return event.preventDefault() opts.openInSidebar(url) } diff --git a/src/client/locales.ts b/src/client/locales.ts index c5ab98b..58ab930 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -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: '刷新', @@ -116,6 +135,10 @@ export const zh = { settingsWidthSuffix: '%', settingsOpenPathTitle: '聊天区文件在侧边栏打开', settingsOpenPathDesc: '在聊天里点击文件链接(工具行、产物列表、文件提及)时,在侧边栏编辑器中打开,不再调用系统默认应用', + settingsTitleBarTitle: '位置兼容模式', + settingsTitleBarDesc: '为 Windows 右上角的原生标题栏预留空间:侧边栏按钮与侧边栏内容整体下移,避免被标题栏遮挡', + settingsTitleBarStripTitle: '下移距离', + settingsTitleBarStripDesc: '标题栏条带高度:侧边栏按钮与内容下移的像素数(0–120,默认 40)', settingsSaveFailed: '保存失败', settingsConflict: '设置已被其他窗口修改,请重试', binaryNoPreview: '此文件类型不支持预览', @@ -166,7 +189,11 @@ export const zh = { settingsBrowserSandboxTitle: '关闭浏览器沙箱(不安全)', settingsBrowserSandboxDesc: '关闭后,访问的任何网站都将与界面同源运行,可读取会话数据并冒充你的登录状态。仅对完全可信的站点开启', settingsBrowserLinksTitle: '聊天区外链在侧边栏打开', - settingsBrowserLinksDesc: '开启后,点击聊天或界面中的 http/https 外链时在侧边栏浏览器中打开,不再弹出新窗口;Ctrl/Cmd 点击可临时放行', + settingsBrowserLinksDesc: '开启后,点击聊天或界面中的外链时在侧边栏打开,不再弹出新窗口;HTTP 与 HTTPS 可分别通过下方开关控制;Ctrl/Cmd 点击可临时放行', + settingsBrowserHttpTitle: '侧边打开HTTP网页', + settingsBrowserHttpDesc: '开启后,点击聊天或界面中的 HTTP 外链时在侧边栏打开(声明了 urlTarget 的插件页面优先);Ctrl/Cmd 点击可临时放行', + settingsBrowserHttpsTitle: '侧边打开HTTPS网页', + settingsBrowserHttpsDesc: '开启后,点击聊天或界面中的 HTTPS 外链时在侧边栏打开。默认关闭:多数 HTTPS 站点拒绝被嵌入,走系统浏览器更顺畅', browserOpenExternal: '在浏览器中打开', browserEmbedBlocked: '{host} 拒绝了嵌入请求', browserEmbedBlockedDesc: '该站点通过 X-Frame-Options / frame-ancestors 禁止在其它页面中显示,无法在侧边栏内加载。可在浏览器中直接打开', @@ -218,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 = { + 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)', @@ -241,6 +286,9 @@ export const en: Record = { 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', @@ -329,6 +377,10 @@ export const en: Record = { settingsWidthSuffix: '%', settingsOpenPathTitle: 'Open chat files in the sidebar', settingsOpenPathDesc: 'Open file links in the chat (tool rows, produced files, mentions) in the sidebar editor instead of the system default app', + settingsTitleBarTitle: 'Position compatibility mode', + settingsTitleBarDesc: 'Reserve space for the native Windows title bar at the top-right so the sidebar buttons and content sit below it instead of underneath', + settingsTitleBarStripTitle: 'Shift distance', + settingsTitleBarStripDesc: 'Title-bar strip height: how far the sidebar buttons and content move down in px (0–120, default 40)', settingsSaveFailed: 'Failed to save', settingsConflict: 'The setting changed in another window — please retry', binaryNoPreview: 'This file type cannot be previewed', @@ -379,7 +431,11 @@ export const en: Record = { settingsBrowserSandboxTitle: 'Disable browser sandbox (unsafe)', settingsBrowserSandboxDesc: 'With the sandbox off, any visited site runs with the same origin as the GUI: it can read session data and act as your logged-in session. Only enable for fully trusted sites', settingsBrowserLinksTitle: 'Open chat external links in the sidebar', - settingsBrowserLinksDesc: 'When on, clicking an http/https external link in the chat or GUI opens the sidebar browser instead of a new window; Ctrl/Cmd+click always bypasses', + settingsBrowserLinksDesc: 'When on, clicking an external link in the chat or GUI opens the sidebar instead of a new window; HTTP and HTTPS are controlled separately by the switches below; Ctrl/Cmd+click always bypasses', + settingsBrowserHttpTitle: 'Open HTTP pages in the sidebar', + settingsBrowserHttpDesc: 'When on, clicking an HTTP external link in the chat or GUI opens the sidebar (plugin pages declaring urlTarget win); Ctrl/Cmd+click always bypasses', + settingsBrowserHttpsTitle: 'Open HTTPS pages in the sidebar', + settingsBrowserHttpsDesc: 'When on, clicking an HTTPS external link in the chat or GUI opens the sidebar. Off by default: most HTTPS sites refuse to be embedded, so the system browser is the smoother default', browserOpenExternal: 'Open in browser', browserEmbedBlocked: '{host} refused to be embedded', browserEmbedBlockedDesc: 'The site forbids being displayed inside other pages (X-Frame-Options / frame-ancestors), so it cannot load in the sidebar. Open it directly in your browser instead.', @@ -431,8 +487,10 @@ export const en: Record = { 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', } /** diff --git a/src/client/paths.ts b/src/client/paths.ts index 70bf192..d87569b 100644 --- a/src/client/paths.ts +++ b/src/client/paths.ts @@ -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). diff --git a/src/client/plugins-tabs.ts b/src/client/plugins-tabs.ts index a7a9e97..7304940 100644 --- a/src/client/plugins-tabs.ts +++ b/src/client/plugins-tabs.ts @@ -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 划选追问', diff --git a/src/client/plugins-viewers.ts b/src/client/plugins-viewers.ts index db131ce..8fe8706 100644 --- a/src/client/plugins-viewers.ts +++ b/src/client/plugins-viewers.ts @@ -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', + }, ] diff --git a/src/client/prefs.ts b/src/client/prefs.ts index 9f993e5..48e630b 100644 --- a/src/client/prefs.ts +++ b/src/client/prefs.ts @@ -11,12 +11,13 @@ import type { api } from './api.ts' import { clampTerminalFontSize, + clampTitleBarStrip, clampWidthPercent, SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs, } from '../prefs-shared.ts' -export { SIDEBAR_PREFS_DEFAULTS, clampTerminalFontSize, clampWidthPercent } +export { SIDEBAR_PREFS_DEFAULTS, clampTerminalFontSize, clampTitleBarStrip, clampWidthPercent } export type { SidebarPrefs } /** The settings wire face the preferences need (a subset of the plugin api). */ @@ -59,6 +60,15 @@ 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, + titleBarStripPx: typeof record.titleBarStripPx === 'number' && Number.isFinite(record.titleBarStripPx) + ? clampTitleBarStrip(record.titleBarStripPx) + : SIDEBAR_PREFS_DEFAULTS.titleBarStripPx, htmlViewerNoSandbox: typeof record.htmlViewerNoSandbox === 'boolean' ? record.htmlViewerNoSandbox : SIDEBAR_PREFS_DEFAULTS.htmlViewerNoSandbox, @@ -71,6 +81,12 @@ export function parsePrefs(value: unknown): SidebarPrefs { browserInterceptLinks: typeof record.browserInterceptLinks === 'boolean' ? record.browserInterceptLinks : SIDEBAR_PREFS_DEFAULTS.browserInterceptLinks, + browserInterceptHttp: typeof record.browserInterceptHttp === 'boolean' + ? record.browserInterceptHttp + : SIDEBAR_PREFS_DEFAULTS.browserInterceptHttp, + browserInterceptHttps: typeof record.browserInterceptHttps === 'boolean' + ? record.browserInterceptHttps + : SIDEBAR_PREFS_DEFAULTS.browserInterceptHttps, tabsEnabled: booleanMapOf(record.tabsEnabled), viewersEnabled: booleanMapOf(record.viewersEnabled), pluginSettings: pluginSettingsMapOf(record.pluginSettings), diff --git a/src/client/produced-files.ts b/src/client/produced-files.ts index a0c2a7b..4a7a2f5 100644 --- a/src/client/produced-files.ts +++ b/src/client/produced-files.ts @@ -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('\\') ? '\\' : '/' diff --git a/src/client/service.ts b/src/client/service.ts index 2aa34c4..69693f1 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -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`). */ @@ -169,6 +192,22 @@ export interface TabDescriptor { * When omitted, a default `{ id, type, title }` tab is created. */ createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial } | null + /** + * External-link target claim (v0.13.0+): when a GUI external-link click + * is taken over (the `browserInterceptLinks` master AND the URL's + * protocol flag — `browserInterceptHttp` / `browserInterceptHttps` — + * are on), the first registered tab whose `urlTarget(url)` returns true + * is opened with `openTab({ type, url, title: hostname })` — the URL is + * the whole payload (the tab reads it from `tab.path`). Registration + * order wins (first claim first served); a disabled tab type is skipped; + * a throwing predicate is swallowed (console.error, the type is skipped). + * The built-in browser tab declares NO urlTarget — it stays the implicit + * fallback target, so plugins can never be shadowed by it. To host more + * than one URL at a time, mint per-URL ids through `createTab` (the + * browser builtin's pattern); otherwise the id safety net focuses the + * existing tab of the same type and the new URL is not applied. + */ + urlTarget?: (url: URL) => boolean /** * Declarative settings shown in the Side card settings page: every * registered tab gets an enable/disable switch (icon + title + id), and @@ -224,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). */ @@ -373,11 +439,38 @@ function baseNameOf(path: string): string { return at === -1 ? path : path.slice(at + 1) } +/** + * Find the tab type that claims an intercepted external-link URL (v0.13.0+). + * Walks the descriptors in REGISTRATION order and returns the first one + * that declares `urlTarget` and matches `url`; a throwing predicate is + * swallowed (console.error, type skipped) so one broken plugin can never + * break the whole link pipeline. The caller passes the ENABLED tab + * descriptors (enablement is the caller's prefs domain — filter + * `service.getTabs()` through `tabsEnabled` before matching) and falls + * back to the built-in browser tab when nothing claims the URL (the + * browser never declares `urlTarget` itself, so it can never shadow a + * plugin claim). + */ +export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDescriptor | undefined { + for (const tab of tabs) { + if (tab.urlTarget === undefined) continue + let claimed = false + try { + claimed = tab.urlTarget(url) === true + } catch (error) { + console.error('[dsh-better-sidebar] urlTarget error:', error) + continue + } + if (claimed) return tab + } + return undefined +} + /** * The plugin version this service instance reports. Keep in lockstep with * `package.json`'s version — `tests/service.spec.ts` asserts the pair. */ -export const SIDEBAR_SERVICE_VERSION = '0.12.1' +export const SIDEBAR_SERVICE_VERSION = '0.12.3' /** * Monotonic capability list consumers use to gate new API usage (features @@ -390,6 +483,8 @@ export const SIDEBAR_SERVICE_VERSION = '0.12.1' * - 'stateSubscription': getSnapshot/subscribeState * - 'tabMeta': SidebarTab.meta (seeds, createTab, updateTab, persistence) * - 'pluginSettings': SidebarSettingsDeclaration.pluginToggles/render + * - 'urlTarget' (v0.13.0): TabDescriptor.urlTarget (external-link claims) + * - 'settingSelect': SidebarSettingToggle type 'select' (options/multi) */ export const SIDEBAR_FEATURES = [ 'badge', @@ -400,6 +495,8 @@ export const SIDEBAR_FEATURES = [ 'stateSubscription', 'tabMeta', 'pluginSettings', + 'urlTarget', + 'settingSelect', ] as const /** Run one plugin callback; a throw is logged and never breaks the caller. */ diff --git a/src/client/settings-nav-icon.ts b/src/client/settings-nav-icon.ts new file mode 100644 index 0000000..e2b574e --- /dev/null +++ b/src/client/settings-nav-icon.ts @@ -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('[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) }) + } +} diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index 68f709d..3cfbb6e 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -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; @@ -209,15 +232,44 @@ padding-right: 40px; } +/* ── Position compatibility mode (Windows native title bar) ────────────── */ + +/* Windows frameless / hidden-title-bar windows draw the native caption + strip (minimize / maximize / close) at the window's top-right corner, + OVER the web content. When the user enables the "position compatibility" + pref, the sidebar yields the strip: the toggle cluster drops below it and + the right panel's content starts below it — the panel's surface still + spans the full height, so the caption buttons float over empty sidebar + fill (the VSCode hidden-title-bar look). The strip height is user-tunable + (`--dsh-title-bar-strip`, written by the Sidebar shell from + `titleBarStripPx`; 40px fallback). The bottom panel is unaffected: it + sits at the window's bottom, never under the top strip. */ +:global(body[data-dsh-title-bar-compat]) .toggleCluster { + top: calc(var(--dsh-title-bar-strip, 40px) + 3px); +} + +:global(body[data-dsh-title-bar-compat]) .panel { + /* The caption strip is drawn over the panel's own top edge: keep the + surface full-height and push the content (the tab strip) below the + strip instead. .panelResize is absolutely positioned and unaffected. */ + padding-top: var(--dsh-title-bar-strip, 40px); +} + /* The shared corner (both panels open): the intersection of the right panel's left edge and the bottom panel's top edge. Horizontal drags resize the width, vertical drags the height — the two panels drag - 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; } @@ -614,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; } @@ -625,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; @@ -651,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 @@ -735,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); @@ -1241,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. */ diff --git a/src/client/state.ts b/src/client/state.ts index 8656ddc..5730bcf 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -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, reid: Map 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 + : 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). diff --git a/src/client/theme.ts b/src/client/theme.ts index b77af10..789986c 100644 --- a/src/client/theme.ts +++ b/src/client/theme.ts @@ -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 diff --git a/src/config.ts b/src/config.ts index b64198e..f7b3a66 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,6 +12,9 @@ import { TERMINAL_FONT_SIZE_DEFAULT, TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN, + TITLE_BAR_STRIP_DEFAULT, + TITLE_BAR_STRIP_MAX, + TITLE_BAR_STRIP_MIN, WIDTH_PERCENT_DEFAULT, WIDTH_PERCENT_MAX, WIDTH_PERCENT_MIN, @@ -24,6 +27,9 @@ export { TERMINAL_FONT_SIZE_DEFAULT, TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN, + TITLE_BAR_STRIP_DEFAULT, + TITLE_BAR_STRIP_MAX, + TITLE_BAR_STRIP_MIN, WIDTH_PERCENT_DEFAULT, WIDTH_PERCENT_MAX, WIDTH_PERCENT_MIN, @@ -42,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. */ @@ -51,6 +65,7 @@ export const Config: z = 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. */ @@ -60,6 +75,7 @@ export interface ResolvedSidebarConfig { listLimit: number terminalsPerSession: number reconnectGraceMs: number + shell: string } /** @@ -75,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() ?? '', } } @@ -82,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 = 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), @@ -91,10 +108,15 @@ export const PrefsSchema: z = 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), htmlViewerDefaultUnsafe: z.boolean().default(false), browserNoSandbox: z.boolean().default(false), browserInterceptLinks: z.boolean().default(true), + browserInterceptHttp: z.boolean().default(true), + browserInterceptHttps: z.boolean().default(false), // Per-feature enable switches are OPEN maps (any tab/viewer id, built-in or // external): an absent key means enabled, so old documents resolve to {} // (everything on) with no migration. Non-boolean values fail validation. diff --git a/src/fs-search.ts b/src/fs-search.ts new file mode 100644 index 0000000..597bbb0 --- /dev/null +++ b/src/fs-search.ts @@ -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 { + 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 => { + 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 } +} diff --git a/src/fs-tree.ts b/src/fs-tree.ts index 437cff7..89cdc5f 100644 --- a/src/fs-tree.ts +++ b/src/fs-tree.ts @@ -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 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 { + 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) diff --git a/src/html-route.ts b/src/html-route.ts index b81a556..1dfac34 100644 --- a/src/html-route.ts +++ b/src/html-route.ts @@ -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 + // '//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 } } } diff --git a/src/index.ts b/src/index.ts index f9e0622..714410e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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' @@ -179,8 +186,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 { @@ -207,6 +214,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 +328,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 +339,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 @@ -426,18 +448,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 +495,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 +506,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) => { @@ -695,8 +737,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 +746,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 { @@ -717,13 +759,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 +787,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 +797,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 +817,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. diff --git a/src/prefs-shared.ts b/src/prefs-shared.ts index a3b6210..5d6c6a2 100644 --- a/src/prefs-shared.ts +++ b/src/prefs-shared.ts @@ -60,6 +60,30 @@ 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 + * content in frameless/hidden-title-bar windows). When on, the toggle + * cluster drops below the strip and the right panel's content starts + * below it. Off by default — the sidebar layout is untouched. + */ + titleBarCompat: boolean + /** + * The reserved top strip height in px when `titleBarCompat` is on + * (0–120, default 40). Drives the `--dsh-title-bar-strip` CSS variable: + * the toggle cluster drops `strip + 3px` and the right panel's content + * starts `strip` px below its top edge. + */ + titleBarStripPx: number /** * Whether the HTML previewer drops its sandboxed iframe. Sandbox ON (the * default) renders previewed HTML in an opaque-origin iframe that cannot @@ -85,13 +109,31 @@ export interface SidebarPrefs { */ browserNoSandbox: boolean /** - * Whether clicking an http(s) EXTERNAL link in the GUI (chat messages, - * tool rows, prose mentions) opens the sidebar browser instead of a new - * browser tab. On by default; gated on the browser tab's own enable - * switch (both must be on for the takeover). Ctrl/Cmd+click always - * bypasses the takeover. + * MASTER switch: whether clicking an EXTERNAL link in the GUI (chat + * messages, tool rows, prose mentions) is taken over into the sidebar at + * all. On by default; the per-protocol granularity lives in + * `browserInterceptHttp` / `browserInterceptHttps` (the protocol flag + * must also be on), and the target tab's own enable switch gates it too. + * Ctrl/Cmd+click always bypasses the takeover. Kept as the master so old + * documents keep their meaning with no migration (an explicit `false` + * stays "never take over"). */ browserInterceptLinks: boolean + /** + * Whether clicking an http EXTERNAL link in the GUI opens the sidebar + * (the built-in browser tab, or a plugin tab that declares `urlTarget`) + * instead of a new browser tab. On by default; gated on the + * `browserInterceptLinks` master and the target tab's own enable switch. + */ + browserInterceptHttp: boolean + /** + * Whether clicking an https EXTERNAL link in the GUI opens the sidebar + * instead of a new browser tab. OFF by default — most https sites (e.g. + * GitHub) refuse iframe embedding, so the system browser is the smoother + * default; gated on the `browserInterceptLinks` master and the target + * tab's own enable switch. + */ + browserInterceptHttps: boolean /** * Per-tab enable switches, keyed by tab descriptor id (`'explorer'`, * `'my-plugin:db'`). An ABSENT key means enabled — only an explicit @@ -123,16 +165,21 @@ 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 export const TERMINAL_FONT_SIZE_MAX = 32 export const TERMINAL_FONT_SIZE_DEFAULT = 13 +/** Range contract of {@link SidebarPrefs.titleBarStripPx}. */ +export const TITLE_BAR_STRIP_MIN = 0 +export const TITLE_BAR_STRIP_MAX = 120 +export const TITLE_BAR_STRIP_DEFAULT = 40 + /** Fallback prefs used whenever the settings document is unreachable or malformed. */ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = { - openByDefault: true, + openByDefault: false, defaultWidthPercent: WIDTH_PERCENT_DEFAULT, autoOpenSubagent: true, autoOpenJobs: true, @@ -141,10 +188,15 @@ 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, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, + browserInterceptHttp: true, + browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {}, @@ -159,3 +211,8 @@ export function clampWidthPercent(value: number): number { export function clampTerminalFontSize(value: number): number { return Math.min(TERMINAL_FONT_SIZE_MAX, Math.max(TERMINAL_FONT_SIZE_MIN, Math.round(value))) } + +/** Clamp one title-bar strip height into the contract range (shared by schema and client reads). */ +export function clampTitleBarStrip(value: number): number { + return Math.min(TITLE_BAR_STRIP_MAX, Math.max(TITLE_BAR_STRIP_MIN, Math.round(value))) +} diff --git a/src/pty-deps.ts b/src/pty-deps.ts new file mode 100644 index 0000000..cfd695c --- /dev/null +++ b/src/pty-deps.ts @@ -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 } : {}), + } +} diff --git a/src/pty-manager.ts b/src/pty-manager.ts index bd483e6..41a80fa 100644 --- a/src/pty-manager.ts +++ b/src/pty-manager.ts @@ -9,7 +9,9 @@ import { chmodSync, existsSync } from 'node:fs' import { dirname, join } from 'node:path' import { createRequire } from 'node:module' -import * as nodePty from 'node-pty' +import { userInfo } from 'node:os' +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). */ @@ -51,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). */ @@ -70,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. */ @@ -118,7 +122,7 @@ export class PtyManager { sessionId, tabId, cwd, - pty: nodePty.spawn(this.shell, [], { + pty: this.nodePty.spawn(this.shell, shellSpawnArgs(), { name: 'xterm-256color', cols: Math.max(2, Math.floor(cols)), rows: Math.max(2, Math.floor(rows)), @@ -191,9 +195,110 @@ export class PtyManager { } } -/** The interactive shell for this platform (empty SHELL falls back). */ -export function defaultShell(): string { - if (process.platform === 'win32') return 'powershell.exe' - const shell = process.env.SHELL - return shell !== undefined && shell.trim() !== '' ? shell : '/bin/bash' +/** + * 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 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 { + const loginShell = userInfo().shell + if (typeof loginShell === 'string' && loginShell.trim() !== '') return loginShell + } catch { + // no passwd entry: fall through to /bin/bash + } + return '/bin/bash' +} + +/** + * Spawn arguments that make the shell behave like a terminal-emulator tab: + * POSIX shells start as login shells (`-l`) so they read the profile files + * (`~/.profile`, `~/.zprofile`); Windows PowerShell takes no login flag. + */ +export function shellSpawnArgs(): string[] { + return process.platform === 'win32' ? [] : ['-l'] } diff --git a/src/wire.ts b/src/wire.ts index 5511024..0ab939c 100644 --- a/src/wire.ts +++ b/src/wire.ts @@ -15,6 +15,7 @@ export type SidebarErrorCode = | 'fs-error' | 'git-error' | 'pty-error' + | 'pty-deps-missing' | 'job-error' | 'settings-rejected' | 'settings-conflict' diff --git a/tests/add-plugin-modal.spec.tsx b/tests/add-plugin-modal.spec.tsx index b1ec09b..6aedab3 100644 --- a/tests/add-plugin-modal.spec.tsx +++ b/tests/add-plugin-modal.spec.tsx @@ -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 { diff --git a/tests/agent-terminal-reconcile.spec.ts b/tests/agent-terminal-reconcile.spec.ts new file mode 100644 index 0000000..8f9cda7 --- /dev/null +++ b/tests/agent-terminal-reconcile.spec.ts @@ -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) + }) +}) diff --git a/tests/builtins.spec.ts b/tests/builtins.spec.ts index 14e726e..80908a3 100644 --- a/tests/builtins.spec.ts +++ b/tests/builtins.spec.ts @@ -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; 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 ?? [] @@ -67,14 +83,19 @@ describe('built-in tab registrations', () => { expect(toggles[1]?.type ?? 'switch').toBe('switch') }) - it('the browser tab declares its sandbox and link-takeover related settings', () => { + it('the browser tab declares its sandbox, link-takeover master and per-protocol settings', () => { const { service } = setup() const toggles = service.getTab('browser')?.settings?.toggles ?? [] - expect(toggles.map(t => t.key)).toEqual(['browserNoSandbox', 'browserInterceptLinks']) - expect(toggles[0]?.title).toBeDefined() - expect(toggles[0]?.desc).toBeDefined() - expect(toggles[1]?.title).toBeDefined() - expect(toggles[1]?.desc).toBeDefined() + expect(toggles.map(t => t.key)).toEqual([ + 'browserNoSandbox', + 'browserInterceptLinks', + 'browserInterceptHttp', + 'browserInterceptHttps', + ]) + for (const toggle of toggles) { + expect(toggle.title).toBeDefined() + expect(toggle.desc).toBeDefined() + } }) it('the browser createTab mints browser: ids and bumps nextBrowser', () => { diff --git a/tests/e2e/drag-layout.e2e.ts b/tests/e2e/drag-layout.e2e.ts new file mode 100644 index 0000000..5b2c54a --- /dev/null +++ b/tests/e2e/drag-layout.e2e.ts @@ -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 { + 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(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(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('*')] + .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) +}) diff --git a/tests/e2e/mount.e2e.ts b/tests/e2e/mount.e2e.ts new file mode 100644 index 0000000..172063f --- /dev/null +++ b/tests/e2e/mount.e2e.ts @@ -0,0 +1,241 @@ +/** + * Headless-render mount lane: prove the npm-packed plugin mounts into a real + * `dsh web` instance and renders without crashing the shell. + * + * The server is NOT started here — `scripts/e2e-mount.sh` boots `dsh web` + * (with the plugin mounted through the official `dsh plugin add` channel) and + * injects the base URL via `DSH_E2E_URL`. This spec: + * + * 1. seeds one workspace + one session through the host's own RPC surface + * (the same `workspace.create` / `session.create` calls the UI makes), + * so the sidebar has a real session to render; + * 2. loads the page in headless Chromium and asserts the shell and the + * plugin's `[data-dsh-better-sidebar]` host mount; + * 3. asserts the plugin's crash markers never appear (no RenderBoundary / + * fail() strips, no `pageerror`, no plugin-prefixed console errors); + * 4. 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 + * assertion. + */ +import { mkdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test, expect, request, type APIRequestContext } from '@playwright/test' + +const BASE_URL = process.env.DSH_E2E_URL +if (!BASE_URL) { + throw new Error('DSH_E2E_URL is not set — boot a DSH web instance with the plugin mounted and point this lane at it (see scripts/e2e-mount.sh)') +} + +/** Workspace the sidebar renders against (created by the lane's seeding). */ +const WORKSPACE_PATH = process.env.DSH_E2E_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-workspace') + +/** A file seeded into the workspace, opened through the Files window's tree to + * exercise the file-open path (editor chunk = client-editor.js). */ +const SEEDED_FILE = 'hello.txt' + +/** + * The plugin's crash markers. The client mounts inside an error boundary that + * renders a strip whose text starts with these prefixes instead of crashing + * (see src/client/index.tsx `fail()` and src/client/RenderBoundary.tsx). + */ +const CRASH_STRIP_PATTERNS = [/^dsh-better-sidebar:/, /^\[dsh-better-sidebar\]/] + +/** Built-in tab titles the sweep drives (en-US copy; follows DSH locale). */ +const BUILTIN_TABS = ['Files', 'Source Control', 'Tasks', 'Terminal', 'Browser'] + +let api: APIRequestContext + +/** Seed one workspace + one session (plus a file for the editor-chunk probe) + * through the host's unary RPC surface. */ +async function seedSession(): Promise { + mkdirSync(WORKSPACE_PATH, { recursive: true }) + writeFileSync(join(WORKSPACE_PATH, SEEDED_FILE), 'hello from the mount lane\n') + const workspace = await api.post(`${BASE_URL}/api/workspace.create`, { + data: { type: 'client-request', rpcId: 'e2e-workspace', method: 'workspace.create', payload: { path: WORKSPACE_PATH } }, + }) + expect(workspace.ok(), `workspace.create: ${workspace.status()} ${await workspace.text()}`).toBe(true) + const workspaceBody = (await workspace.json()) as { + result: { ok: true; value: { workspace: { workspaceId: string } } } | { ok: false; error: unknown } + } + expect(workspaceBody.result.ok).toBe(true) + const workspaceId = (workspaceBody.result as { value: { workspace: { workspaceId: string } } }).value.workspace.workspaceId + + const session = await api.post(`${BASE_URL}/api/session.create`, { + data: { type: 'client-request', rpcId: 'e2e-session', method: 'session.create', payload: { workspaceId } }, + }) + expect(session.ok(), `session.create: ${session.status()} ${await session.text()}`).toBe(true) +} + +test.beforeAll(async () => { + api = await request.newContext({ baseURL: BASE_URL }) + await seedSession() +}) + +test.afterAll(async () => { + await api?.dispose() +}) + +test('plugin mounts into the DSH shell and survives a built-in tab sweep', async ({ page }) => { + const pageErrors: string[] = [] + const consoleErrors: string[] = [] + page.on('pageerror', (error) => pageErrors.push(String(error))) + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()) + }) + + // Load the shell. The app renders into #root; the plugin appends its own + // [data-dsh-better-sidebar] host once its client half activates. + // + // 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]') + await expect(sidebar).toBeAttached({ timeout: 90_000 }) + + // A keyless boot stacks onboarding takeovers that mask the whole shell: a + // versioned welcome notice ("Continue", persists its acknowledgement to + // settings) and, once that is acknowledged, a provider-config dialog + // ("Configure later", session-only — always present while no credential is + // configured). Both mount only after the settings join resolves. Wait + // (bounded) for one to appear; a DSH build without onboarding proceeds + // straight to the sweep. + try { + await expect + .poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 }) + .toBeGreaterThan(0) + } catch { + console.warn('[e2e] no onboarding takeover appeared; proceeding without dismissal') + } + // Dismiss whatever takeover is present, in any stacking order, until none + // remain — a masked click is retried next round instead of failing. + for (let round = 0; round < 8; round++) { + let dismissed = false + for (const name of ['Continue', 'Configure later']) { + const button = page.getByRole('button', { name, exact: true }).first() + if ((await button.count()) === 0) continue + try { + await button.click({ timeout: 4_000 }) + dismissed = true + await page.waitForTimeout(1_000) + } catch { + // Masked by the takeover stacked above it; the next round tries the + // other button first. + } + } + if (!dismissed) break + } + + // The seeded session must give the sidebar a session scope: without it the + // shell renders a disabled toggle cluster and the tab sweep is impossible. + const tabBar = sidebar.locator('[title]') + await expect(tabBar.first()).toBeAttached({ timeout: 90_000 }) + + // 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 => { + await expect + .poll(async () => pageErrors, { timeout: 5_000 }) + .toEqual([]) + const strips = await sidebar.locator('div').evaluateAll( + (nodes, patterns) => nodes.filter((node) => { + const text = (node.textContent ?? '').trim() + return patterns.some((pattern) => pattern.test(text)) + }).length, + CRASH_STRIP_PATTERNS, + ) + expect(strips, 'a dsh-better-sidebar error strip is present in the sidebar').toBe(0) + } + + // Sweep every built-in tab through the "+" menu (the sidebar's own open-tab + // affordance, reachable from any pane state). Each open may fetch a lazy + // chunk (/sidebar/bundle/client-terminal.js / client-editor.js) and mount a + // real viewer — the highest-risk crash surfaces. The pinned plugin must + // offer every listed built-in: a missing or renamed descriptor is a real + // regression and fails the lane loudly instead of silently narrowing the + // sweep. A failure anywhere surfaces as a pageerror or a console error, + // both of which the next assertion sees. + const newTabButton = sidebar.getByRole('button', { name: 'New tab' }).first() + for (const title of BUILTIN_TABS) { + await newTabButton.click() + const item = page.getByRole('menuitem', { name: title }).first() + await expect(item, `built-in tab "${title}" is not offered by the + menu — descriptor removed or its label changed`).toHaveCount(1) + await item.click() + // Let the activation commit (including any lazy-chunk fetch) before the + // crash assertions run. + await page.waitForTimeout(1_500) + await assertNoCrash() + } + + // The editor 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() + + // The plugin's own console prefix must never appear in errors, and no + // unhandled rejection may escape the sweep. + const pluginErrors = consoleErrors.filter((text) => /dsh-better-sidebar|Unhandled/.test(text)) + expect(pluginErrors, 'plugin-prefixed or unhandled console errors during the sweep').toEqual([]) + expect(pageErrors, 'pageerrors during the sweep').toEqual([]) + + // Final screenshot: the rendered panel with a session is the lane's proof. + await page.screenshot({ path: 'test-results/mount-final.png' }) +}) diff --git a/tests/editor-host.spec.tsx b/tests/editor-host.spec.tsx new file mode 100644 index 0000000..f01bb01 --- /dev/null +++ b/tests/editor-host.spec.tsx @@ -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).IS_REACT_ACT_ENVIRONMENT = true + +/** A store with the seeded editor-home tab (default prefs: in-place mode). */ +function setup(): { + store: ReturnType + 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, 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('button[aria-label="Save"]')!.click() }) + expect(calls).toEqual(['mode:edit', 'save']) + } finally { + unmount() + } + }) +}) diff --git a/tests/file-types.spec.ts b/tests/file-types.spec.ts new file mode 100644 index 0000000..66b36c0 --- /dev/null +++ b/tests/file-types.spec.ts @@ -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) + }) +}) diff --git a/tests/fs-search.spec.ts b/tests/fs-search.spec.ts new file mode 100644 index 0000000..9bb0c6b --- /dev/null +++ b/tests/fs-search.spec.ts @@ -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 }) + } + }) +}) diff --git a/tests/fs-tree-symlink.spec.ts b/tests/fs-tree-symlink.spec.ts new file mode 100644 index 0000000..dfbeb30 --- /dev/null +++ b/tests/fs-tree-symlink.spec.ts @@ -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 }) + } + }) +}) diff --git a/tests/fs-tree.spec.ts b/tests/fs-tree.spec.ts new file mode 100644 index 0000000..2175e72 --- /dev/null +++ b/tests/fs-tree.spec.ts @@ -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) + }) +}) diff --git a/tests/git.spec.ts b/tests/git.spec.ts new file mode 100644 index 0000000..cee7996 --- /dev/null +++ b/tests/git.spec.ts @@ -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([]) + }) +}) diff --git a/tests/html-route.spec.ts b/tests/html-route.spec.ts index 95b5d22..8bc8959 100644 --- a/tests/html-route.spec.ts +++ b/tests/html-route.spec.ts @@ -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' }, + }) + }) }) diff --git a/tests/link-intercept.spec.ts b/tests/link-intercept.spec.ts index 1d083c9..74d3e84 100644 --- a/tests/link-intercept.spec.ts +++ b/tests/link-intercept.spec.ts @@ -1,10 +1,12 @@ +// @vitest-environment jsdom /** * External-link interception policy tests: the pure decision (which http(s) - * external links are taken over into the sidebar browser) and the click - * shape (plain left clicks only — modified clicks always bypass). + * external links are taken over into the sidebar browser), the click shape + * (plain left clicks only — modified clicks always bypass), and the + * registered click capture (the caller's `takeoverEnabled(url)` gate). */ import { describe, expect, it } from 'vitest' -import { isPlainLeftClick, shouldInterceptLink } from '../src/client/link-intercept.ts' +import { isPlainLeftClick, registerLinkInterception, shouldInterceptLink } from '../src/client/link-intercept.ts' const SELF = 'http://127.0.0.1:3080' @@ -55,3 +57,66 @@ describe('isPlainLeftClick', () => { expect(isPlainLeftClick({ ...plain, button: 2 })).toBe(false) }) }) + +describe('registerLinkInterception', () => { + /** Dispatch one click on an anchor with the given absolute href. */ + const clickAnchor = (href: string, init: MouseEventInit = {}): void => { + const anchor = document.createElement('a') + anchor.href = href + document.body.appendChild(anchor) + anchor.dispatchEvent(new MouseEvent('click', { + bubbles: true, + cancelable: true, + button: 0, + ...init, + })) + anchor.remove() + } + + it('passes the parsed URL to takeoverEnabled and only takes over when it returns true', () => { + const seen: string[] = [] + const opened: string[] = [] + const dispose = registerLinkInterception({ + takeoverEnabled: (url) => { + seen.push(url.protocol) + return url.protocol === 'http:' + }, + openInSidebar: (url) => { opened.push(url) }, + selfOrigin: SELF, + }) + // https → the gate refuses (the sidebar-takeover defaults: https off). + clickAnchor('https://example.com/page') + // http → the gate passes, the click is taken over with the absolute href. + clickAnchor('http://example.com/page') + expect(seen).toEqual(['https:', 'http:']) + expect(opened).toEqual(['http://example.com/page']) + dispose() + }) + + it('never consults the gate for same-origin or non-http(s) links', () => { + let gateCalls = 0 + const dispose = registerLinkInterception({ + takeoverEnabled: () => { gateCalls++; return true }, + openInSidebar: () => { throw new Error('must not open') }, + selfOrigin: SELF, + }) + clickAnchor(`${SELF}/settings`) + clickAnchor('mailto:a@b.c') + expect(gateCalls).toBe(0) + dispose() + }) + + it('bypasses the takeover on modified clicks even when the gate passes', () => { + let gateCalls = 0 + const dispose = registerLinkInterception({ + takeoverEnabled: () => { gateCalls++; return true }, + openInSidebar: () => { throw new Error('must not open') }, + selfOrigin: SELF, + }) + clickAnchor('http://example.com/', { ctrlKey: true }) + clickAnchor('http://example.com/', { metaKey: true }) + clickAnchor('http://example.com/', { button: 1 }) + expect(gateCalls).toBe(0) + dispose() + }) +}) diff --git a/tests/openpath-intercept.spec.ts b/tests/openpath-intercept.spec.ts new file mode 100644 index 0000000..763003f --- /dev/null +++ b/tests/openpath-intercept.spec.ts @@ -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 { + this.calls.push(path) + this.opened.push(path) + }, + } + return fake + } + + const deps = (overrides: Partial = {}): 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> = [] + const funnel = { openPath: async (): Promise => {} } + const ctx = { + sessions: { + list: { getSnapshot: () => ({ current: 's1', byId: { s1: { cwd: '/w' } } }) }, + }, + workspaces: funnel, + betterSidebar: { openTab: (seed: unknown) => { opened.push(seed as Record) } }, + } 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) + }) +}) diff --git a/tests/paths.spec.ts b/tests/paths.spec.ts new file mode 100644 index 0000000..cee6fec --- /dev/null +++ b/tests/paths.spec.ts @@ -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') + }) +}) diff --git a/tests/platform.ts b/tests/platform.ts new file mode 100644 index 0000000..ae65a87 --- /dev/null +++ b/tests/platform.ts @@ -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' diff --git a/tests/plugin-shape.spec.ts b/tests/plugin-shape.spec.ts index 1de4bcf..b897278 100644 --- a/tests/plugin-shape.spec.ts +++ b/tests/plugin-shape.spec.ts @@ -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 | undefined): Record + })({ 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 | undefined): Record })(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) @@ -54,15 +61,21 @@ describe('dsh-better-sidebar plugin export shape', () => { // and 13px. expect(resolved.terminalFontFamily).toBe('') expect(resolved.terminalFontSize).toBe(13) + // The position-compat mode defaults OFF (the normal layout is default), + // with the strip defaulting to 40px. + expect(resolved.titleBarCompat).toBe(false) + expect(resolved.titleBarStripPx).toBe(40) // The enable-switch maps resolve to {} (everything on) for old documents. expect(resolved.tabsEnabled).toEqual({}) expect(resolved.viewersEnabled).toEqual({}) + // 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 | undefined): Record })({ openByDefault: false, defaultWidthPercent: 45 }) - expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) + expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) }) }) diff --git a/tests/prefs.spec.ts b/tests/prefs.spec.ts new file mode 100644 index 0000000..bfaa18f --- /dev/null +++ b/tests/prefs.spec.ts @@ -0,0 +1,297 @@ +import { describe, expect, it } from 'vitest' +import { 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' + +describe('side card preferences', () => { + /** 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 }), + }) + + const rejecting = (): SidebarSettingsClient => ({ + settingsGet: async () => { throw new Error('route rejected') }, + settingsUpdate: async () => { throw new Error('route rejected') }, + }) + + 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).window + ;(globalThis as Record).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).window + else (globalThis as Record).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) + }) +}) diff --git a/tests/produced-files.spec.ts b/tests/produced-files.spec.ts new file mode 100644 index 0000000..0afb9c5 --- /dev/null +++ b/tests/produced-files.spec.ts @@ -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') + }) +}) diff --git a/tests/pty-deps.spec.ts b/tests/pty-deps.spec.ts new file mode 100644 index 0000000..50de8de --- /dev/null +++ b/tests/pty-deps.spec.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 + } + expect(pkg.dependencies['node-pty']).toBe(DSH_NODE_PTY_RANGE) + }) +}) diff --git a/tests/pty-helpers.spec.ts b/tests/pty-helpers.spec.ts new file mode 100644 index 0000000..0cbdf63 --- /dev/null +++ b/tests/pty-helpers.spec.ts @@ -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() + 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) + }) +}) diff --git a/tests/service.spec.ts b/tests/service.spec.ts index b4487a4..aab61ab 100644 --- a/tests/service.spec.ts +++ b/tests/service.spec.ts @@ -20,8 +20,8 @@ if (g.localStorage === undefined) { } } -import { createBetterSidebarService, SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from '../src/client/service.ts' -import { createSidebarStore, allLeaves, makeDefaultState, openDiffTab, sanitizeState } from '../src/client/state.ts' +import { createBetterSidebarService, matchUrlTarget, SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from '../src/client/service.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)', () => { @@ -223,6 +224,55 @@ describe('matchFileViewer', () => { }) }) +describe('matchUrlTarget (v0.13.0)', () => { + /** A tab descriptor that claims a URL host. */ + const claimingTab = (id: string, host: string) => ({ + id, + title: id, + urlTarget: (url: URL): boolean => url.hostname === host, + component: () => null, + }) + + it('returns undefined when no tab declares urlTarget', () => { + const tabs = [ + { id: 'explorer', title: 'E', component: () => null }, + { id: 'git', title: 'G', component: () => null }, + ] + expect(matchUrlTarget(tabs, new URL('http://example.com/'))).toBeUndefined() + }) + + it('returns the first REGISTRATION-ORDER claim (first match wins)', () => { + const tabs = [ + claimingTab('my:one', 'example.com'), + claimingTab('my:two', 'example.com'), + ] + expect(matchUrlTarget(tabs, new URL('https://example.com/x'))?.id).toBe('my:one') + // A non-matching earlier tab yields to the next match. + const tabs2 = [ + claimingTab('my:one', 'other.com'), + claimingTab('my:two', 'example.com'), + ] + expect(matchUrlTarget(tabs2, new URL('https://example.com/x'))?.id).toBe('my:two') + }) + + it('skips a throwing predicate (the next claim still wins)', () => { + const tabs = [ + { id: 'my:broken', title: 'B', urlTarget: () => { throw new Error('boom') }, component: () => null }, + claimingTab('my:ok', 'example.com'), + ] + expect(matchUrlTarget(tabs, new URL('https://example.com/x'))?.id).toBe('my:ok') + }) + + it('never matches the built-in browser (no urlTarget declared — the caller falls back)', () => { + const tabs = [ + { id: 'browser', title: 'Browser', component: () => null }, + { id: 'my:ok', title: 'OK', urlTarget: () => true, component: () => null }, + ] + expect(matchUrlTarget(tabs, new URL('http://example.com/'))?.id).toBe('my:ok') + expect(matchUrlTarget([{ id: 'browser', title: 'Browser', component: () => null }], new URL('http://example.com/'))).toBeUndefined() + }) +}) + describe('service.openTab dedupe', () => { it('dedupeKey focuses existing tab instead of duplicating', () => { const store = createSidebarStore() @@ -303,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') }) @@ -594,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) }) }) @@ -622,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', () => { @@ -690,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') @@ -734,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. @@ -843,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() }) @@ -982,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/) + }) }) diff --git a/tests/settings-nav-icon.spec.ts b/tests/settings-nav-icon.spec.ts new file mode 100644 index 0000000..74f4839 --- /dev/null +++ b/tests/settings-nav-icon.spec.ts @@ -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 = `${label}` + 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 { + await new Promise(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() + }) +}) diff --git a/tests/side-card-section-rows.spec.tsx b/tests/side-card-section-rows.spec.tsx index 08dac8e..0706b1a 100644 --- a/tests/side-card-section-rows.spec.tsx +++ b/tests/side-card-section-rows.spec.tsx @@ -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('button[aria-haspopup="listbox"]')! + act(() => { anchor.click() }) + return [...document.querySelectorAll('[role="menuitem"]')] +} + +describe('FeatureSettingsRows select rows (interactive)', () => { + const options: NonNullable = [ + { 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 = [ + { 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('[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() + }) +}) diff --git a/tests/side-card-section.spec.tsx b/tests/side-card-section.spec.tsx index 7246561..ecdb9df 100644 --- a/tests/side-card-section.spec.tsx +++ b/tests/side-card-section.spec.tsx @@ -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)', () => { @@ -151,6 +152,28 @@ describe('SideCardSection declarative inventory', () => { const html = renderSection(store, service) expect(html).not.toContain('Feature settings') }) + + it('renders the position-compat mode general row: off by default, checked when the pref is on', () => { + const { store, service } = mount() + let html = renderSection(store, service) + // The general row renders its title and description. + expect(html).toContain('Position compatibility mode') + expect(html).toContain('Reserve space for the native Windows title bar') + // Three general rows now: 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(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') + + // 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(2) + expect(html).toContain('aria-label="Position compatibility mode Feature settings"') + }) }) describe('FeatureSettingsRows (the secondary settings popup body)', () => { @@ -229,6 +252,31 @@ describe('FeatureSettingsRows (the secondary settings popup body)', () => { expect(html).toContain('px') expect(html).not.toContain('type="checkbox"') }) + + it('renders the title-bar strip row: the pref value, the 0–120 bounds and the px suffix', () => { + const html = renderToString(createElement(FeatureSettingsRows, { + toggles: [{ + key: 'titleBarStripPx', + type: 'number', + title: () => 'Shift distance', + desc: () => 'Title-bar strip height in px', + min: 0, + max: 120, + unit: 'px', + }], + prefs: { ...prefs, titleBarStripPx: 64 }, + onToggle: () => {}, + onCommit: () => '64', + })) + expect(html).toContain('Shift distance') + expect(html).toContain('Title-bar strip height in px') + expect(html).toContain('type="number"') + expect(html).toContain('value="64"') + expect(html).toContain('min="0"') + expect(html).toContain('max="120"') + expect(html).toContain('px') + expect(html).not.toContain('type="checkbox"') + }) }) describe('mergePluginSetting (v0.12.0, codex review fix)', () => { diff --git a/tests/sidebar-crash.spec.tsx b/tests/sidebar-crash.spec.tsx index 7adb552..6edfe1d 100644 --- a/tests/sidebar-crash.spec.tsx +++ b/tests/sidebar-crash.spec.tsx @@ -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. diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index ef13e3a..bc66178 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -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 { + 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') @@ -188,6 +214,35 @@ 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 + // argv — the terminal-emulator behavior the tab should match. + writeFileSync(join(home, '.profile'), 'export DSH_LOGIN_MARKER=loaded-from-profile\n') + process.env.HOME = home + const manager = new PtyManager('/bin/bash', 3) + try { + handle = manager.open('s5', 't1', process.cwd(), 80, 24) + handle.pty.write('echo $DSH_LOGIN_MARKER\r') + const deadline = Date.now() + 5000 + while (!handle.transcript.includes('loaded-from-profile') && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 50)) + } + expect(handle.transcript).toContain('loaded-from-profile') + } finally { + manager.disposeAll() + } + } finally { + if (previousHome === undefined) delete process.env.HOME + else process.env.HOME = previousHome + await rmTempDirAfterPtyExit(handle ?? { exited: true }, home) + } + }) + it('lists the repository root level', async () => { const listing = await listDirectory(process.cwd(), 1000) expect(listing.entries.some(entry => entry.name === 'src' && entry.isDir)).toBe(true) @@ -511,8 +566,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, @@ -520,10 +575,15 @@ describe('side card settings routes', () => { terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, + editorExplorer: true, + titleBarCompat: false, + titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, + browserInterceptHttp: true, + browserInterceptHttps: false, // The enable-switch maps default to {} (everything on). tabsEnabled: {}, viewersEnabled: {}, @@ -533,11 +593,11 @@ describe('side card settings routes', () => { revision: 0, }) - 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) }) diff --git a/tests/state.spec.ts b/tests/state.spec.ts new file mode 100644 index 0000000..50df04c --- /dev/null +++ b/tests/state.spec.ts @@ -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 + 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 + 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 + // paneB 现在是 split(row) [旧leaf, 新leaf(tabId)];其父 split 仍存在。 + const bSplit = after.children.find(child => child.kind === 'split') as Extract | 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 + 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 + 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 + const id = split.id + s = { ...s, splits: resizeSplit(s.splits, id, 0, 0.2) } + const after = s.splits as Extract + 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 + 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 + 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 + s = resizeSplitIn(s, split.id, 0, 0.1) + const next = s.bottomSplits as Extract + 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 + 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 + 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 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 + g.window = { clearTimeout: () => {}, setTimeout: () => 0, innerWidth: 1024, innerHeight: 800 } + g.localStorage = { getItem: () => null, setItem: () => {} } + }) + afterEach(() => { + const g = globalThis as Record + 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 + let seq = 0 + const timers = new Map 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 }) + }) + }) +}) diff --git a/tests/subagent-activity.spec.ts b/tests/subagent-activity.spec.ts new file mode 100644 index 0000000..fe016de --- /dev/null +++ b/tests/subagent-activity.spec.ts @@ -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): 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: '' }) + }) +}) diff --git a/tests/subagent-detect.spec.ts b/tests/subagent-detect.spec.ts new file mode 100644 index 0000000..202c2c6 --- /dev/null +++ b/tests/subagent-detect.spec.ts @@ -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 = { + 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 = { + 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']) + }) +}) diff --git a/tests/terminal-deps-banner.spec.tsx b/tests/terminal-deps-banner.spec.tsx new file mode 100644 index 0000000..2ab76ad --- /dev/null +++ b/tests/terminal-deps-banner.spec.tsx @@ -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).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() + }) +}) diff --git a/tests/theme.spec.ts b/tests/theme.spec.ts new file mode 100644 index 0000000..0552779 --- /dev/null +++ b/tests/theme.spec.ts @@ -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('') + }) +}) diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts deleted file mode 100644 index 9c88765..0000000 --- a/tests/unit.spec.ts +++ /dev/null @@ -1,1741 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { compareEntries, isWithin, parentOf, rootLabel, requireAbsolute } from '../src/fs-tree.ts' -import { parseLogLines, parsePorcelainZ } from '../src/git.ts' -import { parseUnifiedDiff } from '../src/client/DiffView.tsx' -import { - activateTab, allLeaves, BOTTOM_DEFAULT, BOTTOM_MIN, closeTab, createSidebarStore, defaultWidthFor, insertLeafAt, makeDefaultState, - migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab, openTabInActivePane, patchTab, reconcileAgentTerminals, resizeSplit, resizeSplitIn, sanitizeState, setBottomHeight, splitPane, tabOpenIn, toggleBottomPanel, toggleExpanded, togglePanel, - type SidebarState, type SidebarTab, type SplitNode, -} from '../src/client/state.ts' -import { loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts' -import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts' -import { extOf, languageKeyForExt } from '../src/client/lang.ts' -import { isPdfExt } from '../src/client/pdf-types.ts' -import { isImageExt } from '../src/client/image-types.ts' -import { relativeTo } from '../src/client/paths.ts' -import { producedForClosing, resolveSidebarPath, selectProducedFiles } from '../src/client/produced-files.ts' -import { wrapOpenPath, type OpenPathInterceptDeps, type OpenPathService } from '../src/client/openpath-intercept.ts' -import { registerOpenPathInterception } from '../src/client/intercept.tsx' -import type { Context } from '../src/context-types.ts' -import { defaultShell, ensureSpawnHelper } from '../src/pty-manager.ts' -import { - collectBranchIds, countSubagentDescendants, detectNewDirectSubagent, directSubagentCount, rootAncestor, -} from '../src/client/subagent-detect.ts' -import { contentText, lastActivity } from '../src/client/subagent-activity.ts' -import type { SidebarHistoryEntry, SidebarSessionList, SidebarSubagentCatalog } from '../src/context-types.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 }, - { name: 'A', path: '/x/A', isDir: true, hidden: false }, - { name: 'a.txt', path: '/x/a.txt', isDir: false, hidden: false }, - { name: '.hidden', path: '/x/.hidden', isDir: false, hidden: true }, - ] - expect(rows.sort(compareEntries).map(row => row.name)).toEqual(['A', '.hidden', 'a.txt', 'b.txt']) - }) - - it('derives root labels and parents', () => { - // 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() - // Windows-drive roots and segments, asserted only where win32 semantics apply. - if (process.platform === 'win32') { - 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 absolute paths and rejects relative ones', () => { - // resolve() is platform-native: '/a/b' roots to the current drive on win32. - expect(requireAbsolute('/a/b')).toBe(process.platform === 'win32' ? '\\a\\b' : '/a/b') - if (process.platform === 'win32') { - expect(requireAbsolute('C:/proj')).toBe('C:\\proj') - } - expect(() => requireAbsolute('a/b')).toThrow(/not an absolute path/) - expect(() => requireAbsolute('../a')).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) - }) -}) - -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([]) - }) -}) - -describe('sidebar state', () => { - const state = (): SidebarState => makeDefaultState() - - 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 - 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 - 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 - // paneB 现在是 split(row) [旧leaf, 新leaf(tabId)];其父 split 仍存在。 - const bSplit = after.children.find(child => child.kind === 'split') as Extract | 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 - 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 - 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 - const id = split.id - s = { ...s, splits: resizeSplit(s.splits, id, 0, 0.2) } - const after = s.splits as Extract - 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 - 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 - 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. - expect((s.splits as { tabs: SidebarTab[] }).tabs.map(t => t.type)).toEqual(['explorer']) - 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 - s = resizeSplitIn(s, split.id, 0, 0.1) - const next = s.bottomSplits as Extract - 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 - 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 - 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) - }) -}) - -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) - }) -}) - -describe('pty helpers', () => { - it('falls back from an empty SHELL to a usable shell', () => { - const previous = process.env.SHELL - try { - process.env.SHELL = '' - expect(defaultShell()).toBe('/bin/bash') - delete process.env.SHELL - expect(defaultShell()).toBe('/bin/bash') - } finally { - if (previous === undefined) delete process.env.SHELL - else process.env.SHELL = previous - } - }) - - 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) - }) -}) - -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') - }) -}) - -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 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 explorerTab = allLeaves(s.splits)[0]!.tabs.find(tab => tab.type === 'explorer')!.id - s = closeTab(s, paneA, explorerTab) - 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) - }) -}) - -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') - }) -}) - -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('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) - }) -}) - -describe('side card preferences', () => { - /** 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 }), - }) - - const rejecting = (): SidebarSettingsClient => ({ - settingsGet: async () => { throw new Error('route rejected') }, - settingsUpdate: async () => { throw new Error('route rejected') }, - }) - - 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, - htmlViewerNoSandbox: false, - htmlViewerDefaultUnsafe: false, - browserNoSandbox: false, - browserInterceptLinks: true, - 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: true, - defaultWidthPercent: 33, - autoOpenSubagent: true, - autoOpenJobs: true, - agentTerminalTools: false, - bottomPanelAutoTerminal: true, - terminalFontFamily: '', - terminalFontSize: 13, - interceptOpenPath: true, - htmlViewerNoSandbox: false, - htmlViewerDefaultUnsafe: false, - browserNoSandbox: false, - browserInterceptLinks: true, - 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, - htmlViewerNoSandbox: false, - htmlViewerDefaultUnsafe: false, - browserNoSandbox: false, - browserInterceptLinks: true, - 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('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, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) - store.setSession('fresh-session') - expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) - 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 open. - const openStore = createSidebarStore() - openStore.setSession('another-fresh') - expect(openStore.getSnapshot().state?.panelOpen).toBe(true) - }) - - 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).window - ;(globalThis as Record).window = { - innerWidth: 390, - clearTimeout: () => {}, - setTimeout: (_fn: () => void) => 0, - } - try { - const store = createSidebarStore() - // Default prefs say openByDefault: true — the narrow viewport overrides - // it 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).window - else (globalThis as Record).window = original - } - }) - - it('skips the default explorer tab when the explorer type is disabled', () => { - const store = createSidebarStore() - store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {}, pluginSettings: {} }) - store.setSession('no-explorer') - 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 explorer tab again. - const openStore = createSidebarStore() - openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) - openStore.setSession('with-explorer') - const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs) - expect(openTabs.map(tab => tab.type)).toEqual(['explorer']) - }) - - 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 seedExplorer flag controls the default explorer tab. - expect(makeDefaultState(400, true, false).splits.kind).toBe('leaf') - expect((makeDefaultState(400, true, false).splits as { tabs: unknown[] }).tabs).toHaveLength(0) - }) -}) - -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 = { - 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 = { - 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']) - }) -}) - -describe('subagent activity summary parser', () => { - /** One history entry from raw event fields. */ - const entry = (type: string, data: Record): 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: '' }) - }) -}) - -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 { - this.calls.push(path) - this.opened.push(path) - }, - } - return fake - } - - const deps = (overrides: Partial = {}): 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> = [] - const funnel = { openPath: async (): Promise => {} } - const ctx = { - sessions: { - list: { getSnapshot: () => ({ current: 's1', byId: { s1: { cwd: '/w' } } }) }, - }, - workspaces: funnel, - betterSidebar: { openTab: (seed: unknown) => { opened.push(seed as Record) } }, - } 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) - }) -}) - -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 - g.window = { clearTimeout: () => {}, setTimeout: () => 0, innerWidth: 1024, innerHeight: 800 } - g.localStorage = { getItem: () => null, setItem: () => {} } - }) - afterEach(() => { - const g = globalThis as Record - 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 - let seq = 0 - const timers = new Map 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 }) - }) -}) -}) diff --git a/tsdown.config.ts b/tsdown.config.ts index d5f33d5..3163253 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -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) diff --git a/vitest.config.ts b/vitest.config.ts index ee4cf96..7f1c62e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -19,5 +19,18 @@ export default defineConfig({ inline: [/@deepseek-ai\/dsh-client-ui-primitives/], }, }, + // The Playwright headless-render lane lives in tests/e2e (specs named + // *.e2e.ts). Keep vitest from ever collecting it, both by naming (the + // default include only matches *.test.* / *.spec.*) and by an explicit + // exclude. NOTE: `exclude` REPLACES vitest's defaults, so the standard + // node_modules/dist/etc. excludes must be restated here. + exclude: [ + 'tests/e2e/**', + '**/node_modules/**', + '**/dist/**', + '**/cypress/**', + '**/.{idea,git,cache,output,temp}/**', + '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*', + ], }, })