diff --git a/AGENTS.md b/AGENTS.md index b9dce87..272741e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ **保留在插件侧的三件**:`md/markdown`(自带渲染器,用户认为优于内置)、`html/htm`(自带沙箱预览 + `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe`,宿主没有这两个开关)、以及 `code` catch-all(**它是可编辑的 CodeMirror + 保存**,内置那几个是只读预览)。未知二进制(`.zip`/`.wasm`)仍走 `code` 认领 → 宿主 fsRead 判 binary → head 重匹配无 sniffer → `EditorHost.tsx` 的 `load.status === 'binary'` 兜底渲染下载面板,**功能不回归**。 6. **宿主 `browser` kind 在 Web profile 不再挂载**:`packages/bundle/web-app/cordis.patch.yml` 给 `ui-sidebar-browser` 加了 `disabled: !!js "ctx.get('profileContext')?.name !== 'desktop'"`。因此 e2e 的 `HOST_OWNED_TABS` 只剩 `['terminal']`,并且**显式断言 browser 条目缺席**(钉住新真值,而不是默默放宽)。DSH 同时新增用户设置 `linkOpening` 决定正文链接进侧栏还是新标签页。 7. **外链接管收敛**:删掉 `browserInterceptLinks` / `browserInterceptHttp` / `browserInterceptHttps` 三个偏好键(含 20 份词典的 key)。新行为是**只认领声明了 `urlTarget` 的链接**,其余一律不 `preventDefault`、放行给宿主;认领成功但目标类型此刻不可用时 `window.open(url, '_blank', 'noopener,noreferrer')` 兜底。**修掉了一个真实回归**:0.1.6 那版把浏览器交还宿主时,`openInSidebar` 仍兜底到 `'browser'`,而插件注册表里已无该类型、`service.openTab` 对未注册类型静默 `return`——默认配置下插件自绘 markdown(sidechat 转录 / 编辑器预览 / diff)里的 http 链接**点了没反应**。 -8. **文件树新增实时刷新**:插件接管了 `files` kind,所以宿主自己的按目录 watch 覆盖不到它。新增 `src/fs-watch.ts`(宿主侧,每条连接一组 `fs.watch`,150ms 去抖,上限 64 个句柄)+ `src/client/use-dir-watch.ts`(一条 `/sidebar/ws/fs-watch` socket 携带展开目录集,目录折叠即退订),客户端收到通知后只丢弃并重列那一层缓存。路径解析沿用 `fs.tree` 的同一道 workspace fence。 +8. **文件树新增实时刷新**:插件接管了 `files` kind,所以宿主自己的按目录 watch 覆盖不到它。新增 `src/fs-watch.ts`(宿主侧,每条连接一组 `fs.watch`,150ms 去抖,上限 64 个句柄)+ `src/client/use-dir-watch.ts`(一条 `/sidebar/ws/fs-watch` socket 携带展开目录集,目录折叠即退订),客户端收到通知后只丢弃并重列那一层缓存。路径解析沿用 `fs.tree` 的同一套词法解析(v0.23.0 起无工作区包含检查)。 ### 3.3 既有缺陷(不是 0.1.7 引入,但这次一并修) diff --git a/README.md b/README.md index 6640210..d5bc532 100644 --- a/README.md +++ b/README.md @@ -214,7 +214,7 @@ dsh registry enable dsh-external/dsh-better-sidebar - 🗂️ **只读文件预览整体让给 DSH 的文档预览**:DSH 0.1.7 的 `ui-sidebar-documentpreview` 自带表格 / PDF / 图片 / Office 渲染(宿主侧 Office→PDF 转换、电子表格 worker 表格、图片 / PDF 缩放、按目录自动刷新),所以插件删掉了自己的 `image` / `pdf` / `binary-download` 三个 viewer,并在 `editor.canOpen` 里**拒绝认领**这些扩展名——`xlsx xls csv tsv fods pdf png jpg jpeg gif webp svg bmp ico doc docx ppt pptx`——把文件地址交回宿主。**rc.1 收回其中 9 个**:`xlsb` / `xlt` / `xltx` / `xltm` / `ots` / `dot` / `dotx` / `avif` / `ods` 宿主其实**没有渲染器**(点开只有「暂不支持预览」),而它们在让出之前是走插件兜底显示下载面板的,属于我们上一版自己引入的回归;现由插件的 `code` catch-all 重新认领。`fods` 继续让出(宿主会用纯文本显示这段扁平 XML,比下载面板有用)。**插件仍保留三件宿主没有的**:Markdown(自带渲染器)、HTML(自带沙箱预览 + `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` 两个安全开关)、以及**可编辑**的文本 / 代码编辑器(内置那几个是只读预览);未知二进制(`.zip` / `.wasm`)仍走代码编辑器判 binary 后的下载面板,功能不回归。 - 🔗 **外链接管收敛**:删掉按协议分流的三个外链接管设置项(20 份词典的相关词条一并删除)。现在插件**只认领有 tab 类型通过 `urlTarget` 明确声明认领的链接**,其余一律放行、由宿主决定(DSH 0.1.7 新增用户设置 `linkOpening`,决定正文链接进侧栏还是新标签页);**一个都没认领到时不阻止默认行为**;认领成功但目标类型在打开那一刻已不可用(插件卸载 / 被关)时兜底 `window.open(url, '_blank', 'noopener,noreferrer')`——顺手修掉了上一版留下的真实回归:插件自绘 markdown(侧边对话转录 / 编辑器预览 / diff 面板)里的 http 链接点了没反应。另外宿主的 `browser` kind **在 Web profile 已不再挂载**(0.1.7 只在 desktop profile 挂载它)。 - ⚙️ **设置接入面重写 + 用户偏好的自动回迁**:DSH 0.1.7 删除了插件可注册的设置命名空间,改为**按插件 Loader 行的 entry id 找表单**(`SettingsForms`:只剩 `describe` / `update` / `replace` / `mutate` / `configure`)。插件偏好因此落在 **profile 的 cordis patch 文档**里(即本插件的挂载行),不再是 `~/.dsh/settings.yaml`;schema 来自插件模块导出的 `Config`(本版把用户偏好并进 `Config`,并给每个偏好字段标 `meta.volatile = true`——**这一个标记就是「改设置实时生效、不重挂插件」的全部机制**)。**用户设置不会丢**:插件首次启动时会把旧 `settings.yaml` / `settings.yaml.imported` 里的 `dsh-better-sidebar` 段一次性回迁(只在该行还没有任何用户值时执行,且只迁移当前 schema 仍声明的字段)。entry id 是**运行时自发现**的(本包默认 `better-sidebar`,聚合包挂载时会是别的 id),不硬编码。 -- 🔄 **文件树实时刷新**:插件接管了内置「文件」页,宿主自己的按目录 watch 覆盖不到它——本版新增 `/sidebar/ws/fs-watch`:客户端上报**已展开**的目录,宿主按目录 `fs.watch`(150ms 去抖、每连接 64 个句柄上限、路径仍走 `fs.tree` 同一道 workspace fence),改动后只重列那一层、折叠即退订。此前文件树会一直陈旧到手动刷新。 +- 🔄 **文件树实时刷新**:插件接管了内置「文件」页,宿主自己的按目录 watch 覆盖不到它——本版新增 `/sidebar/ws/fs-watch`:客户端上报**已展开**的目录,宿主按目录 `fs.watch`(150ms 去抖、每连接 64 个句柄上限、路径仍走 `fs.tree` 同一套词法解析),改动后只重列那一层、折叠即退订。此前文件树会一直陈旧到手动刷新。 - 🐛 **会话跟随修好了**:插件此前读的是一个**不存在的 `SessionListState.current` 字段**(插件的类型镜像自己造了它,编译期一直放行),导致「按会话持久化」实际没绑上、窄屏 park 门控恒假。现在改用 DSH 0.1.7 的 `ctx.sidebarRight.mounted`(只在该列真正换成另一个会话时才变化)。 - 🖥️ **模型侧代价不变**:插件原有的 8 个 `terminal_*` 工具(默认关)已在上一版删除,上游等价物 `@deepseek-ai/dsh-tool-terminal` **仍未被任何 shipped bundle 默认挂载**,需要持久终端时请在 profile 的 `cordis.patch.yml` 里自行插入一行 `tool-terminal`(否则模型只有一次性 `bash` / `pwsh`)。 - 📐 **基线**:`@deepseek-ai/dsh-*` 全部钉 `0.1.7-rc.1`,`@deepseek-ai/cordis` peer 下限 `^4.0.3`;`ui-primitives` 图标具名导出整族改名(`Icon<14|16>` → `IconRegular` / `Medium`,26 个具名导入随之适配);会话格式 v3→v4(sidechat 边界注入改用 `plugin:dsh-better-sidebar`,tool 结果消息改 `role: 'tool'` 顶层形状,解析器同时接受新旧两种形状以兼容历史日志)。 @@ -270,7 +270,8 @@ make clean # 清理 lib/、*.tgz、playwright-report/、test-results/ ## 🔐 安全 -- 路由受 Host 头信任围栏保护(与 `/api` 一致);`fs.write` 原子写入;媒体/预览路由仅限会话 cwd 内文件(`workspaceFence` 关掉时除外,见设置页);git 只调 CLI、绝不设置身份 +- 路由受 Host 头信任围栏保护(与 `/api` 一致);`fs.write` 原子写入;git 只调 CLI、绝不设置身份 +- ⚠️ **v0.23.0 起文件系统路由不再做工作区包含检查**:`fs.tree` / `fs.trees` / `fs.read` / `fs.write` / `fs.rename` / `fs.remove` / `fs.mkdir` / 媒体 / HTML 预览 / `/sidebar/upload` / `archive.build` 能读写**宿主用户可访问的任意路径**(只受 OS 权限约束,`workspaceFence` 开关与 403 分支已删除)——调用方不能再把这些路由当作被围栏保护的接口 - HTML 预览的内容在**不透明源沙箱 iframe** 中渲染(无 `allow-same-origin`/`allow-top-navigation`、`no-referrer`、权限策略全禁);`/sidebar/html` 路由带 CSP `sandbox` + 大小/路径边界 - 设置页可按功能关闭 HTML 预览的沙箱(`htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe`,默认关闭,带警告文案)——关闭后内容与界面同源,仅建议对完全可信内容使用。**网页 tab 的沙箱不再是插件的面**:浏览器视图由宿主提供(desktop profile),其沙箱与导航策略见 DSH 侧文档 diff --git a/README_EN.md b/README_EN.md index 0f797d3..a30e909 100644 --- a/README_EN.md +++ b/README_EN.md @@ -223,7 +223,7 @@ WeChat / QQ group QR codes will live here. After uploading the QR images (drag t - 🗂️ **Read-only file previews handed to DSH's document preview**: DSH 0.1.7's `ui-sidebar-documentpreview` ships its own spreadsheet / PDF / image / Office rendering (host-side Office→PDF conversion, worker-backed spreadsheet tables, image / PDF zoom, per-directory auto-refresh), so the plugin deleted its `image` / `pdf` / `binary-download` viewers and **refuses** those extensions in `editor.canOpen` — `xlsx xls csv tsv fods pdf png jpg jpeg gif webp svg bmp ico doc docx ppt pptx` — handing the address back to the host. **rc.1 takes nine of them back**: `xlsb` / `xlt` / `xltx` / `xltm` / `ots` / `dot` / `dotx` / `avif` / `ods` have **no host renderer at all** (opening one only said "preview is not available"), yet before the handover they reached the plugin's download pane — a regression we introduced ourselves in the previous version. The plugin's `code` catch-all claims them again. `fods` stays handed over (the host shows that flat XML as plain text, which beats a download pane). — handing the file address back to the host. **Three things the host does not have stay in the plugin**: Markdown (its own renderer), HTML (its own sandboxed preview plus the `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` safety switches), and the **editable** text / code editor (the built-in ones are read-only previews); unknown binaries (`.zip` / `.wasm`) still land on the code editor's download pane after the binary check, so nothing regresses. - 🔗 **External-link takeover narrowed**: the three protocol-routing external-link settings are gone (with their keys in all 20 locale dictionaries). The plugin now takes over **only links a tab type explicitly claims through `urlTarget`** and lets everything else through for the host to route (DSH 0.1.7 adds the user setting `linkOpening`, deciding whether prose links open in the sidebar or a new tab); **when nothing claims a link it does not preventDefault**; a successful claim whose target type is unavailable at open time falls back to `window.open(url, '_blank', 'noopener,noreferrer')` — which also fixes a real regression from the previous version: http links inside plugin-drawn markdown (Side Chat transcripts / editor previews / diff panes) did nothing when clicked. Separately, the host's `browser` kind is **no longer mounted in the Web profile** (0.1.7 mounts it in the desktop profile only). - ⚙️ **Settings surface rewritten + preferences imported automatically**: DSH 0.1.7 removed the registrable settings namespace in favour of **looking a form up by the plugin Loader row's entry id** (`SettingsForms`: only `describe` / `update` / `replace` / `mutate` / `configure` remain). Plugin preferences therefore live in the **profile's cordis patch document** (i.e. this plugin's mount row), not in `~/.dsh/settings.yaml`; the schema comes from the plugin module's exported `Config` (this version merges the user preferences into `Config` and marks every preference field `meta.volatile = true` — **that single flag is the entire "settings apply live, without remounting the plugin" mechanism**). **Your settings are not lost**: on first boot the plugin imports the `dsh-better-sidebar` section of the old `settings.yaml` / `settings.yaml.imported` once (only while that row still has no user values, and only fields the current schema still declares). The entry id is **discovered at runtime** (this bundle defaults to `better-sidebar`; an aggregate bundle mounts it under a different id) and never hardcoded. -- 🔄 **Live-refreshing file tree**: the plugin takes over the built-in Files page, so the host's own per-directory watch cannot cover that tree — this version adds `/sidebar/ws/fs-watch`: the client reports the directories it has **expanded**, the host watches exactly those with `fs.watch` (150ms debounce, a 64-handle cap per connection, paths going through the same workspace fence as `fs.tree`), and a change re-lists just that level; collapsing unsubscribes. Before this, the tree stayed stale until a manual refresh. +- 🔄 **Live-refreshing file tree**: the plugin takes over the built-in Files page, so the host's own per-directory watch cannot cover that tree — this version adds `/sidebar/ws/fs-watch`: the client reports the directories it has **expanded**, the host watches exactly those with `fs.watch` (150ms debounce, a 64-handle cap per connection, paths resolved by the same lexical rules as `fs.tree`), and a change re-lists just that level; collapsing unsubscribes. Before this, the tree stayed stale until a manual refresh. - 🐛 **Session following fixed**: the plugin used to read a **non-existent `SessionListState.current` field** (its own type mirror invented it, so the compiler never complained), which meant per-session persistence was never actually bound and the narrow-viewport park gate was always false. It now uses DSH 0.1.7's `ctx.sidebarRight.mounted` (set only when the column really switches to another session). - 🖥️ **The model-side cost is unchanged**: the plugin's own 8 `terminal_*` tools (off by default) were already removed in the previous version, and the upstream equivalent `@deepseek-ai/dsh-tool-terminal` is **still not mounted by any shipped bundle** — add a `tool-terminal` row to your profile's `cordis.patch.yml` when you need a persistent terminal (otherwise the model only has one-shot `bash` / `pwsh`). - 📐 **Baseline**: every `@deepseek-ai/dsh-*` pins `0.1.7-rc.1`, with the `@deepseek-ai/cordis` peer floor at `^4.0.3`; `ui-primitives` renamed its whole family of named icon exports (`Icon<14|16>` → `IconRegular` / `Medium`, 26 named imports adapted); session format v3→v4 (the Side Chat boundary injection now uses `plugin:dsh-better-sidebar`, and tool-result messages use the top-level `role: 'tool'` shape, with parsers accepting both old and new shapes for historical logs). @@ -279,7 +279,8 @@ make clean # remove lib/, *.tgz, playwright-report/, test-results/ ## 🔐 Security -- Routes protected by a Host-header trust fence (same as `/api`); `fs.write` is atomic; media/preview routes only serve files inside the session cwd (unless `workspaceFence` is turned off in settings); git only shells out to the CLI and never sets identity +- Routes protected by a Host-header trust fence (same as `/api`); `fs.write` is atomic; git only shells out to the CLI and never sets identity +- ⚠️ **Since v0.23.0 the filesystem routes do NO workspace containment**: `fs.tree` / `fs.trees` / `fs.read` / `fs.write` / `fs.rename` / `fs.remove` / `fs.mkdir` / media / HTML preview / `/sidebar/upload` / `archive.build` reach **any path the host user can reach** (OS permissions are the only bound; the `workspaceFence` switch and the 403 branch are gone) — do not treat these routes as fenced - HTML preview content renders in an **opaque-origin sandboxed iframe** (no `allow-same-origin`/`allow-top-navigation`, `no-referrer`, all permission policies disabled); the `/sidebar/html` route carries a CSP `sandbox` + size/path bounds - The settings page can disable the HTML preview's sandbox per feature (`htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe`, off by default, with a warning) — when off, content shares the origin with the UI; only recommended for fully trusted content. **The web tab's sandbox is no longer this plugin's surface**: the browser view comes from the host (desktop profile); see DSH's own docs for its sandbox and navigation policy diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md index 6fa24ee..4b03ed2 100644 --- a/docs/external-plugin-guide.md +++ b/docs/external-plugin-guide.md @@ -25,7 +25,7 @@ | 布局持久化 | 原生栏的布局**只在内存**(刷新后回到折叠默认),插件自己的底部工作台仍然持久化 | | 跨会话打开 | 目标会话的右侧栏 store 未挂载时,打开会排队到该会话上屏后重放 | | 内置类型接管 | 插件的 `editor` 类型以 `extension` 优先级认领 `dsh-resource://file/**`(压过内置 `ui-sidebar-documentpreview` 的 `text` 预览——即 `fallback` 带),并接管内置 `files` 页面 kind(`openTab('files')` 打开插件的文件树);插件卸载/禁用时内置实现自动复位。**但认领是有选择的**:宿主自己的文档预览已经覆盖的格式(表格 / PDF / 图片 / Office,清单见 §5.4)由 `editor.canOpen` 主动**拒绝**,地址交回内置 `text` 档 | -| 文件树实时刷新(v0.21.1+) | 插件接管了内置 `files` 页,所以宿主自己的按目录 watch 覆盖不到这棵树——插件自带一条 `/sidebar/ws/fs-watch` socket:客户端上报**已展开**的目录集,宿主侧按目录 `fs.watch`(150ms 去抖、每连接上限 64 个句柄),变动后只让那一层缓存失效并重列;目录折叠即退订。路径仍走 `fs.tree` 同一道 workspace fence | +| 文件树实时刷新(v0.21.1+) | 插件接管了内置 `files` 页,所以宿主自己的按目录 watch 覆盖不到这棵树——插件自带一条 `/sidebar/ws/fs-watch` socket:客户端上报**已展开**的目录集,宿主侧按目录 `fs.watch`(150ms 去抖、每连接上限 64 个句柄),变动后只让那一层缓存失效并重列;目录折叠即退订。路径仍走 `fs.tree` 同一套词法解析 | | 链接接管(v0.21.1+) | DOM 层只接管**有类型通过 `urlTarget` 声明认领**的外链(Ctrl/Cmd/Shift/Alt 点击一律放行);一个都没认领到时**不阻止默认行为**,交回宿主。见 §4.1 的 `urlTarget` | | path 种子的去向(v0.19.2+) | `path` seed 的含义**跟随类型**:只有 `editor`(唯一认领 `dsh-resource://file/**` 的类型)把 path 转成资源地址打开(文件落在编辑器);**其余类型保留页面型打开**,path 随导航 params 落到合成记录的 `tab.path` 供组件消费——组件型 tab 的 path seed 不会被改道到文件编辑器(v0.19.0/0.19.1 上一切 path seed 都被改道,组件从未挂载,#632) | | 终端(已交还宿主) | 插件**不再提供任何终端**:宿主 0.1.6 起自带 `ui-sidebar-terminal`(kind `terminal`),插件侧 PTY 栈与 `terminal_*` 工具整体删除。这里不再有「插件终端数量上限」这类语义 | @@ -586,17 +586,18 @@ const { value } = await res.json() // 错误时 { ok: false, error: { code, me |---|---| | `session.cwd` | 会话权威 cwd(`{ cwd, root, parent }`) | | `fs.tree` | 目录列表(`{ path, entries: FsEntry[], truncated }`;FsEntry 含 `isSymlink`/`broken`,目录软链接的 `isDir` 按目标类型) | +| `fs.trees` | **批量**目录列表(v0.23.0+):入参 `{ sessionId, cwd?, paths: string[] }`(最多 64 条,绝对或会话相对),一次请求返回 `{ levels: [{ path, entries, truncated, error? }] }`。每层与 `fs.tree` 同解析、同缓存;**某一层失败只在该层带 `error`**(`entries: []`),整个批次仍是成功响应——树的一次挂载/刷新因此是 1 个请求而不是 N 个 | | `fs.read` | 读文件:文本返回 `{ kind: 'text', content, truncated }`;二进制返回 `{ kind: 'binary', size, truncated, head }`(head = base64 前 4KB) | | `fs.write` | 原子写文件 | | `git.status` / `git.diff` / `git.log` 等 | 全套 Git 只读 + 写操作 | | `pty.close` / `agent-pty.close` | **已删除**(插件自带的 PTY 栈随终端一起移除;宿主 `ui-sidebar-terminal` 不通过本插件的路由暴露控制面) | | `settings.get` / `settings.update` | 侧边栏偏好读写(revision 守卫,冲突回 wire 错误码 `settings-conflict`);后端在 0.1.7 上就是宿主的 `SettingsForms`,见 §8.2 | -> **文件路径安全边界**:`fs.tree`、`fs.read`、`fs.write`、`fs.rename`、`fs.remove`、`fs.mkdir`、`/sidebar/file`、`/sidebar/html`、`/sidebar/upload` 和 `archive.build` 都以请求对应 session 的权威 `cwd` 作为 workspace 根目录。路径会按真实文件系统路径检查,越界绝对路径、`..` 解析结果和指向 workspace 外部的符号链接都会被拒绝;消费插件不应把 `cwd` 当作可由用户扩大权限范围的参数。 +> **文件路径语义(v0.23.0 起:无包含检查)**:`fs.tree`、`fs.trees`、`fs.read`、`fs.write`、`fs.rename`、`fs.remove`、`fs.mkdir`、`/sidebar/file`、`/sidebar/html`、`/sidebar/upload` 和 `archive.build` 仍以请求对应 session 的权威 `cwd` 作为**相对路径的基准**,但**不再把 `cwd` 当边界**:绝对路径原样使用,`..` 只做词法折叠,符号链接跟随,越界不再是 403。安全影响见 §8.1 的醒目声明。 ### 6.1 打包下载(`archive.build` / `archive.status` / `/sidebar/archive`,v0.22.0+) -把若干文件/目录打成 ZIP 下载,并且**有进度可看**:选择项在 `archive.build` 里一次性收集(同一个 workspace fence,目录递归、符号链接跳过、同名条目用父目录消歧),打包在后台进行,客户端轮询进度、完成后取字节。 +把若干文件/目录打成 ZIP 下载,并且**有进度可看**:选择项在 `archive.build` 里一次性收集(与 `fs.tree` 同一套词法解析——**无包含检查**,目录递归、符号链接跳过、同名条目用父目录消歧),打包在后台进行,客户端轮询进度、完成后取字节。 ```ts // 1) 启动:paths 是会话命名空间里的绝对路径(与 fs.tree 的行 path 同形) @@ -871,7 +872,7 @@ ctx.effect(() => - 展示:小卡片网格(图标 + 标题 + 类型 id),**高亮 = 启用**,勾选徽标钉在卡片最右端;viewer 卡片额外显示扩展名。 - 持久化:开关写入 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,**缺省 = 启用**,显式 `false` 才禁用)。 - 关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开(`console.warn`)、派生流程(子代理自动展开、agent 终端自动补 tab)停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。 -- `settings.toggles`(可选):在卡片行下追加**嵌套设置行**(仅父级启用时显示),绑定 `SidebarPrefs` 字段;通过卡片底部「功能设置」条在原生弹窗中编辑。行控件形状见 §4.1 的 `SettingRow`:`type: 'switch' | 'text' | 'number'`(v0.11.0+;text/number 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀)与 `type: 'select'`(v0.13.0+;`options` 支持 value/title/desc/icon,`multi` 多选存数组并按 options 顺序提交;任一项带 icon 时渲染大图标选项卡)。内置示例:subagent tab 的 `autoOpenSubagent`、editor tab 的 `editorExplorer` 图标化下拉与 `workspaceFence` 开关(工作区路径围栏,见 §8.1)。 +- `settings.toggles`(可选):在卡片行下追加**嵌套设置行**(仅父级启用时显示),绑定 `SidebarPrefs` 字段;通过卡片底部「功能设置」条在原生弹窗中编辑。行控件形状见 §4.1 的 `SettingRow`:`type: 'switch' | 'text' | 'number'`(v0.11.0+;text/number 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀)与 `type: 'select'`(v0.13.0+;`options` 支持 value/title/desc/icon,`multi` 多选存数组并按 options 顺序提交;任一项带 icon 时渲染大图标选项卡)。内置示例:subagent tab 的 `autoOpenSubagent`、editor tab 的 `editorExplorer` 图标化下拉(工作区路径围栏与它的开关已移除,见 §8.1)。 - `settings.pluginToggles`(可选,v0.12.0+):**插件自有设置行**,行控件与 toggles 相同,但 key 是插件局部的——持久化在 prefs 文档的 `pluginSettings[]`(开放 map,无需宿主 schema 字段)。tab 与 viewer 都可用(v0.12.0 起 viewer 卡片也有设置条)。 - `settings.render`(可选,v0.12.0+):**自定义设置面板**——追加渲染在行列表之后,可单独存在。props 含 store/service/prefs、本 descriptor 的 `pluginSettings` blob、`updatePluginSetting(key, value)` 与 `close()`;抛错会被吞掉并显示内联错误。 @@ -911,7 +912,7 @@ ctx.effect(() => ) ``` -> ⚠️ **`toggles` 的 key 必须是本插件 `PrefsSchema` 的字段**(`PrefsSchema` 已并入本插件 Loader 行的 `Config`;内置键:`autoOpenSubagent` / `autoOpenJobs` / `tasksViewMode` / `mobileNoAutoOpen` / `mobileDefaultTree` / `agentOpenTools` / `editorExplorer` / `workspaceFence` / `titleBarScheme` / `titleBarPresetId` / `customCss` / `titleBarCompat` / `titleBarStripPx` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `tabsEnabled` / `viewersEnabled` / `pluginSettings`;**已删除**:`agentTerminalTools` / `terminalShell` / `terminalShellArgs` / `terminalFontFamily` / `terminalFontSize` / `bottomPanelAutoTerminal` / `browserNoSandbox` / `browserAllowedLoopback` / 三个按协议分流的旧外链接管键)。**v0.12.0 起设置 seam 已开放**:你自己的设置走 `pluginToggles`(声明式行)或 `render`(自定义面板),值持久化在 `pluginSettings[id]`——不再需要本插件的 schema 字段,也不再被 seam 丢弃。值须 JSON 可序列化(行控件只产出 string/number/boolean;自定义面板自行负责)。 +> ⚠️ **`toggles` 的 key 必须是本插件 `PrefsSchema` 的字段**(`PrefsSchema` 已并入本插件 Loader 行的 `Config`;内置键:`autoOpenSubagent` / `autoOpenJobs` / `tasksViewMode` / `mobileNoAutoOpen` / `mobileDefaultTree` / `agentOpenTools` / `editorExplorer` / `titleBarScheme` / `titleBarPresetId` / `customCss` / `titleBarCompat` / `titleBarStripPx` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `tabsEnabled` / `viewersEnabled` / `pluginSettings`;**已删除**:`workspaceFence`(v0.23.0,围栏整体移除,见 §8.1)/ `agentTerminalTools` / `terminalShell` / `terminalShellArgs` / `terminalFontFamily` / `terminalFontSize` / `bottomPanelAutoTerminal` / `browserNoSandbox` / `browserAllowedLoopback` / 三个按协议分流的旧外链接管键)。**v0.12.0 起设置 seam 已开放**:你自己的设置走 `pluginToggles`(声明式行)或 `render`(自定义面板),值持久化在 `pluginSettings[id]`——不再需要本插件的 schema 字段,也不再被 seam 丢弃。值须 JSON 可序列化(行控件只产出 string/number/boolean;自定义面板自行负责)。 ### 8.2 宿主设置表单(DSH 0.1.7+:`SettingsForms`) @@ -955,12 +956,17 @@ interface SettingsDescriptor { 4. **`settings/document-updated` 你监听不到,别写**:事件签名是 `'settings/document-updated'(ns, revision)`(语义是「该条目的表单值 / 可用性 / 页面策略变了,表单客户端重新读取 schema、解析值与 revision」),但它在 **settings 服务自己的 context 上 `emit`**,而 cordis 事件只向该 ctx 的**祖先**冒泡——插件 fiber 是兄弟,`ctx.on(...)` 永远不会触发。**替代做法:每次重新读取表单时重新求值**(本插件的 `settingsFace.get()` 在返回当前值前跑一遍门控同步;客户端侧另经 `remote` 服务的 `$on('settings/document-updated')` 镜像触发重新拉取)。**旧文档里的 `settings/updated` 事件在 0.1.7 已不存在**。 -### 8.1 内置键 `workspaceFence`(工作区路径围栏) +### 8.1 工作区包含检查已移除(v0.23.0,权限放开) -所有侧栏文件系统路由(`fs.tree` / `fs.read` / `fs.write` / `/sidebar/file` 媒体 / `/sidebar/html` 预览 / `/sidebar/upload`)默认强制**工作区包含检查**:客户端提供的路径经 realpath 解析后必须落在会话工作区内,否则 403(wire 错误码 `forbidden`,消息 `path "..." is outside workspace`)。`workspaceFence: false`(默认 `true`)解除该包含检查——路径仍会解析符号链接得到 canonical 路径,但不再拒绝工作区外的目标(如全局 `~/.dsh/AGENTS.md`、会话 cwd 之外的 linked worktree)。 +**`workspaceFence` 这个键已经不存在了,也没有替代开关。** 侧栏的文件系统路由(`fs.tree` / `fs.trees` / `fs.read` / `fs.write` / `fs.rename` / `fs.remove` / `fs.mkdir` / `/sidebar/file` 媒体 / `/sidebar/html` 预览 / `/sidebar/upload` / `archive.build`)**不再做任何工作区包含检查**:路径只做词法解析(会话相对路径拼到会话 cwd 下、`resolve()` 折叠 `..`),不再 realpath、不再比对前缀、**不再有 403 `forbidden` 分支**。符号链接被照常跟随。 -- 开关位置:设置页「文件」卡片 →「功能设置」二级弹窗;编辑器加载失败与文件树列目录失败两个错误面在触发围栏拒绝时会显示原因 + 一键全局关闭按钮(`FenceErrorNotice`,写 `workspaceFence: false` 后自动重试失败的操作)。 -- ⚠️ **安全代价**:关闭期间,页面内任意同源脚本(**包括第三方消费插件**)都能通过上述路由读写主机上任意文件——开关文案已明示该风险,用完建议重新打开。 +> ## ⚠️ 安全声明(必读) +> +> 这是一次**权限放开**:插件侧边栏的 fs 路由现在能读写**宿主用户能访问的任意路径**,只受 OS 权限约束。也就是说,一个能访问到本插件 `/sidebar/*` 路由的同源页面/脚本(**包括你写的第三方消费插件**)可以借这些路由读取、改写、删除该用户权限内的任何文件——例如 `~/.ssh/`、`~/.dsh/`、其他项目的源码。 +> +> 缓解手段只有两块,别指望有第三块:① 每条路由都过同一个**浏览器信任栅栏**(Host 头 loopback / `trustedHosts`,见 §0 与 §10),所以前提是攻击面已经能发同源请求;② 宿主用户的 OS 权限本身的边界。**不要**再把「工作区围栏」当成安全边界来设计你的插件——它已经不在了。 + +历史背景(`< v0.23.0`):旧的实现用 realpath 解析后比对工作区前缀,越界回 403,并有一个 `workspaceFence` 开关(默认开)可临时关闭;开启时编辑器/文件树错误面会显示原因 + 一键关闭按钮(`FenceErrorNotice`)。用户要求删除该检查,上述代码路径随之删除(`src/path-security.ts` 现在是纯词法解析)。旧 profile 里遗留的 `workspaceFence` 键只是**未知键**——schemastery 的 object schema 对未知键是容忍的,既不报错也不再有任何效果。 --- diff --git a/dsh.plugin.json b/dsh.plugin.json index d2367db..6fe3075 100644 --- a/dsh.plugin.json +++ b/dsh.plugin.json @@ -1,6 +1,6 @@ { "id": "dsh-external/dsh-better-sidebar", - "version": "0.22.0", + "version": "0.23.0", "main": "./lib/index.js", "description": "更好用的右侧边栏——文件树、编辑器、文件变动、任务和侧边对话都收在这一栏里,每个会话互不干扰。暴露服务供其他插件注册侧边栏页面与文件预览器", "engines": { diff --git a/src/client/EditorHost.tsx b/src/client/EditorHost.tsx index 763eea9..bc3e563 100644 --- a/src/client/EditorHost.tsx +++ b/src/client/EditorHost.tsx @@ -27,9 +27,8 @@ import { createElement } from 'react' import clsx from 'clsx' import { IconCheckOutlineRegular, IconFolderOpenRegular, IconRefreshOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context } from '../context-types.ts' -import { api, isOutsideWorkspaceMessage, mediaUrl, type SessionScope } from './api.ts' +import { api, mediaUrl, type SessionScope } from './api.ts' import { BinaryDownload } from './binary-download.tsx' -import { FenceErrorNotice } from './FenceErrorNotice.tsx' import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './editor-load.ts' import { baseName } from './FileTree.tsx' import { createFrameBatcher } from './frame-batcher.ts' @@ -417,7 +416,6 @@ export function EditorHost(props: { {showEmpty &&
{t('editorEmptyHint')}
} {!showEmpty && load.status === 'loading' &&
{t('loading')}
} - {!showEmpty && load.status === 'error' && (isOutsideWorkspaceMessage(load.message) - ? { setReloadSeq(sequence => sequence + 1) }} /> - :
{load.message}
)} + {!showEmpty && load.status === 'error' &&
{load.message}
} {!showEmpty && load.status === 'binary' && } {!showEmpty && load.status === 'ready' && createElement(load.viewer.component, { ctx, store, scope, path, title, @@ -545,7 +541,6 @@ export function EditorHost(props: { /> void }) { - const { store, onDisabled } = props - const [busy, setBusy] = useState(false) - const disable = (): void => { - if (busy) return - setBusy(true) - api.settingsUpdate({ workspaceFence: false }).then((view) => { - store.setPrefs(parsePrefs(view.value)) - onDisabled() - }).catch((error: unknown) => { - // The fence stays armed on failure (the notice remains, button - // re-enabled); the console carries the cause. - console.error('workspace fence disable failed', error) - setBusy(false) - }) - } - return ( -
- {t('fenceErrorReason')} - -
- ) -} diff --git a/src/client/FileTree.tsx b/src/client/FileTree.tsx index d046c28..d6117f6 100644 --- a/src/client/FileTree.tsx +++ b/src/client/FileTree.tsx @@ -49,11 +49,7 @@ import { } from '@deepseek-ai/dsh-client-ui-primitives' import { SiCursor, SiZedindustries } from 'react-icons/si' import { VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc' -import { - api, archiveBuild, archiveDownloadUrl, archiveStatus, downloadUrl, isOutsideWorkspaceMessage, - type FsEntry, -} from './api.ts' -import { FenceErrorNotice } from './FenceErrorNotice.tsx' +import { api, archiveBuild, archiveDownloadUrl, archiveStatus, downloadUrl, type FsEntry } from './api.ts' import { builtinFileIcon, builtinFolderIcon } from './file-icons.tsx' import { IconUploadOutline16, IconVscode16 } from './icons.tsx' import { isImeComposition } from './ime-guard.ts' @@ -63,7 +59,6 @@ import type { OpenWithTarget } from './open-with.ts' import { relativeTo } from './paths.ts' import { t } from './locales.ts' import type { BetterSidebarService } from './service.ts' -import type { SidebarStore } from './state.ts' import { Chip, ConfirmDialog, IconButton, Notice, StatusBadge, useGitStatus, type GitTone, type StatusTone, @@ -406,8 +401,6 @@ const RootRow = memo(function RootRow(props: RootRowProps): ReactNode { export function FileTree(props: { sessionId: string cwd: string | undefined - /** The sidebar store: the fence-refusal notice writes the `workspaceFence` pref through it. */ - store: SidebarStore expanded: string[] /** Files highlighted by a "Show in folder" reveal (absolute paths). */ revealed: string[] @@ -481,7 +474,7 @@ export function FileTree(props: { // callbacks must not change identity when the caller re-renders). // `onReferenceFile` is read through `propsRef` like the other row callbacks. const { - sessionId, cwd, store, expanded, revealed, onOpenFileNewTab, onOpenFileSide, + sessionId, cwd, expanded, revealed, onOpenFileNewTab, onOpenFileSide, openInApp, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, openWithShowPluginTargets, onPathRenamed, onPathDeleted, refreshTick, onUploadRequest, busy, hidden, visible, service, @@ -506,8 +499,6 @@ export function FileTree(props: { const dirRowIcon = (path: string, open: boolean): ReactNode => service !== undefined ? service.folderIcon(path, open, 14) : builtinFolderIcon(open, 14) const dataRef = useRef(data) - /** Every in-flight level fetch, keyed by directory (aborted on refresh/unmount). */ - const controllersRef = useRef(new Map()) /** Bumped whenever the cache is wiped: a response from an older generation is dropped. */ const generationRef = useRef(0) /** The row whose path was just copied ("copied" label replaces its button). */ @@ -531,6 +522,8 @@ export function FileTree(props: { const [deletingSelected, setDeletingSelected] = useState(false) /** The last mutation failure (dismissable strip above the tree). */ const [actionError, setActionError] = useState(null) + /** A failed re-list that kept the previous rows on screen (hint line). */ + const [loadError, setLoadError] = useState(null) /** The multi-selection (absolute paths) and its Shift anchor. */ const [selected, setSelected] = useState>(() => new Set()) const anchorRef = useRef(null) @@ -640,34 +633,72 @@ export function FileTree(props: { }, []) /** - * List one directory into the cache. Levels already loaded (including the - * empty placeholder of an in-flight fetch) are left alone; a stale response - * — the cache was wiped by a refresh tick, or a newer fetch for the same - * directory started — is dropped instead of overwriting fresher data. + * List a SET of directories into the cache with ONE `fs.trees` request. + * + * The visible set (the workspace root plus every expanded directory) is + * always loaded this way, so mounting the tree, a refresh tick and expanding + * a directory each cost exactly one POST — not one per level (the N+1 the + * file list was reported for). Levels already loaded (including the empty + * placeholder of an in-flight fetch) are left alone, so an expand only asks + * for what is genuinely missing; a stale response — the cache was wiped by a + * refresh tick — is dropped instead of overwriting fresher data. + * + * A level the host failed to read comes back with its own `error` and is + * stored on that level alone (the other levels render normally). When the + * WHOLE request fails, the levels that already had a listing keep it (plus a + * hint) and only the levels with nothing to show carry the message. + * + * `force` re-lists levels even when they are cached — the refresh tick's + * path — while leaving their current rows untouched until the answer lands. */ - const loadDir = useCallback((dir: string) => { - if (dataRef.current[dir] !== undefined) return + const loadLevels = useCallback((paths: readonly string[], options?: { force?: boolean }) => { + const force = options?.force === true + const wanted = force + ? [...new Set(paths)] + : paths.filter(path => dataRef.current[path] === undefined) + if (wanted.length === 0) return const generation = generationRef.current - controllersRef.current.get(dir)?.abort() - const controller = new AbortController() - controllersRef.current.set(dir, controller) - storeLevel(dir, {}) - api.fsTree({ sessionId, cwd }, dir, controller.signal).then((listing) => { - if (controller.signal.aborted || generation !== generationRef.current) return - storeLevel(dir, { entries: listing.entries, truncated: listing.truncated }) + // A FORCED re-list (a refresh tick) keeps the listing on screen until the + // fresh one arrives: no blank frame, and a failed refresh degrades to the + // previous listing plus a hint instead of an empty tree. + if (!force) for (const path of wanted) storeLevel(path, {}) + // The route takes no signal: the GENERATION counter is the staleness guard + // (a refresh tick or an unmount bumps it, so a late answer is dropped). + api.fsTrees({ sessionId, cwd }, wanted).then((result) => { + if (generation !== generationRef.current) return + setLoadError(null) + for (const level of result.levels) { + storeLevel(level.path, { + entries: level.entries, + truncated: level.truncated, + ...(level.error !== undefined ? { error: level.error } : {}), + }) + } }).catch((error: unknown) => { - if (controller.signal.aborted || generation !== generationRef.current) return - storeLevel(dir, { error: error instanceof Error ? error.message : String(error) }) + if (generation !== generationRef.current) return + const message = error instanceof Error ? error.message : String(error) + let keptListing = false + for (const path of wanted) { + const level = dataRef.current[path] + if (level?.entries !== undefined) { + keptListing = true + continue + } + storeLevel(path, { error: message }) + } + // Levels that had a listing keep it; the hint explains why they are not + // fresher. With nothing to keep, the per-level rows already say it. + setLoadError(keptListing ? message : null) }) }, [sessionId, cwd, storeLevel]) - /** Drop one level from the cache and reload it (the fence notice's retry, - * a watch notice, or a landed mutation). */ + /** Drop one level from the cache and reload it (a watch notice, or the + * parent of a landed mutation). */ const retryDir = useCallback((dir: string) => { delete dataRef.current[dir] setData({ ...dataRef.current }) - loadDir(dir) - }, [loadDir]) + loadLevels([dir]) + }, [loadLevels]) /** * Settle the tree after one rename/delete landed at `prefix`: drop every @@ -712,38 +743,34 @@ export function FileTree(props: { }) } - // The caller's refresh tick wipes the cache (declared BEFORE the load - // effect so the reload below sees the empty cache) and invalidates every - // in-flight response, so a slow answer for the old generation can never - // land on top of the fresh listing. + // The caller's refresh tick invalidates every in-flight response (a slow + // answer for the old generation can never land on top of the fresh listing) + // and flags the next load as FORCED: the visible levels are re-listed in one + // batch, but their current rows stay on screen until the answer arrives. + // Declared BEFORE the load effect so that effect sees the flag. const lastTick = useRef(refreshTick) + const forceNextLoad = useRef(false) useEffect(() => { if (lastTick.current === refreshTick) return lastTick.current = refreshTick generationRef.current += 1 - for (const controller of controllersRef.current.values()) controller.abort() - controllersRef.current.clear() - dataRef.current = {} - setData({}) + forceNextLoad.current = true }, [refreshTick]) useEffect(() => { - // Load the visible set; already-loaded levels (kept in the cache) are - // not refetched. Only the refresh tick wipes the cache. Every level - // starts its own fetch, so a tick reloads them all concurrently. + // Load the visible set in ONE batch request; already-loaded levels (kept + // in the cache) are not refetched, so this asks only for what is new — a + // refresh tick re-lists root + every expanded directory in one POST. const root = cwd if (root === undefined) return - loadDir(root) - for (const dir of expanded) loadDir(dir) - }, [cwd, expanded, refreshTick, loadDir]) + const force = forceNextLoad.current + forceNextLoad.current = false + loadLevels([root, ...expanded], { force }) + }, [cwd, expanded, refreshTick, loadLevels]) - // A torn-down tree must not leave requests running against an unmounted - // component (their stores would be dropped anyway — this is lifecycle, not - // correctness, but it keeps the network honest). - useEffect(() => () => { - for (const controller of controllersRef.current.values()) controller.abort() - controllersRef.current.clear() - }, []) + // A torn-down tree must not write the answer of an in-flight batch into a + // dead component: bumping the generation makes every late response stale. + useEffect(() => () => { generationRef.current += 1 }, []) // Live refresh: the host watches the folders this tree has expanded and // names the one that changed, so exactly that level is dropped and @@ -1502,23 +1529,14 @@ export function FileTree(props: { ) } if (level.error !== undefined) { - // The fence refusal becomes the friendly notice (reason + one-click - // global off + immediate retry of this directory), never the raw - // `path "..." is outside workspace` wire text. + // A level the host could not read: the raw message on the level that + // failed, never on its siblings (the batch reports errors per level). return ( <> {head} - {isOutsideWorkspaceMessage(level.error) - ? ( -
- { retryDir(dir) }} /> -
- ) - : ( -
- {level.error} -
- )} +
+ {level.error} +
) } @@ -1617,6 +1635,11 @@ export function FileTree(props: { strip (the two CAN coexist — a stale error stays readable while a new attempt is in flight). `role="status"` makes it a polite live region, so progress is announced without stealing focus. */} + {/* A refresh whose batch request failed: the rows above are the + PREVIOUS listing, so the hint explains why nothing changed. */} + {loadError !== null && ( + {loadError} + )} {archiveBusy && ( {archiveProgress === null diff --git a/src/client/TreePanel.tsx b/src/client/TreePanel.tsx index 0b2d5c2..343877e 100644 --- a/src/client/TreePanel.tsx +++ b/src/client/TreePanel.tsx @@ -23,7 +23,6 @@ import clsx from 'clsx' import { IconFolderOpenRegular, IconRefreshOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import { api } from './api.ts' import type { BetterSidebarService } from './service.ts' -import type { SidebarStore } from './state.ts' import { FileTree } from './FileTree.tsx' import { IconUploadOutline16 } from './icons.tsx' import type { OpenInApp } from './open-in-app.ts' @@ -51,8 +50,6 @@ interface UploadSession { export function TreePanel(props: { sessionId: string cwd: string | undefined - /** The sidebar store (passed through to the tree's fence-refusal notice). */ - store: SidebarStore expanded: string[] revealed: string[] onToggle: (path: string) => void @@ -90,7 +87,7 @@ export function TreePanel(props: { service?: BetterSidebarService }) { const { - sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, + sessionId, cwd, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openInApp, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, openWithShowPluginTargets, onReferenceFile, onPathRenamed, onPathDeleted, visible, full, service, @@ -287,7 +284,6 @@ export function TreePanel(props: { ('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), + /** + * Batch listing: every requested level in ONE request (the tree's mount and + * refresh send the expanded set instead of N `fsTree` calls). A level that + * failed carries `error` in place — the batch itself still succeeds. + */ + fsTrees: (scope: SessionScope, paths: readonly string[], signal?: AbortSignal) => + call<{ levels: FsLevel[] }>('fs.trees', scopePayload(scope, { paths: [...paths] }), 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) => diff --git a/src/client/builtins/tabs.tsx b/src/client/builtins/tabs.tsx index 17e8b9d..03d06c7 100644 --- a/src/client/builtins/tabs.tsx +++ b/src/client/builtins/tabs.tsx @@ -43,11 +43,10 @@ export function builtinTabs(): readonly TabDescriptor[] { dedupeKey: (tab) => tab.path, // Declarative settings: the file-open behavior picker (in-place switch // vs per-path windows) renders as an iconed select row under the - // editor card's gear in the Side card settings page, followed by the - // workspace fence switch (the host's containment guard over every - // sidebar filesystem route); the "open with" configuration (SSH host + - // custom editors) is the custom panel BELOW those rows — the settings - // seam renders rows first, custom panel after. + // editor card's gear in the Side card settings page; the "open with" + // configuration (SSH host + custom editors) is the custom panel BELOW + // those rows — the settings seam renders rows first, custom panel after. + // The workspace-fence switch is GONE: there is no containment to toggle. settings: { toggles: [{ key: 'editorExplorer', @@ -68,10 +67,6 @@ export function builtinTabs(): readonly TabDescriptor[] { desc: () => t('editorExplorerSplitDesc'), }, ], - }, { - key: 'workspaceFence', - title: () => t('settingsFenceTitle'), - desc: () => t('settingsFenceDesc'), }], // Plugin-owned rows (values live in `pluginSettings['editor']`): the // plugin's own open-with targets are shown only when the host reports diff --git a/src/client/changes/GitLens.tsx b/src/client/changes/GitLens.tsx index b65a05a..098db3e 100644 --- a/src/client/changes/GitLens.tsx +++ b/src/client/changes/GitLens.tsx @@ -36,7 +36,7 @@ import type { GitLogEntry, GitStatusEntry, GitStatusResult, GitWorktree, Session import { api } from '../api.ts' import { builtinFileIcon, builtinFolderIcon } from '../file-icons.tsx' import { usePolling } from '../use-polling.ts' -import { baseName, isWithinWorkspace, relativeTo } from '../paths.ts' +import { baseName, relativeTo } from '../paths.ts' import { resolveSidebarPath } from '../paths.ts' import { relativeTime, t } from '../locales.ts' import type { SidebarDiffRef, SidebarStore } from '../state.ts' @@ -249,7 +249,8 @@ const DirRow = memo(function DirRow(props: { export interface GitLensProps { scope: SessionScope - /** The sidebar store: reads the `workspaceFence` pref (see the open guard below). */ + /** @deprecated The sidebar store is no longer read here (the fence guard it + * fed is gone); the prop stays for the caller's shape. */ store: SidebarStore onOpenFile: (path: string) => void /** Preview one change in the shared bottom pane (worktree or commit ref). */ @@ -266,7 +267,7 @@ export interface GitLensProps { } export function GitLens(props: GitLensProps) { - const { scope, store, onOpenFile, onPreview, selectedRef, visible, refreshTick = 0, ctx } = props + const { scope, onOpenFile, onPreview, selectedRef, visible, refreshTick = 0, ctx } = props const service = ctx?.get('betterSidebar') /** The file row's artwork: a registered icon wins, else the host's own @@ -952,13 +953,10 @@ export function GitLens(props: GitLensProps) { open={fileMenu !== null} onClose={() => { setFileMenu(null) }} items={[ - // A linked worktree outside the session workspace cannot be - // opened in the editor while the host's workspace fence is - // armed: it rejects every path under that checkout. Hide the - // action for that checkout so the menu does not offer a no-op - // that confuses the user; with the fence disarmed (the - // `workspaceFence` pref) the open is allowed through. - ...(fileMenu !== null && (store.getPrefs().workspaceFence === false || isWithinWorkspace(scope.cwd ?? '', resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, fileMenu.path))) + // The open action is always offered: the host's workspace fence + // is gone, so a path under any checkout (linked worktree + // included) opens like any other. + ...(fileMenu !== null ? [{ id: 'open', label: t('openEditor'), icon: }] : []), fileMenu?.staged === true @@ -977,12 +975,8 @@ export function GitLens(props: GitLensProps) { setFileMenu(null) if (id === 'open') { const resolved = resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, target.path) - // Defense-in-depth: the menu hides this action when the - // resolved path escapes the session workspace, but a - // racing repo switch could still reach here with a path - // the host would reject. No-op in that case — unless the - // workspace fence is disarmed by pref. - if (store.getPrefs().workspaceFence !== false && !isWithinWorkspace(scope.cwd ?? '', resolved)) return + // The host accepts any path now (no containment), so the open + // guard that mirrored the old fence is gone with it. onOpenFile(resolved) return } diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts index bfd7c31..70f98e9 100644 --- a/src/client/locales-ar.ts +++ b/src/client/locales-ar.ts @@ -263,10 +263,6 @@ export const ar: Record = { settingsJobsDesc: 'تُفعَّل صفحة المهام كلما ظهرت مهمة خلفية جديدة للمحادثة الحالية (كل مهمة جديدة تُطلق التفعيل)؛ في الشاشات العريضة تُوسَّع البطاقة الجانبية أيضاً، أما في الشاشات الضيقة فلا يُفرض فتح الدرج بملء الشاشة؛ عند إيقاف الخيار يصبح الفتح يدوياً', settingsToolsTitle: 'حقن أدوات الطرفية للنموذج', settingsToolsDesc: 'عند التفعيل، يمكن للنموذج إنشاء وتشغيل طرفيات الشريط الجانبي عبر أدوات terminal_* الثمانية (معطّل افتراضياً)', - settingsFenceTitle: 'فحص مسارات مساحة العمل', - settingsFenceDesc: 'عند التفعيل، تصل وظائف الملفات في الشريط الجانبي إلى مسارات داخل مساحة عمل الجلسة فقط (الافتراضي)؛ عند الإيقاف تصبح ملفات المضيف كلها متاحة — وتحصل سكربتات الصفحة على النفاذ نفسه طوال ذلك', - fenceErrorReason: 'هذا المسار خارج مساحة عمل الجلسة وقد حجبه الفحص', - fenceDisableAction: 'إيقاف فحص مساحة العمل', settingsBottomTerminalTitle: 'فتح طرفية تلقائياً عند أول توسيع للوحة السفلية', settingsBottomTerminalDesc: 'عند توسيع اللوحة السفلية لأول مرة في الجلسة، حاول فتح تبويب طرفية جديد هناك (حصة الطرفيات ما زالت تسري؛ مفعّل افتراضياً)', settingsFontFamilyTitle: 'عائلة خط الطرفية', diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts index 4683218..e6bf4bb 100644 --- a/src/client/locales-de.ts +++ b/src/client/locales-de.ts @@ -248,10 +248,6 @@ export const de: Record = { settingsJobsDesc: 'Die Aufgaben-Seite aktivieren, sobald für die aktuelle Unterhaltung eine neue Hintergrundaufgabe erscheint (jede neue Aufgabe löst dies aus); auf breiten Ansichten wird zusätzlich die Seitenkarte ausgeklappt, auf schmalen Ansichten wird das Öffnen der Vollbild-Schublade nicht erzwungen; ausschalten, um sie manuell zu öffnen', settingsToolsTitle: 'Terminal-Werkzeuge für das Modell bereitstellen', settingsToolsDesc: 'Wenn aktiviert, kann das Modell über die 8 terminal_*-Werkzeuge Terminale in der Seitenleiste erstellen und steuern (standardmäßig deaktiviert)', - settingsFenceTitle: 'Workspace-Pfadkontrolle', - settingsFenceDesc: 'Wenn aktiv, erreichen die Dateifunktionen der Seitenleiste nur Pfade innerhalb des Session-Workspaces (Standard); ausgeschaltet sind Dateien überall auf dem Host erreichbar — Skripte der Seite erhalten solange dieselbe Reichweite', - fenceErrorReason: 'Dieser Pfad liegt außerhalb des Session-Workspaces und wurde von der Workspace-Kontrolle blockiert', - fenceDisableAction: 'Workspace-Kontrolle ausschalten', settingsBottomTerminalTitle: 'Beim ersten Ausklappen des unteren Panels automatisch ein Terminal öffnen', settingsBottomTerminalDesc: 'Wird das untere Panel in einer Sitzung zum ersten Mal ausgeklappt, wird dort versucht, einen neuen Terminal-Tab zu öffnen (das Terminal-Kontingent gilt weiterhin; standardmäßig aktiviert)', settingsFontFamilyTitle: 'Terminal-Schriftart', diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts index 92e1d3b..d57bda3 100644 --- a/src/client/locales-fr.ts +++ b/src/client/locales-fr.ts @@ -255,10 +255,6 @@ export const fr: Record = { settingsJobsDesc: 'À chaque nouvelle tâche d’arrière-plan dans la session actuelle, activer la page Gestion des tâches (chaque nouvelle tâche déclenche l’activation) ; sur les écrans larges, déployer également la carte latérale ; sur les écrans étroits, ne pas forcer l’ouverture du tiroir plein écran ; une fois l’option désactivée, l’ouverture est manuelle', settingsToolsTitle: 'Injecter des outils de terminal au modèle', settingsToolsDesc: 'Une fois activé, le modèle peut créer et piloter des terminaux de la barre latérale via les 8 outils terminal_* (désactivé par défaut)', - settingsFenceTitle: 'Contrôle des chemins de l\'espace de travail', - settingsFenceDesc: 'Activé, les fonctions fichiers de la barre latérale n\'atteignent que les chemins dans l\'espace de travail de la session (par défaut) ; désactivé, les fichiers de tout l\'hôte deviennent accessibles — les scripts de la page obtiennent la même portée pendant ce temps', - fenceErrorReason: 'Ce chemin est hors de l\'espace de travail de la session et a été bloqué par le contrôle', - fenceDisableAction: 'Désactiver le contrôle de l\'espace de travail', settingsBottomTerminalTitle: 'Ouvrir automatiquement un terminal au premier déploiement du panneau inférieur', settingsBottomTerminalDesc: 'Lors du premier déploiement du panneau inférieur dans une session, tenter d’y ouvrir automatiquement un nouvel onglet de terminal (la limite du nombre de terminaux s’applique toujours ; activé par défaut)', settingsFontFamilyTitle: 'Police du terminal', diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts index f11eca6..ba94283 100644 --- a/src/client/locales-hi.ts +++ b/src/client/locales-hi.ts @@ -262,10 +262,6 @@ export const hi: Record = { settingsJobsDesc: 'वर्तमान वार्तालाप में हर नया बैकग्राउंड कार्य आने पर कार्य पृष्ठ सक्रिय करें (हर नया कार्य ट्रिगर करता है); चौड़ी स्क्रीन पर साइड कार्ड भी विस्तृत होता है, जबकि संकीर्ण स्क्रीन पर पूर्ण-स्क्रीन दराज को जबरन नहीं खोला जाता; विकल्प बंद होने पर इसे मैन्युअल रूप से खोलना होगा', settingsToolsTitle: 'मॉडल के लिए टर्मिनल टूल इंजेक्ट करें', settingsToolsDesc: 'सक्षम होने पर, मॉडल 8 terminal_* टूल्स से साइडबार टर्मिनल बना और चला सकता है (डिफ़ॉल्ट रूप से बंद)', - settingsFenceTitle: 'वर्कस्पेस पाथ जाँच', - settingsFenceDesc: 'चालू होने पर साइडबार की फ़ाइल सुविधाएँ केवल सेशन वर्कस्पेस के भीतर के पाथ तक पहुँचती हैं (डिफ़ॉल्ट); बंद करने पर होस्ट की कोई भी फ़ाइल खोली जा सकती है — उस दौरान पेज के स्क्रिप्ट को भी वही पहुँच मिल जाती है', - fenceErrorReason: 'यह पाथ सेशन वर्कस्पेस के बाहर है और जाँच द्वारा अवरुद्ध किया गया', - fenceDisableAction: 'वर्कस्पेस जाँच बंद करें', settingsBottomTerminalTitle: 'निचला पैनल पहली बार खुलने पर स्वतः टर्मिनल खोलें', settingsBottomTerminalDesc: 'सत्र में निचला पैनल पहली बार विस्तृत होने पर वहाँ नया टर्मिनल टैब खोलने का प्रयास (टर्मिनल कोटा लागू; डिफ़ॉल्ट रूप से चालू)', settingsFontFamilyTitle: 'टर्मिनल फ़ॉन्ट परिवार', diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts index c71b8bc..13df042 100644 --- a/src/client/locales-id.ts +++ b/src/client/locales-id.ts @@ -260,10 +260,6 @@ export const id: Record = { settingsJobsDesc: 'Aktifkan halaman Tasks setiap kali tugas latar baru muncul untuk obrolan saat ini (setiap tugas baru memicu aktivasi); pada layar lebar, kartu samping juga dibentangkan; pada layar sempit, laci layar penuh tidak dipaksa terbuka; matikan untuk membukanya secara manual', settingsToolsTitle: 'Suntik alat terminal untuk model', settingsToolsDesc: 'Saat diaktifkan, model dapat membuat dan mengendalikan terminal sidebar melalui 8 alat terminal_* (nonaktif secara default)', - settingsFenceTitle: 'Pemeriksaan path workspace', - settingsFenceDesc: 'Saat aktif, fitur berkas sidebar hanya menjangkau path di dalam workspace sesi (default); saat nonaktif, berkas di mana pun di host dapat diakses — skrip halaman memperoleh jangkauan yang sama selama itu', - fenceErrorReason: 'Path ini berada di luar workspace sesi dan diblokir oleh pemeriksaan', - fenceDisableAction: 'Matikan pemeriksaan workspace', settingsBottomTerminalTitle: 'Buka terminal otomatis saat panel bawah pertama kali dibentangkan', settingsBottomTerminalDesc: 'Saat panel bawah pertama kali dibentangkan dalam sesi, coba buka tab terminal baru di panel bawah (kuota terminal tetap berlaku; aktif secara default)', settingsFontFamilyTitle: 'Keluarga font terminal', diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts index ae76120..fb292d6 100644 --- a/src/client/locales-it.ts +++ b/src/client/locales-it.ts @@ -253,10 +253,6 @@ export const it: Record = { settingsJobsDesc: 'Quando nella conversazione attuale compare una nuova attività in background, attiva automaticamente la pagina Attività (l’attivazione avviene per ogni nuova attività); sugli schermi ampi espande anche la barra laterale, mentre sugli schermi stretti non forza l’apertura del pannello a schermo intero; disattiva per selezionare la pagina manualmente', settingsToolsTitle: 'Inietta strumenti del terminale per il modello', settingsToolsDesc: 'Se attivato, il modello può creare e gestire terminali della barra laterale attraverso gli 8 strumenti terminal_* (disattivato per impostazione predefinita)', - settingsFenceTitle: 'Controllo dei percorsi del workspace', - settingsFenceDesc: 'Attivo, le funzioni file della barra laterale raggiungono solo i percorsi nel workspace di sessione (predefinito); disattivo, ogni file dell\'host è raggiungibile — gli script della pagina ottengono la stessa portata nel frattempo', - fenceErrorReason: 'Questo percorso è fuori dal workspace di sessione ed è stato bloccato dal controllo', - fenceDisableAction: 'Disattiva il controllo del workspace', settingsBottomTerminalTitle: 'Apri automaticamente un terminale alla prima espansione del pannello inferiore', settingsBottomTerminalDesc: 'Quando il pannello inferiore viene espanso per la prima volta in una sessione, prova ad aprire una nuova scheda terminale nel pannello inferiore (il limite di terminali si applica comunque; attivo per impostazione predefinita)', settingsFontFamilyTitle: 'Famiglia di caratteri del terminale', diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts index fe04414..60f54c5 100644 --- a/src/client/locales-ja.ts +++ b/src/client/locales-ja.ts @@ -262,10 +262,6 @@ export const ja: Record = { settingsJobsDesc: '現在の会話で新しいバックグラウンドタスクが発生するたびにタスク管理ページをアクティブ化する(新規タスクごとに発火)。広い画面ではサイドカードも展開するが、狭い画面では全画面ドロワーを強制的に開かない。オフにすると手動で開く必要がある', settingsToolsTitle: 'モデルにターミナルツールを注入', settingsToolsDesc: 'オンにすると、モデルが terminal_create 等 8 個のツールでサイドバーターミナルを作成・操作可能(デフォルトオフ)', - settingsFenceTitle: 'ワークスペースパスガード', - settingsFenceDesc: 'オンの場合、サイドバーのファイル機能はセッションワークスペース内のパスのみアクセス可能(デフォルト)。オフにするとホスト上の任意のファイルにアクセスできるが、オフの間はページ内スクリプトも同じ権限を得る', - fenceErrorReason: 'このパスはセッションワークスペースの外にあり、ワークスペースガードによりブロックされました', - fenceDisableAction: 'ワークスペースガードをオフにする', settingsBottomTerminalTitle: '下パネル初回展開時に自動でターミナルを開く', settingsBottomTerminalDesc: '会話ごとに下パネルを初めて展開した際、下パネルに新しいターミナルタブを自動で開く(ターミナル数上限は依然適用。デフォルトオン)', settingsFontFamilyTitle: 'ターミナルフォント', diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts index 474cbad..b818d02 100644 --- a/src/client/locales-ko.ts +++ b/src/client/locales-ko.ts @@ -254,10 +254,6 @@ export const ko: Record = { settingsJobsDesc: '현재 대화에 새 백그라운드 작업이 생길 때마다 작업 관리 페이지를 활성화합니다(새 작업마다 트리거됨). 넓은 화면에서는 사이드 카드도 펼치지만, 좁은 화면에서는 전체 화면 드로어를 강제로 열지 않습니다. 끄면 수동으로 열어야 합니다', settingsToolsTitle: '모델에 터미널 도구 주입', settingsToolsDesc: '켜면 모델이 terminal_create 등 8개 도구로 사이드바 터미널을 만들고 조작할 수 있습니다(기본 꺼짐)', - settingsFenceTitle: '워크스페이스 경로 검사', - settingsFenceDesc: '켜면 사이드바의 파일 기능이 세션 워크스페이스 내 경로만 접근합니다(기본값). 끄면 호스트의 모든 파일에 접근할 수 있으며, 꺼져 있는 동안 페이지 내 스크립트도 동일한 권한을 얻습니다', - fenceErrorReason: '이 경로는 세션 워크스페이스 밖에 있어 검사에 의해 차단되었습니다', - fenceDisableAction: '워크스페이스 검사 끄기', settingsBottomTerminalTitle: '하단 패널을 처음 펼칠 때 터미널 자동 열기', settingsBottomTerminalDesc: '세션에서 하단 패널을 처음 펼칠 때 하단 패널에 새 터미널 탭을 자동으로 열려고 시도합니다(터미널 개수 상한은 여전히 적용됩니다. 기본 켜짐)', settingsFontFamilyTitle: '터미널 글꼴', diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts index 0739ec1..f79828e 100644 --- a/src/client/locales-nl.ts +++ b/src/client/locales-nl.ts @@ -260,10 +260,6 @@ export const nl: Record = { settingsJobsDesc: 'Wanneer er voor de huidige conversatie een nieuwe achtergrondtaak verschijnt, wordt de Taken-pagina automatisch geactiveerd (dit gebeurt bij elke nieuwe taak); op brede schermen wordt ook de zijkaart uitgeklapt, terwijl op smalle schermen het schuifpaneel op volledig scherm niet geforceerd wordt geopend; schakel uit om de pagina handmatig te selecteren', settingsToolsTitle: 'Terminaltools voor het model injecteren', settingsToolsDesc: 'Indien ingeschakeld kan het model zijbalk-terminals aanmaken en besturen via de 8 terminal_*-tools (standaard uit)', - settingsFenceTitle: 'Workspace-padcontrole', - settingsFenceDesc: 'Aan: de bestandsfuncties van de zijbalk bereiken alleen paden binnen de sessie-workspace (standaard); uit: elk bestand op de host is bereikbaar — paginascripts krijgen dat bereik zolang het uit staat', - fenceErrorReason: 'Dit pad ligt buiten de sessie-workspace en is geblokkeerd door de controle', - fenceDisableAction: 'Workspace-controle uitzetten', settingsBottomTerminalTitle: 'Automatisch een terminal openen bij de eerste uitklap van het onderpaneel', settingsBottomTerminalDesc: 'Wanneer het onderpaneel voor het eerst in een sessie wordt uitgeklapt, wordt geprobeerd daar een nieuw terminaltabblad te openen (het terminalquotum blijft gelden; standaard aan)', settingsFontFamilyTitle: 'Terminallettertype-familie', diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts index 1f40f74..37adbaf 100644 --- a/src/client/locales-pl.ts +++ b/src/client/locales-pl.ts @@ -264,10 +264,6 @@ export const pl: Record = { settingsJobsDesc: 'Gdy w bieżącej rozmowie pojawi się nowe zadanie w tle, automatycznie aktywuj stronę „Zadania” (akcja jest wyzwalana przez każde nowe zadanie); na szerokich ekranach rozwiń także panel boczny, a na wąskich nie wymuszaj otwarcia pełnoekranowego panelu; wyłącz, aby wybierać stronę ręcznie', settingsToolsTitle: 'Wstrzyknij narzędzia terminala dla modelu', settingsToolsDesc: 'Po włączeniu model może tworzyć i sterować terminalami panelu bocznego przez 8 narzędzi terminal_* (domyślnie wyłączone)', - settingsFenceTitle: 'Kontrola ścieżek obszaru roboczego', - settingsFenceDesc: 'Włączona: funkcje plików paska bocznego sięgają tylko ścieżek w obszarze roboczym sesji (domyślnie); wyłączona: dostępny jest dowolny plik hosta — skrypty strony zyskują ten sam zasięg przez ten czas', - fenceErrorReason: 'Ta ścieżka jest poza obszarem roboczym sesji i została zablokowana przez kontrolę', - fenceDisableAction: 'Wyłącz kontrolę obszaru roboczego', settingsBottomTerminalTitle: 'Automatycznie otwieraj terminal przy pierwszym rozwinięciu panelu dolnego', settingsBottomTerminalDesc: 'Gdy panel dolny jest rozwijany po raz pierwszy w sesji, spróbuj otworzyć nową kartę terminala tam (limit terminali nadal obowiązuje; domyślnie włączone)', settingsFontFamilyTitle: 'Krój czcionki terminala', diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts index a7dbe98..fa75123 100644 --- a/src/client/locales-pt.ts +++ b/src/client/locales-pt.ts @@ -245,10 +245,6 @@ export const pt: Record = { settingsJobsDesc: 'Ativar a página de Tarefas sempre que uma nova tarefa em segundo plano aparecer na conversa atual (cada nova tarefa aciona a ativação); em telas largas, expandir também o cartão lateral; em telas estreitas, não forçar a abertura da gaveta em tela cheia; desative para abri-la manualmente', settingsToolsTitle: 'Injetar ferramentas de terminal para o modelo', settingsToolsDesc: 'Quando ativado, o modelo pode criar e operar terminais da barra lateral por meio das 8 ferramentas terminal_* (desativado por padrão)', - settingsFenceTitle: 'Controle de caminhos do workspace', - settingsFenceDesc: 'Ativado, os recursos de arquivos da barra lateral só alcançam caminhos dentro do workspace da sessão (padrão); desativado, arquivos de todo o host ficam acessíveis — scripts da página ganham o mesmo alcance enquanto isso', - fenceErrorReason: 'Este caminho está fora do workspace da sessão e foi bloqueado pelo controle', - fenceDisableAction: 'Desativar o controle do workspace', settingsBottomTerminalTitle: 'Abrir um terminal automaticamente na primeira expansão do painel inferior', settingsBottomTerminalDesc: 'Quando o painel inferior for expandido pela primeira vez em uma sessão, tente abrir uma nova aba de terminal lá (a cota de terminais ainda se aplica; ativado por padrão)', settingsFontFamilyTitle: 'Família de fonte do terminal', diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts index 01d2399..1bc466a 100644 --- a/src/client/locales-ru.ts +++ b/src/client/locales-ru.ts @@ -259,10 +259,6 @@ export const ru: Record = { settingsJobsDesc: 'При появлении новой фоновой задачи в текущей сессии автоматически активируется страница «Задачи» (срабатывает для каждой новой задачи); на широких экранах также разворачивается боковая панель, а на узких экранах полноэкранная выдвижная панель не открывается принудительно; отключите, чтобы выбирать страницу вручную', settingsToolsTitle: 'Внедрить терминальные инструменты для модели', settingsToolsDesc: 'Если включено, модель может через 8 инструментов (terminal_create и др.) создавать терминалы боковой панели и управлять ими (по умолчанию выключено)', - settingsFenceTitle: 'Контроль путей рабочей области', - settingsFenceDesc: 'Включено — файловые функции боковой панели достигают только путей внутри рабочей области сессии (по умолчанию); выключено — доступны любые файлы хоста, а скрипты страницы получают тот же доступ всё это время', - fenceErrorReason: 'Этот путь вне рабочей области сессии и заблокирован контролем', - fenceDisableAction: 'Отключить контроль рабочей области', settingsBottomTerminalTitle: 'Автооткрытие терминала при первом разворачивании нижней панели', settingsBottomTerminalDesc: 'При первом разворачивании нижней панели в сессии попытаться автоматически открыть новую вкладку терминала в ней (лимит терминалов всё равно применяется; по умолчанию включено)', settingsFontFamilyTitle: 'Шрифт терминала', diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts index 0170863..9011f8a 100644 --- a/src/client/locales-sv.ts +++ b/src/client/locales-sv.ts @@ -245,10 +245,6 @@ export const sv: Record = { settingsJobsDesc: 'När ett nytt bakgrundsjobb dyker upp för den aktuella konversationen aktiveras sidan Uppgifter automatiskt (detta sker för varje nytt jobb); på breda skärmar expanderas även sidokortet, medan helskärmspanelen inte tvingas öppnas på smala skärmar; stäng av för att välja sidan manuellt', settingsToolsTitle: 'Injicera terminalverktyg för modellen', settingsToolsDesc: 'När aktiverat kan modellen skapa och styra sidopanelsterminaler via de 8 terminal_*-verktygen (av som standard)', - settingsFenceTitle: 'Kontroll av workspace-sökvägar', - settingsFenceDesc: 'På: sidopanelens filfunktioner når bara sökvägar i sessionens workspace (standard); av: vilken fil som helst på värden kan nås — sidans skript får samma räckvidd så länge', - fenceErrorReason: 'Denna sökväg ligger utanför sessionens workspace och blockerades av kontrollen', - fenceDisableAction: 'Stäng av workspace-kontrollen', settingsBottomTerminalTitle: 'Öppna automatiskt en terminal vid bottenpanelens första expandering', settingsBottomTerminalDesc: 'När bottenpanelen expanderas för första gången i en session, försök öppna en ny terminalflik där (terminalgränsen gäller fortfarande; på som standard)', settingsFontFamilyTitle: 'Terminalens teckensnittsfamilj', diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts index 68fd069..58eee91 100644 --- a/src/client/locales-th.ts +++ b/src/client/locales-th.ts @@ -262,10 +262,6 @@ export const th: Record = { settingsJobsDesc: 'เมื่อมีงานเบื้องหลังใหม่ปรากฏในแชทปัจจุบัน ระบบจะเลือกหน้า Tasks โดยอัตโนมัติ (ทุกงานใหม่จะทริกเกอร์); ในมุมมองแบบกว้าง ระบบจะขยายการ์ดด้านข้างด้วย ส่วนในมุมมองแบบแคบ ระบบจะไม่บังคับเปิดลิ้นชักเต็มหน้าจอ; ปิดเพื่อเลือกหน้าด้วยตนเอง', settingsToolsTitle: 'ฉีดเครื่องมือเทอร์มินัลสำหรับโมเดล', settingsToolsDesc: 'เมื่อเปิดใช้ โมเดลสามารถสร้างและควบคุมเทอร์มินัลแถบด้านข้างผ่านเครื่องมือ terminal_* 8 ตัว (ปิดเป็นค่าเริ่มต้น)', - settingsFenceTitle: 'การตรวจสอบเส้นทางเวิร์กสเปซ', - settingsFenceDesc: 'เมื่อเปิด ฟีเจอร์ไฟล์ของแถบข้างเข้าถึงได้เฉพาะเส้นทางในเวิร์กสเปซของเซสชัน (ค่าเริ่มต้น) เมื่อปิด จะเข้าถึงไฟล์ใดก็ได้บนโฮสต์ — สคริปต์ในหน้าก็ได้ขอบเขตเดียวกันตลอดช่วงนั้น', - fenceErrorReason: 'เส้นทางนี้อยู่นอกเวิร์กสเปซของเซสชันและถูกบล็อกโดยการตรวจสอบ', - fenceDisableAction: 'ปิดการตรวจสอบเวิร์กสเปซ', settingsBottomTerminalTitle: 'เปิดเทอร์มินัลอัตโนมัติเมื่อพาเนลล่างขยายครั้งแรก', settingsBottomTerminalDesc: 'เมื่อพาเนลล่างถูกขยายครั้งแรกในเซสชัน ลองเปิด tab เทอร์มินัลใหม่ที่นั่น (โควต้าเทอร์มินัลยังคงมีผล; เปิดเป็นค่าเริ่มต้น)', settingsFontFamilyTitle: 'ฟอนต์เทอร์มินัล', diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts index ae38ade..2b34552 100644 --- a/src/client/locales-tr.ts +++ b/src/client/locales-tr.ts @@ -262,10 +262,6 @@ export const tr: Record = { settingsJobsDesc: 'Geçerli sohbette her yeni arka plan işi belirdiğinde Görevler sayfasını etkinleştir (her yeni iş tetikler); geniş ekranlarda yan kart da genişletilir, dar ekranlarda tam ekran çekmecesi zorla açılmaz; kapalıyken elle açılması gerekir', settingsToolsTitle: 'Model için terminal araçları enjekte et', settingsToolsDesc: 'Etkinleştirildiğinde model, 8 terminal_* aracı üzerinden kenar çubuğu terminalleri oluşturup kullanabilir (varsayılan kapalı)', - settingsFenceTitle: 'Çalışma alanı yol denetimi', - settingsFenceDesc: 'Açıkken kenar çubuğunun dosya işlevleri yalnızca oturum çalışma alanındaki yollara erişir (varsayılan); kapatıldığında ana makinedeki her dosyaya erişilebilir — o süre boyunca sayfadaki betikler de aynı erişimi kazanır', - fenceErrorReason: 'Bu yol oturum çalışma alanının dışında ve denetim tarafından engellendi', - fenceDisableAction: 'Çalışma alanı denetimini kapat', settingsBottomTerminalTitle: 'Alt panelin ilk genişletilmesinde otomatik terminal aç', settingsBottomTerminalDesc: 'Bir oturumda alt panel ilk genişletildiğinde orada yeni bir terminal sekmesi açmayı dene (terminal kotası yine geçerli; varsayılan açık)', settingsFontFamilyTitle: 'Terminal yazı tipi ailesi', diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts index 46dc2e9..55b26bb 100644 --- a/src/client/locales-vi.ts +++ b/src/client/locales-vi.ts @@ -262,10 +262,6 @@ export const vi: Record = { settingsJobsDesc: 'Mỗi khi phiên hiện tại có tác vụ nền mới, kích hoạt trang Quản lý tác vụ (mỗi tác vụ mới đều kích hoạt); trên màn hình rộng, thẻ bên cũng được mở rộng; trên màn hình hẹp, ngăn kéo toàn màn hình không bị ép mở; tắt để mở thủ công', settingsToolsTitle: 'Tiêm công cụ terminal cho mô hình', settingsToolsDesc: 'Khi bật, mô hình có thể tạo và điều khiển terminal thanh bên qua 8 công cụ terminal_* (mặc định tắt)', - settingsFenceTitle: 'Kiểm tra đường dẫn workspace', - settingsFenceDesc: 'Khi bật, các tính năng tệp của thanh bên chỉ truy cập đường dẫn trong workspace của phiên (mặc định); khi tắt, mọi tệp trên máy chủ đều có thể truy cập — script trong trang cũng có quyền tương ứng trong thời gian đó', - fenceErrorReason: 'Đường dẫn này nằm ngoài workspace của phiên và đã bị chặn bởi kiểm tra', - fenceDisableAction: 'Tắt kiểm tra workspace', settingsBottomTerminalTitle: 'Tự động mở terminal khi panel dưới mở rộng lần đầu', settingsBottomTerminalDesc: 'Khi panel dưới được mở rộng lần đầu trong phiên, thử mở một tab terminal mới ở panel dưới (giới hạn terminal vẫn áp dụng; mặc định bật)', settingsFontFamilyTitle: 'Font terminal', diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts index d6bb6df..1b65ee5 100644 --- a/src/client/locales-zh-HK.ts +++ b/src/client/locales-zh-HK.ts @@ -277,10 +277,6 @@ export const zhHK: Record = { settingsJobsDesc: '目前工作階段出現新的背景任務時,自動選取任務管理頁(每個新任務都會觸發);寬螢幕時亦會展開側邊欄,窄螢幕時不會強制開啟全螢幕抽屜;關閉後需手動選取', settingsToolsTitle: '為模型注入終端工具', settingsToolsDesc: '開啟後,模型可透過 terminal_create 等 8 個工具建立並操作側邊欄終端(預設關閉)', - settingsFenceTitle: '工作區路徑檢測', - settingsFenceDesc: '開啟後,側欄的檔案功能僅能存取工作階段工作區內的路徑(預設);關閉後可存取主機上任意檔案——關閉期間頁面內指令碼也將獲得同等存取能力', - fenceErrorReason: '此路徑在工作階段工作區之外,已被工作區檢測攔截', - fenceDisableAction: '關閉工作區檢測', settingsBottomTerminalTitle: '底部面板首次展開自動開終端', settingsBottomTerminalDesc: '每次工作階段中第一次展開底部面板時,嘗試在底部面板自動開啟一個新終端標籤(終端數量上限仍會限制;預設開啟)', settingsFontFamilyTitle: '終端字體', diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts index 2639422..baacd37 100644 --- a/src/client/locales-zh-MO.ts +++ b/src/client/locales-zh-MO.ts @@ -277,10 +277,6 @@ export const zhMO: Record = { settingsJobsDesc: '目前工作階段出現新的背景任務時,自動選取任務管理頁(每個新任務都會觸發);寬螢幕時亦會展開側邊欄,窄螢幕時不會強制開啟全螢幕抽屜;關閉後需手動選取', settingsToolsTitle: '為模型注入終端工具', settingsToolsDesc: '開啟後,模型可透過 terminal_create 等 8 個工具建立並操作側邊欄終端(預設關閉)', - settingsFenceTitle: '工作區路徑檢測', - settingsFenceDesc: '開啟後,側欄的檔案功能僅能存取工作階段工作區內的路徑(預設);關閉後可存取主機上任意檔案——關閉期間頁面內指令碼也將獲得同等存取能力', - fenceErrorReason: '此路徑在工作階段工作區之外,已被工作區檢測攔截', - fenceDisableAction: '關閉工作區檢測', settingsBottomTerminalTitle: '底部面板首次展開自動開終端', settingsBottomTerminalDesc: '每次工作階段中第一次展開底部面板時,嘗試在底部面板自動開啟一個新終端標籤(終端數量上限仍會限制;預設開啟)', settingsFontFamilyTitle: '終端字體', diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts index 4ac238f..d8da1e7 100644 --- a/src/client/locales-zh-TW.ts +++ b/src/client/locales-zh-TW.ts @@ -277,10 +277,6 @@ export const zhTW: Record = { settingsJobsDesc: '目前工作階段出現新的背景任務時,自動選取任務管理頁(每個新任務都會觸發);寬螢幕時亦會展開側邊欄,窄螢幕時不會強制開啟全螢幕抽屜;關閉後需手動選取', settingsToolsTitle: '為模型注入終端工具', settingsToolsDesc: '開啟後,模型可透過 terminal_create 等 8 個工具建立並操作側邊欄終端(預設關閉)', - settingsFenceTitle: '工作區路徑檢測', - settingsFenceDesc: '開啟後,側欄的檔案功能僅能存取工作階段工作區內的路徑(預設);關閉後可存取主機上任意檔案——關閉期間頁面內指令碼也將獲得同等存取能力', - fenceErrorReason: '此路徑在工作階段工作區之外,已被工作區檢測攔截', - fenceDisableAction: '關閉工作區檢測', settingsBottomTerminalTitle: '底部面板首次展開自動開終端', settingsBottomTerminalDesc: '每次工作階段中第一次展開底部面板時,嘗試在底部面板自動開啟一個新終端標籤(終端數量上限仍會限制;預設開啟)', settingsFontFamilyTitle: '終端字體', diff --git a/src/client/locales.ts b/src/client/locales.ts index 80be7fd..fb87c7d 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -274,10 +274,6 @@ export const zh = { settingsJobsDesc: '当前会话出现新的后台任务时,自动激活任务管理页(每个新任务都会触发);宽屏同时展开侧边栏,窄屏不强制展开全屏抽屉;关闭后需手动打开', settingsToolsTitle: '为模型注入终端工具', settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)', - settingsFenceTitle: '工作区路径检测', - settingsFenceDesc: '开启后,侧栏的文件功能仅能访问会话工作区内的路径(默认);关闭后可访问主机上任意文件——关闭期间页面内脚本也将获得同等访问能力', - fenceErrorReason: '此路径在会话工作区之外,已被工作区检测拦截', - fenceDisableAction: '关闭工作区检测', settingsBottomTerminalTitle: '底部面板首次展开自动开终端', settingsBottomTerminalDesc: '每次会话中第一次展开底部面板时,尝试在底部面板自动打开一个新终端标签(终端数量上限仍会限制;默认开启)', settingsFontFamilyTitle: '终端字体', @@ -788,10 +784,6 @@ export const en: Record = { settingsJobsDesc: 'Activate the Tasks page whenever a new background job appears for the current conversation (every new job triggers); wide viewports also expand the side card, while narrow full-screen drawers are not forced open; turn off to open it manually', settingsToolsTitle: 'Inject terminal tools for the model', settingsToolsDesc: 'When enabled, the model can create and drive sidebar terminals through the 8 terminal_* tools (off by default)', - settingsFenceTitle: 'Workspace path fence', - settingsFenceDesc: 'On, the sidebar\'s file features only reach paths inside the session workspace (default); off, any file on the host is reachable — page scripts gain the same reach while it is off', - fenceErrorReason: 'This path is outside the session workspace and was blocked by the workspace fence', - fenceDisableAction: 'Turn off the workspace fence', settingsBottomTerminalTitle: 'Auto-open a terminal on the bottom panel\'s first expansion', settingsBottomTerminalDesc: 'When the bottom panel is expanded for the first time in a session, try to open a fresh terminal tab there (the terminal quota still applies; on by default)', settingsFontFamilyTitle: 'Terminal font family', diff --git a/src/client/native/index.ts b/src/client/native/index.ts index c126034..7b90be2 100644 --- a/src/client/native/index.ts +++ b/src/client/native/index.ts @@ -185,6 +185,10 @@ export interface NativeSurfaceDeps { */ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void { const { ctx, store, service, records, reportFailure } = deps + // Bind the store BEFORE any body can render: the per-session state is the + // authority for the explorer's expansion set, so the native surface and the + // workbench share one set per session and a closed tab cannot drop it. + records.attachStore(store) // Wait for the tab-type REGISTRY (a service), not for the slot declaration: // the native seat declares `sidebar.right.pane.tab` BEFORE it provides // `sidebarRightTabs`, so a declaration-triggered registration reads the diff --git a/src/client/native/tab-adapter.tsx b/src/client/native/tab-adapter.tsx index 89bfa21..35c79cf 100644 --- a/src/client/native/tab-adapter.tsx +++ b/src/client/native/tab-adapter.tsx @@ -14,7 +14,10 @@ * record + `navigation.params` and kept live across navigations, so a * component that retitles itself (`updateTab`) or rewrites its path (the * editor's in-place file switch) keeps working; - * - the explorer's per-tab expansion/reveal sets; + * - the explorer's expansion/reveal sets: EXPANSION lives in the per-session + * state the workbench already uses (one set per session, shared by every + * native tab of that session — a closed tab cannot drop it), while the + * transient "show in folder" reveal stays per tab; * - the per-kind instance counter behind titles like "Terminal 2". * * Nothing here is a singleton: the registry is created once per client @@ -28,6 +31,7 @@ import { RenderBoundary } from '../RenderBoundary.tsx' import { OrphanedTab } from '../OrphanedTab.tsx' import { referenceInChat } from '../reference-in-chat.ts' import type { BetterSidebarService } from '../service.ts' +import { toggleExpanded } from '../state.ts' import type { SidebarStore, SidebarTab, TabType } from '../state.ts' import css from '../sidebar.module.css' @@ -77,7 +81,15 @@ export interface NativeTabInfo { interface View { tab: SidebarTab scope: SessionScope + /** + * A PROJECTION of the session's expansion set (never an authoritative copy): + * refreshed from the store on every `ensure` and on every store change, so + * every native tab of one session shows the same set and reopening a tab + * cannot reset it. Cached only for referential stability — a fresh array on + * each render would re-run the tree's load effect for nothing. + */ expanded: string[] + /** The transient "show in folder" highlight: per tab by design. */ revealed: string[] /** Bumped on every mutation; the components subscribe to it. */ version: number @@ -114,7 +126,14 @@ export interface NativeTabRecords { update(id: string, patch: { title?: string; path?: string; meta?: unknown }): void /** Forget a record (the native tab closed). */ drop(id: string): void - /** Toggle one directory in a record's expansion set. */ + /** + * Bind the plugin store — the per-session state is the AUTHORITY for every + * view's expansion set. Idempotent; the first call also subscribes to store + * changes so a toggle made anywhere (the workbench, another native tab, a + * persisted restore) refreshes every native view. + */ + attachStore(store: SidebarStore): void + /** Toggle one directory in a record's SESSION expansion set. */ toggleExpanded(id: string, path: string): void /** Mint the next instance number of a kind (titles like "Terminal 2"). */ nextInstance(kind: string): number @@ -124,6 +143,11 @@ export interface NativeTabRecords { subscribe(listener: () => void): () => void } +/** Whether two expansion sets hold the same paths in the same order. */ +function samePaths(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((path, index) => path === right[index]) +} + /** Create the record registry for one client activation. */ export function createNativeTabRecords(): NativeTabRecords { const views = new Map() @@ -134,7 +158,39 @@ export function createNativeTabRecords(): NativeTabRecords { views.set(id, { ...view, version: view.version + 1 }) notify() } + /** The session-state authority (bound through {@link NativeTabRecords.attachStore}). */ + let store: SidebarStore | undefined + /** The session's expansion set (absent session = nothing expanded yet). */ + const expandedOf = (sessionId: string): string[] => + store?.getSessionStates().get(sessionId)?.expanded ?? [] + /** + * Re-read every view's expansion projection from the session state. Views + * that changed get a version bump (which is what re-renders their bodies); + * identical sets keep their array identity so the tree's `expanded` prop — + * and therefore its load effect — stays stable. + */ + const syncExpanded = (): void => { + if (store === undefined) return + // One map copy for the whole pass (getSessionStates copies by contract). + const states = store.getSessionStates() + let changed = false + for (const [id, entry] of views) { + const next = states.get(entry.scope.sessionId)?.expanded ?? [] + if (samePaths(entry.expanded, next)) continue + views.set(id, { ...entry, expanded: next, version: entry.version + 1 }) + changed = true + } + if (changed) notify() + } return { + attachStore(next) { + if (store === next) return + store = next + // One listener for the whole registry: the store lives as long as this + // activation, so the returned disposer is not kept. + next.subscribe(syncExpanded) + syncExpanded() + }, ensure({ id, kind, title, params, scope, mint }) { const existing = views.get(id) if (existing === undefined) { @@ -150,7 +206,7 @@ export function createNativeTabRecords(): NativeTabRecords { ...(meta === undefined ? {} : { meta }), }, scope, - expanded: [], + expanded: expandedOf(scope.sessionId), revealed: [], version: 0, } @@ -169,6 +225,13 @@ export function createNativeTabRecords(): NativeTabRecords { : {} patch.meta = { ...meta, url: params.url } } + // The expansion set always mirrors the CURRENT session state (a record + // reused for another session must not keep the previous one's set). + const expanded = expandedOf(scope.sessionId) + if (existing.scope.sessionId !== scope.sessionId || !samePaths(existing.expanded, expanded)) { + views.set(id, { ...existing, scope, expanded, tab: { ...existing.tab, ...patch } }) + return views.get(id)! + } if (existing.scope.cwd !== scope.cwd) { views.set(id, { ...existing, scope, tab: { ...existing.tab, ...patch } }) return views.get(id)! @@ -190,11 +253,17 @@ export function createNativeTabRecords(): NativeTabRecords { }, toggleExpanded(id, path) { const entry = views.get(id) - if (entry === undefined) return - const expanded = entry.expanded.includes(path) - ? entry.expanded.filter(candidate => candidate !== path) - : [...entry.expanded, path] - put(id, { ...entry, expanded }) + if (entry === undefined || store === undefined) return + const sessionId = entry.scope.sessionId + // The ACTIVE session goes through `reduce` (it notifies, so the native + // views refresh through the subscription); a background session goes + // through `reduceFor`, which deliberately stays silent — refresh here. + if (store.getSnapshot().sessionId === sessionId) { + store.reduce(state => toggleExpanded(state, path)) + return + } + store.reduceFor(sessionId, state => toggleExpanded(state, path)) + syncExpanded() }, nextInstance(kind) { const next = (instances.get(kind) ?? 0) + 1 diff --git a/src/client/prefs.ts b/src/client/prefs.ts index 7d4b9a0..9bb9bd9 100644 --- a/src/client/prefs.ts +++ b/src/client/prefs.ts @@ -60,9 +60,6 @@ export function parsePrefs(value: unknown): SidebarPrefs { editorExplorer: typeof record.editorExplorer === 'boolean' ? record.editorExplorer : SIDEBAR_PREFS_DEFAULTS.editorExplorer, - workspaceFence: typeof record.workspaceFence === 'boolean' - ? record.workspaceFence - : SIDEBAR_PREFS_DEFAULTS.workspaceFence, // The title-bar scheme (auto | web | preset | custom). The schema // declares the field WITHOUT a default, so documents written by older // plugin versions resolve without it — migrate from the legacy fields: diff --git a/src/client/service.ts b/src/client/service.ts index 1d13c0e..2415b86 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -639,7 +639,7 @@ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDes * The plugin version this service instance reports. Keep in lockstep with * `package.json`'s version — `tests/service.spec.ts` asserts the pair. */ -export const SIDEBAR_SERVICE_VERSION = '0.22.0' +export const SIDEBAR_SERVICE_VERSION = '0.23.0' /** * Monotonic capability list consumers use to gate new API usage (features diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index ec5625c..ba84c08 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -1414,18 +1414,6 @@ body[data-dsh-sidebar-dragging] .bottomPanel { color: var(--dsw-alias-state-error-primary); } -/* The workspace-fence refusal row (FenceErrorNotice): reason text plus the - * one-click global-off button share one baseline, wrapping when narrow. */ -.fenceError { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 8px; - padding: 8px 16px; - font: var(--dsw-font-xxs-12); - color: var(--dsw-alias-state-error-primary); -} - .editorBanner { flex: none; padding: 4px 8px; diff --git a/src/config.ts b/src/config.ts index f2f9fbc..14a5742 100644 --- a/src/config.ts +++ b/src/config.ts @@ -76,7 +76,15 @@ export function resolveSidebarConfig(config: SidebarConfig | undefined): Resolve // ── User-facing "Side card" preferences ───────────────────────────────────── -/** Schemastery schema for the user-facing preferences (validated by the settings service). */ +/** + * Schemastery schema for the user-facing preferences (validated by the + * settings service). + * + * Asserted as `z` (the interface lives in `prefs-shared.ts`; the + * explicit annotation is also what keeps the emitted declaration portable). + * Unknown keys are TOLERATED by this schema and pass through untouched, which + * is what keeps a legacy `workspaceFence: true` in an old profile harmless. + */ export const PrefsSchema: z = z.object({ autoOpenSubagent: z.boolean().default(true), autoOpenJobs: z.boolean().default(true), @@ -85,7 +93,6 @@ export const PrefsSchema: z = z.object({ mobileDefaultTree: z.boolean().default(true), agentOpenTools: z.boolean().default(false), editorExplorer: z.boolean().default(false), - workspaceFence: z.boolean().default(true), titleBarScheme: z.union([z.const('auto'), z.const('web'), z.const('preset'), z.const('custom')]), titleBarPresetId: z.string(), customCss: z.string(), diff --git a/src/fs-operations.ts b/src/fs-operations.ts index c2b3392..ec92baa 100644 --- a/src/fs-operations.ts +++ b/src/fs-operations.ts @@ -1,37 +1,38 @@ /** - * Workspace-safe file mutations for the sidebar (the upload route today). + * File mutations for the sidebar (the upload route and the tree's renames, + * deletes and mkdirs). * - * Every write is confined to the real session workspace: the upload - * directory is resolved absolute and its target is checked through existing - * filesystem ancestors, the relative path is sanitized (absolute paths, '.', - * '..' and empty segments are refused), and the final target must stay inside - * the workspace after symlink resolution. Bytes stream from the request body - * to a uniquely named temp sibling - * and are renamed into place, so a failed, aborted, or oversized upload never - * leaves a partial file at the target path. + * ⚠️ **No workspace containment any more.** The guard was removed at the + * user's request, so these operations reach whatever the HOST USER can reach. + * What is still enforced is the SHAPE of a request: the relative upload path + * is sanitized (absolute paths, '.', '..' and empty segments are refused), a + * rename/mkdir name must be one path segment, an existing destination is + * refused instead of clobbered, and the session workspace root itself is never + * renamable or removable. Bytes stream from the request body to a uniquely + * named temp sibling and are renamed into place, so a failed, aborted, or + * oversized upload never leaves a partial file at the target path. * - * The tree's rename/delete (below) are link-aware: existence and containment - * are verified against the fully resolved target (a symlink pointing outside - * the workspace is refused while the fence is armed), but the operation - * itself addresses the lexical row path — renaming or deleting a symlink - * row renames/unlinks the LINK, never its target, matching what the tree - * row visually names (VS Code semantics). + * The tree's rename/delete are link-aware: they address the LEXICAL row path + * (lstat decides), so renaming or deleting a symlink row renames/unlinks the + * LINK, never its target — matching what the tree row visually names (VS Code + * semantics). Every mutation invalidates the directory cache of the level(s) + * it touched. */ import { randomUUID } from 'node:crypto' import { once } from 'node:events' import { createWriteStream } from 'node:fs' -import { access, lstat, mkdir, realpath, rename, rm, stat, unlink } from 'node:fs/promises' +import { access, lstat, mkdir, rename, rm, stat, unlink } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' -import { isWithin, requireAbsolute } from './fs-tree.ts' +import { invalidateDirectoryCache, requireAbsolute } from './fs-tree.ts' import { ensureWorkspacePath, ensureWorkspaceWritePath } from './path-security.ts' import { resolveSessionPath } from './session-path.ts' import { SidebarError } from './wire.ts' /** Inputs of one upload: the session scope plus the request body stream. */ export interface WorkspaceUploadInput { - /** The session workspace root; target and directory must stay inside it. */ + /** The session workspace root (the base of session-relative targets). */ cwd: string - /** Absolute upload directory chosen by the client (inside `cwd`). */ + /** Absolute upload directory chosen by the client. */ dir: string /** Relative path below `dir` (absolute paths, '.', '..' and empty segments refused). */ relativePath: string @@ -39,7 +40,7 @@ export interface WorkspaceUploadInput { chunks: AsyncIterable /** Byte cap; an oversized upload is refused without touching the target. */ limit: number - /** Whether workspace containment is enforced (the `workspaceFence` setting; on by default). */ + /** @deprecated IGNORED — containment was removed. Do not pass it. */ fence?: boolean } @@ -51,13 +52,13 @@ export interface WorkspaceUploadInput { * target independent (each writes and renames its own file; the last rename * wins) and never blocks later uploads after a crashed process. * - * @throws SidebarError with a wire code for containment, shape, and size - * failures; the temp file is always removed on failure. + * @throws SidebarError with a wire code for shape and size failures; the temp + * file is always removed on failure. */ export async function writeWorkspaceUpload(input: WorkspaceUploadInput): Promise<{ path: string; size: number }> { - const { cwd, dir, relativePath, chunks, limit, fence = true } = input + const { cwd, dir, relativePath, chunks, limit } = input const base = requireAbsolute(dir) - await ensureWorkspacePath(cwd, base, fence) + await ensureWorkspacePath(cwd, base) if (relativePath === '' || relativePath.startsWith('/') || relativePath.startsWith('\\')) { throw new SidebarError('bad-request', 'relativePath must stay below the upload directory', 400) } @@ -66,7 +67,7 @@ export async function writeWorkspaceUpload(input: WorkspaceUploadInput): Promise throw new SidebarError('bad-request', 'relativePath must stay below the upload directory', 400) } const target = join(base, ...segments) - const safeTarget = await ensureWorkspaceWritePath(cwd, target, fence) + const safeTarget = await ensureWorkspaceWritePath(cwd, target) const tmp = join(dirname(safeTarget), `.${basename(safeTarget)}.dsh-upload-${randomUUID()}.tmp`) await mkdir(dirname(safeTarget), { recursive: true }) const stream = createWriteStream(tmp, { flags: 'wx' }) @@ -92,6 +93,8 @@ export async function writeWorkspaceUpload(input: WorkspaceUploadInput): Promise if (streamError !== undefined) throw streamError await rename(tmp, safeTarget) const info = await stat(safeTarget) + // The target's level (and, for a new folder, its parent) is now stale. + invalidateDirectoryCache(dirname(safeTarget)) return { path: target, size: info.size } } catch (error) { // Wait for the stream to fully close before unlinking (Windows locks open @@ -105,36 +108,29 @@ export async function writeWorkspaceUpload(input: WorkspaceUploadInput): Promise /** Inputs of one tree-row rename. */ export interface WorkspaceRenameInput { - /** The session workspace root; the renamed entry must stay inside it. */ + /** The session workspace root (the base of session-relative targets). */ cwd: string /** Absolute path of the row as the tree displays it (may be a symlink). */ path: string /** The new base name (single segment — rename never moves across directories). */ name: string - /** Whether workspace containment is enforced (the `workspaceFence` setting; on by default). */ + /** @deprecated IGNORED — containment was removed. Do not pass it. */ fence?: boolean } -/** Resolve one existing entry for a link-aware mutation: the lexical row path - * plus its fully resolved real target (fence-checked). ENOENT becomes an - * fs-error, mirroring path-security's resolveRealPath semantics. */ +/** + * Resolve one existing entry for a link-aware mutation: the lexical row path + * plus the resolved workspace root (for the "never rename/remove the root" + * check). No realpath, no containment: the path exists (lstat decides) and the + * operation addresses it as written. + */ async function resolveEntry( cwd: string, target: string, - fence: boolean, ): Promise<{ absolute: string; real: string; realCwd: string }> { const absolute = requireAbsolute(resolveSessionPath(cwd, target)) - let real: string - let realCwd: string - try { - ;[realCwd, real] = await Promise.all([realpath(cwd), realpath(absolute)]) - } catch (error) { - throw new SidebarError('fs-error', `cannot resolve "${target}": ${error instanceof Error ? error.message : String(error)}`, 400) - } - if (fence && !isWithin(realCwd, real)) { - throw new SidebarError('forbidden', `path "${target}" is outside workspace`, 403) - } - return { absolute, real, realCwd } + const realCwd = requireAbsolute(cwd) + return { absolute, real: absolute, realCwd } } /** Whether a path exists (ENOENT → false; other failures propagate). */ @@ -156,21 +152,21 @@ async function pathExists(target: string): Promise { * renames the link, not its target. A no-op rename (same name) succeeds * without touching the filesystem. * - * @throws SidebarError with a wire code for shape, containment, existence - * and root failures. + * @throws SidebarError with a wire code for shape, existence and root + * failures. */ export async function renameWorkspaceEntry(input: WorkspaceRenameInput): Promise<{ path: string }> { - const { cwd, path, name, fence = true } = input + const { cwd, path, name } = input if (name === '' || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) { throw new SidebarError('bad-request', 'name must be a single path segment', 400) } - const { absolute, real, realCwd } = await resolveEntry(cwd, path, fence) + const { absolute, real, realCwd } = await resolveEntry(cwd, path) if (real === realCwd) { throw new SidebarError('fs-error', 'cannot rename the workspace root', 400) } if (basename(absolute) === name) return { path: absolute } const destination = join(dirname(absolute), name) - const safeDestination = await ensureWorkspaceWritePath(cwd, destination, fence) + const safeDestination = await ensureWorkspaceWritePath(cwd, destination) if (await pathExists(safeDestination)) { throw new SidebarError('fs-error', `"${name}" already exists`, 409) } @@ -179,18 +175,19 @@ export async function renameWorkspaceEntry(input: WorkspaceRenameInput): Promise } catch (error) { throw new SidebarError('fs-error', `cannot rename "${path}" to "${name}": ${error instanceof Error ? error.message : String(error)}`, 400) } + invalidateDirectoryCache(dirname(safeDestination)) return { path: safeDestination } } /** Inputs of one new directory row. */ export interface WorkspaceMkdirInput { - /** The session workspace root; the new directory must stay inside it. */ + /** The session workspace root (the base of session-relative targets). */ cwd: string /** Absolute path of the PARENT row as the tree displays it (a directory). */ path: string /** The new directory's base name (single segment — mkdir never nests). */ name: string - /** Whether workspace containment is enforced (the `workspaceFence` setting; on by default). */ + /** @deprecated IGNORED — containment was removed. Do not pass it. */ fence?: boolean } @@ -198,19 +195,18 @@ export interface WorkspaceMkdirInput { * Create one directory inside an existing tree row: `/`. * The name must be a single path segment; an existing destination is refused * (mkdir would otherwise fail with EEXIST anyway, but the explicit check - * yields the same "already exists" sentence rename uses); the parent must - * resolve inside the (fence-checked) workspace. + * yields the same "already exists" sentence rename uses); the parent row may + * be any directory the host user can write. * - * @throws SidebarError with a wire code for shape, containment and existence - * failures. + * @throws SidebarError with a wire code for shape and existence failures. */ export async function mkdirWorkspaceEntry(input: WorkspaceMkdirInput): Promise<{ path: string }> { - const { cwd, path, name, fence = true } = input + const { cwd, path, name } = input if (name === '' || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) { throw new SidebarError('bad-request', 'name must be a single path segment', 400) } - const { absolute } = await resolveEntry(cwd, path, fence) - const destination = await ensureWorkspaceWritePath(cwd, join(absolute, name), fence) + const { absolute } = await resolveEntry(cwd, path) + const destination = await ensureWorkspaceWritePath(cwd, join(absolute, name)) if (await pathExists(destination)) { throw new SidebarError('fs-error', `"${name}" already exists`, 409) } @@ -219,16 +215,18 @@ export async function mkdirWorkspaceEntry(input: WorkspaceMkdirInput): Promise<{ } catch (error) { throw new SidebarError('fs-error', `cannot create "${name}": ${error instanceof Error ? error.message : String(error)}`, 400) } + // The PARENT level gained a row; the new directory's own level is empty. + invalidateDirectoryCache(absolute) return { path: destination } } /** Inputs of one tree-row delete. */ export interface WorkspaceRemoveInput { - /** The session workspace root; the removed entry must stay inside it. */ + /** The session workspace root (the base of session-relative targets). */ cwd: string /** Absolute path of the row as the tree displays it (may be a symlink). */ path: string - /** Whether workspace containment is enforced (the `workspaceFence` setting; on by default). */ + /** @deprecated IGNORED — containment was removed. Do not pass it. */ fence?: boolean } @@ -238,12 +236,11 @@ export interface WorkspaceRemoveInput { * only (lstat decides, so a link to a directory does not recurse into its * target). The workspace root itself is never removable. * - * @throws SidebarError with a wire code for containment, existence and - * root failures. + * @throws SidebarError with a wire code for existence and root failures. */ export async function removeWorkspaceEntry(input: WorkspaceRemoveInput): Promise<{ path: string }> { - const { cwd, path, fence = true } = input - const { absolute, real, realCwd } = await resolveEntry(cwd, path, fence) + const { cwd, path } = input + const { absolute, real, realCwd } = await resolveEntry(cwd, path) if (real === realCwd) { throw new SidebarError('fs-error', 'cannot remove the workspace root', 400) } @@ -254,5 +251,6 @@ export async function removeWorkspaceEntry(input: WorkspaceRemoveInput): Promise } catch (error) { throw new SidebarError('fs-error', `cannot remove "${path}": ${error instanceof Error ? error.message : String(error)}`, 400) } + invalidateDirectoryCache(dirname(absolute)) return { path: absolute } } diff --git a/src/fs-tree.ts b/src/fs-tree.ts index 89cdc5f..c794b5c 100644 --- a/src/fs-tree.ts +++ b/src/fs-tree.ts @@ -1,14 +1,21 @@ /** - * 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 - * 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. + * Single-level directory listing for the sidebar explorer. + * + * The hot path here IS the explorer's latency budget, so the implementation is + * deliberately allocation-shy: one `readdir(withFileTypes)` call (no + * `opendir` stream), rows built only for the capped slice, paths composed by + * string concatenation, and a hand-rolled case-insensitive sort (see + * {@link compareEntries} — `localeCompare` alone costs ~15ms/10k rows, ~40x + * the whole listing budget). A short TTL cache ({@link DIRECTORY_CACHE_TTL_MS}) + * absorbs the repeat listings a tree view makes on every re-render. + * + * Row semantics are unchanged: directories first, case-insensitive name order + * with a deterministic tie-break, POSIX-hidden entries flagged for dimming, + * symlinks stat'ed once so a link to a directory expands like a directory and + * a dangling one is flagged broken. */ -import { opendir, stat } from 'node:fs/promises' -import { basename, dirname, isAbsolute, join, resolve } from 'node:path' +import { readdir, stat } from 'node:fs/promises' +import { basename, dirname, isAbsolute, resolve, sep } from 'node:path' import { SidebarError } from './wire.ts' /** One explorer row. */ @@ -30,60 +37,142 @@ export interface SidebarFsListing { truncated: boolean } -/** Directory-first, case-insensitive name ordering (VSCode explorer order). */ +/** + * Directory-first, case-insensitive name ordering (VSCode explorer order). + * + * `toLowerCase()` + code-point comparison instead of `localeCompare`: measured + * at ~0.35ms/10k rows against ~14.6ms for the collator, and the tie-break on + * the ORIGINAL name keeps the order total and deterministic for names that + * only differ in case (`A` before `a`). + */ export function compareEntries(a: SidebarFsEntry, b: SidebarFsEntry): number { if (a.isDir !== b.isDir) return a.isDir ? -1 : 1 - return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) + const la = a.name.toLowerCase() + const lb = b.name.toLowerCase() + if (la !== lb) return la < lb ? -1 : 1 + if (a.name === b.name) return 0 + return a.name < b.name ? -1 : 1 +} + +/** How many symlink target stats run in flight during one level listing. */ +const SYMLINK_PROBE_CONCURRENCY = 32 + +/** + * How long one listed level stays valid. Short on purpose: it absorbs the + * re-listing a re-render (or a burst of tree opens) causes without hiding + * changes for perceptibly long, and every writer invalidates it explicitly + * anyway (see {@link invalidateDirectoryCache}). + */ +export const DIRECTORY_CACHE_TTL_MS = 1_500 + +/** + * The level cache, keyed by `"\0"`. + * + * Keyed by ABSOLUTE PATH, not by session: a listing is a pure function of the + * path plus the cap, and its rows already carry absolute paths, so sharing an + * entry between two sessions that read the same directory is correct (and + * desirable — one directory read serves both). Nothing session-scoped is + * stored, so no session's data can leak into another's listing. + */ +const directoryCache = new Map() + +/** Cache key of one level read. */ +function cacheKey(path: string, maxEntries: number): string { + return `${path}\0${maxEntries}` } /** - * List one directory level. + * Drop the cached level(s) for one directory (and, when called with no + * argument, everything). Writers call this after any mutation: `fs.write`, + * `fs.rename`, `fs.remove`, `fs.mkdir`, the upload route and the fs-watch + * notifier all funnel here, so a stale level is never served past the write + * that changed it. + * @param path - absolute (or session-relative) directory to invalidate; absent + * clears the whole cache. + */ +export function invalidateDirectoryCache(path?: string): void { + if (path === undefined) { + directoryCache.clear() + return + } + const target = resolve(path) + const prefix = `${target}\0` + for (const key of directoryCache.keys()) { + if (key.startsWith(prefix)) directoryCache.delete(key) + } +} + +/** Max entries tracked in the TTL cache before the oldest are evicted. */ +const DIRECTORY_CACHE_MAX = 512 + +/** Read one level through the cache when it is fresh. */ +async function listDirectoryCached(path: string, maxEntries: number): Promise { + const key = cacheKey(path, maxEntries) + const now = Date.now() + const hit = directoryCache.get(key) + if (hit !== undefined && now - hit.at < DIRECTORY_CACHE_TTL_MS) return hit.listing + const listing = await readDirectory(path, maxEntries) + // Bounded: a deep walk of many directories must not grow this map without + // limit (entries are tiny, but an unbounded map in a long-lived host is a + // leak). Map iteration order is insertion order, so the first key is oldest. + if (directoryCache.size >= DIRECTORY_CACHE_MAX) { + const oldest = directoryCache.keys().next().value + if (oldest !== undefined) directoryCache.delete(oldest) + } + directoryCache.set(key, { at: now, listing }) + return listing +} + +/** + * List one directory level (cached for {@link DIRECTORY_CACHE_TTL_MS}). * @param path - absolute directory path. * @param maxEntries - row bound of one level (extra rows flag `truncated`). * @returns the sorted listing. * @throws {SidebarError} fs-error when the level is unreadable or not a directory. */ export async function listDirectory(path: string, maxEntries = 1000): Promise { - let level - try { - level = await opendir(path) - } catch (error) { - throw new SidebarError('fs-error', `cannot list "${path}": ${messageOf(error)}`, 400) - } - const rows: SidebarFsEntry[] = [] - let overflow = 0 - try { - for await (const dirent of level) { - if (rows.length >= maxEntries) { - overflow += 1 - continue - } - // Platform join: on Windows the level path uses '\' — a hardcoded '/' - // would leak mixed separators into every row's path. - rows.push({ - name: dirent.name, - path: join(path, dirent.name), - isDir: dirent.isDirectory(), - isSymlink: dirent.isSymbolicLink(), - broken: false, - hidden: dirent.name.startsWith('.'), - }) - } - } catch (error) { - throw new SidebarError('fs-error', `cannot list "${path}": ${messageOf(error)}`, 400) - } - // Probe symlink targets AFTER the readdir stream closes, with bounded - // concurrency: a symlink-heavy level (UNC/network targets) would otherwise - // serialize up to maxEntries stat calls and stall the explorer. Non-symlink - // rows are skipped by the probe, so levels without links stay as cheap as - // before. - await probeSymlinkTargets(rows) - rows.sort(compareEntries) - return { path, entries: rows, truncated: overflow > 0 } + return await listDirectoryCached(path, maxEntries) } -/** How many symlink target stats run in flight during one level listing. */ -const SYMLINK_PROBE_CONCURRENCY = 32 +/** Read one level straight from the filesystem (the cache's slow path). */ +async function readDirectory(path: string, maxEntries: number): Promise { + let dirents + try { + // One syscall batch instead of an `opendir` stream: `readdir` with types is + // roughly half the wall time of `for await (const dirent of await opendir())` + // and gives the same Dirent objects. + dirents = await readdir(path, { withFileTypes: true }) + } catch (error) { + throw new SidebarError('fs-error', `cannot list "${path}": ${messageOf(error)}`, 400) + } + const truncated = dirents.length > maxEntries + // Build rows ONLY for the rows that survive the cap: composing a row costs + // ~0.3µs (path concat + three flags), so an uncapped 10k level would spend + // ~3ms on rows nobody will see. + const kept = truncated ? dirents.slice(0, maxEntries) : dirents + const prefix = path.endsWith(sep) ? path : `${path}${sep}` + const rows: SidebarFsEntry[] = new Array(kept.length) + for (let index = 0; index < kept.length; index += 1) { + const dirent = kept[index]! + // String concat over `path.join`: ~10x faster on 10k rows, and the + // separator stays platform-correct (`sep`). + rows[index] = { + name: dirent.name, + path: `${prefix}${dirent.name}`, + isDir: dirent.isDirectory(), + isSymlink: dirent.isSymbolicLink(), + broken: false, + hidden: dirent.name.startsWith('.'), + } + } + // Probe symlink targets AFTER the read, with bounded concurrency: a + // symlink-heavy level (UNC/network targets) would otherwise serialize up to + // maxEntries stat calls and stall the explorer. Non-symlink rows are skipped, + // so levels without links stay as cheap as before. + await probeSymlinkTargets(rows) + rows.sort(compareEntries) + return { path, entries: rows, truncated } +} /** Probe each symlink row's target once (bounded concurrency, order-preserving). */ async function probeSymlinkTargets(rows: SidebarFsEntry[], concurrency = SYMLINK_PROBE_CONCURRENCY): Promise { @@ -134,11 +223,14 @@ export function requireAbsolute(path: string): string { /** * Whether `target` lies under `base` (or equals it), tolerant of separator * style and — on Windows, where the filesystem is case-insensitive — of - * letter case. The media route uses this instead of a raw `startsWith` so a - * case-mismatched or mixed-separator path can never be misclassified - * (e.g. `C:\Users\Me` vs `c:/users/me/file.png`). + * letter case. + * + * LEXICAL ONLY, and no longer a security boundary: the sidebar's containment + * fence was removed (see path-security.ts), so nothing in this plugin decides + * access by this function any more. It is kept for the client-side mirror + * usage and the historical tests, not as a guard. * @param platform - filesystem semantics; injectable so both branches are - * unit-testable on any host. + * unit-testable on any host. */ export function isWithin(base: string, target: string, platform: NodeJS.Platform = process.platform): boolean { const norm = (value: string): string => value.replace(/[\\/]+/g, '/').replace(/\/$/, '') diff --git a/src/fs-watch.ts b/src/fs-watch.ts index 4da6963..b94c971 100644 --- a/src/fs-watch.ts +++ b/src/fs-watch.ts @@ -11,9 +11,14 @@ * not the whole workspace — because `fs.watch` is one OS handle per directory * and a deep tree would exhaust them. Collapsing a folder or closing the * socket releases its handle. + * + * A debounced change also drops the host's listing cache for that directory + * (`invalidateDirectoryCache`), so the re-list the client is about to ask for + * cannot be answered from a level cached before the change. * @module dsh-better-sidebar/fs-watch */ import { watch, type FSWatcher } from 'node:fs' +import { invalidateDirectoryCache } from './fs-tree.ts' /** How long a burst of filesystem events is folded into a single push. */ const DEBOUNCE_MS = 150 @@ -80,7 +85,14 @@ export function createDirectoryWatchers( if (entry === undefined || entry.timer !== undefined) return entry.timer = setTimeout(() => { entry.timer = undefined - if (watchers.has(dir)) push({ dir }) + if (!watchers.has(dir)) return + // The host's listing cache would otherwise answer the re-list with the + // pre-change level for up to its TTL. The write routes invalidate + // themselves; this covers everything that changed BEHIND the plugin + // (another editor, a build, the model's own bash), which is exactly why + // the watcher exists. + invalidateDirectoryCache(dir) + push({ dir }) }, DEBOUNCE_MS) // A pending re-list must never hold the host process open. entry.timer.unref() diff --git a/src/index.ts b/src/index.ts index 3c73fb5..260e87d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,7 @@ * session's authoritative cwd comes from the session store. */ import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' -import { basename, dirname, extname, isAbsolute, join } from 'node:path' +import { basename, dirname, extname, isAbsolute, join, sep } from 'node:path' import type { IncomingMessage } from 'node:http' import type { Duplex } from 'node:stream' import { WebSocket, WebSocketServer } from 'ws' @@ -28,7 +28,7 @@ import { type SidebarConfig, type SidebarPrefs, } from './config.ts' -import { parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts' +import { invalidateDirectoryCache, listDirectory, messageOf, parentOf, requireAbsolute, rootLabel, type SidebarFsEntry } from './fs-tree.ts' import { resolveSessionPath } from './session-path.ts' import { mkdirWorkspaceEntry, renameWorkspaceEntry, removeWorkspaceEntry, writeWorkspaceUpload } from './fs-operations.ts' import { ensureWorkspacePath, ensureWorkspaceWritePath } from './path-security.ts' @@ -75,6 +75,18 @@ export { archiveNameOf, collectZipEntries, contentDispositionOf, disambiguateArc /** Plugin identity for cordis.yml rows. */ export const name = 'dsh-better-sidebar' +/** Row bound of one `fs.trees` batch request (a mount/refresh sends what it shows). */ +export const FS_TREES_MAX_PATHS = 64 + +/** One level of an `fs.trees` batch: either a listing or that level's failure. */ +export interface SidebarFsLevel { + path: string + entries: SidebarFsEntry[] + truncated: boolean + /** Present only when THIS level failed (the batch itself still succeeded). */ + error?: string +} + /** Services required before mounting: the webserver routes, the session store, the web runtime's trusted hosts, and the tool registry. */ export const inject = ['webServer', 'sessions', 'webRuntime', 'tools'] @@ -235,19 +247,6 @@ export interface SidebarSettingsFace { update(patch: Record, expectedRevision?: number): Promise<{ value?: unknown; revision?: number }> } -/** - * Whether the workspace fence is armed for the sidebar's filesystem routes - * (the settings-page `workspaceFence` switch under the files card's gear). - * An absent settings service or a missing field keeps the fence ON — the - * containment default never depends on the settings surface being reachable. - */ -function fenceEnabledOf(getSettings: () => SidebarSettingsFace | undefined): boolean { - const settings = getSettings() - const value = settings?.get().value - if (value === null || typeof value !== 'object') return true - return (value as Record).workspaceFence !== false -} - function buildApi( ctx: Context, resolved: ResolvedSidebarConfig, @@ -292,9 +291,38 @@ function buildApi( 'fs.tree': async (payload) => { const { cwd } = await cwdOf(payload) const record = payload as { path?: unknown } - const target = record.path === undefined ? cwd : await ensureWorkspacePath(cwd, requireString(payload, 'path'), fenceEnabledOf(getSettings)) + const target = record.path === undefined ? cwd : await ensureWorkspacePath(cwd, requireString(payload, 'path')) return listDirectory(target, resolved.listLimit) }, + // Batch listing: one request for every level the tree has expanded, so a + // mount/refresh costs one round trip instead of N. Each path rides the + // SAME resolution + cache as `fs.tree` (so a level listed here is warm for + // the single-path route and vice versa), and one failing level is reported + // in place — the other levels still render. + 'fs.trees': async (payload) => { + const { cwd } = await cwdOf(payload) + const record = payload as { paths?: unknown } | null + const paths = Array.isArray(record?.paths) + ? record.paths.filter((value): value is string => typeof value === 'string' && value !== '') + : [] + if (paths.length === 0) throw new SidebarError('bad-request', 'paths must be a non-empty array') + if (paths.length > FS_TREES_MAX_PATHS) { + throw new SidebarError('bad-request', `too many paths (max ${FS_TREES_MAX_PATHS})`) + } + const levels = await Promise.all(paths.map(async (raw): Promise => { + // A session-relative path is accepted here (the tree already carries + // cwd-relative paths); `fs.tree` itself keeps requiring absolute input. + const requested = isAbsolute(raw) ? raw : `${cwd}${sep}${raw}` + try { + return await listDirectory(await ensureWorkspacePath(cwd, requested), resolved.listLimit) + } catch (error) { + // Per-level failure: the batch itself stays a success (one unreadable + // directory must not blank the whole tree). + return { path: requested, entries: [], truncated: false, error: messageOf(error) } + } + })) + return { levels } + }, '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 @@ -310,14 +338,14 @@ function buildApi( // child-repo path is relative to the selected repoRoot, not the session // cwd; thread it so the path resolves inside the authorized workspace. const selected = selectedRepoOf(payload) - const path = await ensureWorkspacePath(cwd, await resolveGitPath(cwd, requireString(payload, 'path'), selected), fenceEnabledOf(getSettings)) + const path = await ensureWorkspacePath(cwd, await resolveGitPath(cwd, requireString(payload, 'path'), selected)) const { content, truncated, binary, size, head } = await readText(path, resolved.readLimit) if (binary) return { kind: 'binary', size, truncated, head } return { kind: 'text', content, truncated } }, 'fs.write': async (payload) => { const { cwd } = await cwdOf(payload) - const path = await ensureWorkspaceWritePath(cwd, requireString(payload, 'path'), fenceEnabledOf(getSettings)) + const path = await ensureWorkspaceWritePath(cwd, requireString(payload, 'path')) const content = requireString(payload, 'content') const tmp = `${path}.dsh-sidebar-tmp-${process.pid}` try { @@ -328,6 +356,7 @@ function buildApi( await rm(tmp, { force: true }).catch(() => {}) throw new SidebarError('fs-error', `cannot write "${path}": ${error instanceof Error ? error.message : String(error)}`, 400) } + invalidateDirectoryCache(dirname(path)) return { ok: true } }, // The tree row's rename: single-segment name, destination-existence and @@ -339,18 +368,16 @@ function buildApi( cwd, path: requireString(payload, 'path'), name: requireString(payload, 'name'), - fence: fenceEnabledOf(getSettings), }) }, // The tree's "new folder": one directory inside an existing row, with - // the same single-segment/existence/containment rules as rename. + // the same single-segment/existence rules as rename. 'fs.mkdir': async (payload) => { const { cwd } = await cwdOf(payload) return mkdirWorkspaceEntry({ cwd, path: requireString(payload, 'path'), name: requireString(payload, 'name'), - fence: fenceEnabledOf(getSettings), }) }, // The tree row's delete (permanent — the host has no trash): recursive @@ -360,7 +387,6 @@ function buildApi( return removeWorkspaceEntry({ cwd, path: requireString(payload, 'path'), - fence: fenceEnabledOf(getSettings), }) }, 'git.worktrees': async (payload) => { @@ -561,9 +587,8 @@ function buildApi( : [] if (paths.length === 0) throw new SidebarError('bad-request', 'paths must be a non-empty array') const name = archiveNameOf(typeof record?.name === 'string' ? record.name : null) - const fenceOn = fenceEnabledOf(getSettings) const selected: string[] = [] - for (const raw of paths) selected.push(await ensureWorkspacePath(cwd, raw, fenceOn)) + for (const raw of paths) selected.push(await ensureWorkspacePath(cwd, raw)) // Same-basename selections (a/index.ts + b/index.ts) get parent segments // prepended, so no two archive members collide. const entries: ZipEntry[] = [] @@ -931,7 +956,6 @@ export function apply(ctx: Context, config?: SidebarConfig): void { relativePath, chunks: req, limit: resolved.uploadLimit, - fence: fenceEnabledOf(() => settingsFace), }) writeOk(res, { path, size }) } catch (error) { @@ -999,7 +1023,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void { const raw = url.searchParams.get('path') if (sessionId === null || raw === null) throw new SidebarError('bad-request', 'sessionId and path are required') const cwd = await sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined) - const path = await ensureWorkspacePath(cwd, raw, fenceEnabledOf(() => settingsFace)) + const path = await ensureWorkspacePath(cwd, raw) const info = await stat(path) if (!info.isFile() || info.size > resolved.mediaLimit) { throw new SidebarError('fs-error', 'not a file or too large', 400) @@ -1059,7 +1083,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void { // real-path guard, with the same semantics as the media route's // fallback. const cwd = await sessionCwdOf(ctx, sessionId) - const absolute = await ensureWorkspacePath(cwd, path, fenceEnabledOf(() => settingsFace)) + const absolute = await ensureWorkspacePath(cwd, path) const info = await stat(absolute) if (!info.isFile() || info.size > resolved.mediaLimit) { throw new SidebarError('fs-error', 'not a file or too large', 400) @@ -1108,8 +1132,8 @@ export function apply(ctx: Context, config?: SidebarConfig): void { // stale for the rest of the session. One socket per session carries the // reader's expanded-folder set; the host watches exactly those directories // and pushes a debounced notice per change, so the tree re-lists in place. - // Paths are resolved through the same workspace fence as `fs.tree`, so a - // watch can never observe a directory the tree itself could not list. + // Paths are resolved exactly like `fs.tree`'s (same resolution, same + // lexical rules), so the watch observes what the tree can list. const fsWatchWss = new WebSocketServer({ noServer: true }) ctx.effect(() => ctx.webServer.registerUpgrade({ path: '/sidebar/ws/fs-watch', @@ -1119,7 +1143,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void { return } fsWatchWss.handleUpgrade(req as unknown as IncomingMessage, socket as unknown as Duplex, head as Buffer, (ws) => { - void attachFsWatch(ctx, ws, req, () => fenceEnabledOf(() => settingsFace)) + void attachFsWatch(ctx, ws, req) }) }, }), 'dsh-better-sidebar: file-tree watch WebSocket') @@ -1145,16 +1169,14 @@ interface FsWatchFrame { * the session's workspace exactly like `fs.tree`'s. A path that fails * resolution, or a rejection past the watcher cap, is answered with * `{ dir, ok: false }` so the client can stop asking rather than retry. - * @param ctx - host plugin context (session cwd, workspace fence). + * @param ctx - host plugin context (session cwd). * @param ws - the accepted socket. * @param req - the upgrade request carrying `?sessionId=`. - * @param fenceEnabled - whether the workspace containment fence is on. */ async function attachFsWatch( ctx: Context, ws: WebSocket, req: SidebarHttpRequest, - fenceEnabled: () => boolean, ): Promise { try { const url = new URL(req.url ?? '/', 'http://dsh.internal') @@ -1176,7 +1198,7 @@ async function attachFsWatch( ws.on('close', () => { watchers.close() }) ws.on('error', () => { watchers.close() }) ws.on('message', (data) => { - void handleFsWatchFrame(ctx, ws, watchers, sessionId, data, fenceEnabled) + void handleFsWatchFrame(ctx, ws, watchers, sessionId, data) }) } catch (error) { ws.close(1011, error instanceof Error ? error.message : String(error)) @@ -1190,7 +1212,6 @@ async function attachFsWatch( * @param watchers - the socket's watcher set. * @param sessionId - the session the socket was opened for. * @param data - the raw frame text. - * @param fenceEnabled - whether the workspace containment fence is on. */ async function handleFsWatchFrame( ctx: Context, @@ -1198,7 +1219,6 @@ async function handleFsWatchFrame( watchers: DirectoryWatchers, sessionId: string, data: unknown, - fenceEnabled: () => boolean, ): Promise { let frame: FsWatchFrame try { @@ -1210,7 +1230,7 @@ async function handleFsWatchFrame( if (path === undefined || path === '') return try { const cwd = await sessionCwdOf(ctx, sessionId) - const dir = await ensureWorkspacePath(cwd, path, fenceEnabled()) + const dir = await ensureWorkspacePath(cwd, path) if (frame.op === 'unwatch') { watchers.remove(dir) return diff --git a/src/path-security.ts b/src/path-security.ts index fb31027..bf57c8a 100644 --- a/src/path-security.ts +++ b/src/path-security.ts @@ -1,82 +1,65 @@ -/** Filesystem path guards shared by sidebar APIs that access a session workspace. */ -import { realpath } from 'node:fs/promises' -import { basename, dirname, join } from 'node:path' -import { isWithin, requireAbsolute } from './fs-tree.ts' +/** + * Lexical path resolution for the sidebar's filesystem routes. + * + * ⚠️ **This module no longer enforces workspace containment.** It used to + * canonicalize through `realpath` and reject anything outside the session + * workspace; the user asked for that guard to be removed, so the sidebar fs + * routes now reach any path the HOST USER can reach, bounded only by OS + * permissions (and by the browser-trust fence on the routes themselves). + * + * What remains is the part every caller still needs: turn the client's + * (possibly session-relative) target into one absolute, lexically-normalized + * path. `resolveSessionPath` joins it under the session cwd, `resolve()` + * collapses `.` / `..` segments, and {@link requireAbsolute} rejects + * non-absolute input. No `realpath`, no `isWithin`, no 403. + * + * Kept as one module (and the old names kept) so the call sites read the same + * way they did with the fence: the change is the SEMANTICS — "resolve" rather + * than "guard". + */ +import { requireAbsolute } from './fs-tree.ts' import { resolveSessionPath } from './session-path.ts' -import { SidebarError } from './wire.ts' -/** Resolve a path and convert filesystem resolution failures to an API error. */ -async function resolveRealPath(path: string, label: string): Promise { - try { - return await realpath(path) - } catch (error) { - throw new SidebarError('fs-error', `cannot resolve ${label} "${path}": ${error instanceof Error ? error.message : String(error)}`, 400) - } -} - -/** Reject a resolved path whose real filesystem target escapes the workspace. */ -function assertWithinWorkspace(workspace: string, target: string): void { - if (!isWithin(workspace, target)) { - throw new SidebarError('forbidden', `path "${target}" is outside workspace`, 403) - } +/** + * Resolve an existing path to one absolute, lexically-normalized target. + * + * @param cwd - Session workspace directory (the base of relative targets). + * @param target - Client-supplied absolute or session-relative path. + * @param _fence - IGNORED. Kept as a positional parameter so every call site + * (and the route tests) did not have to change shape in the same commit; the + * containment decision it used to carry is gone for good. New code should + * omit it. + * @returns The absolute path used for the filesystem operation. + * @deprecated The name is historical — this is now plain resolution. Use + * {@link resolveTarget} in new code. + */ +export async function ensureWorkspacePath(cwd: string, target: string, _fence?: boolean): Promise { + return resolveTarget(cwd, target) } /** - * Resolve an existing workspace path through symlinks and (unless disarmed) - * enforce containment. + * Resolve a path for a WRITE destination (which may not exist yet). * - * @param cwd - Session workspace directory. - * @param target - Client-supplied absolute path in the session's namespace. - * @param fence - Whether containment is enforced (the settings-page - * `workspaceFence` switch). Even when false the paths are still resolved - * through symlinks so callers always receive the canonical target. - * @returns The canonical absolute path used for the filesystem operation. + * Lexical only, exactly like {@link ensureWorkspacePath}: a missing target is + * returned as the caller composed it (its parent is created on demand), and an + * existing symlink in the path is NOT resolved — the caller writes through it, + * which is what "no containment policy" means. + * + * @param cwd - Session workspace directory (the base of relative targets). + * @param target - Client-supplied absolute or session-relative path. + * @param _fence - IGNORED (see {@link ensureWorkspacePath}). + * @returns The absolute destination path. + * @deprecated The name is historical — this is now plain resolution. */ -export async function ensureWorkspacePath(cwd: string, target: string, fence = true): Promise { - const absolute = requireAbsolute(resolveSessionPath(cwd, target)) - const [realCwd, realTarget] = await Promise.all([ - resolveRealPath(cwd, 'workspace'), - resolveRealPath(absolute, 'target'), - ]) - if (fence) assertWithinWorkspace(realCwd, realTarget) - return realTarget +export async function ensureWorkspaceWritePath(cwd: string, target: string, _fence?: boolean): Promise { + return resolveTarget(cwd, target) } /** - * Validate a write destination, including destinations that do not exist yet. - * Existing targets are resolved to catch symlinks; missing targets are checked - * against the nearest existing ancestor before the caller creates or renames. - * The returned path is rebuilt from that canonical ancestor, so an existing - * symlink is never left in the path passed to the write operation. - * - * @param cwd - Session workspace directory. - * @param target - Client-supplied absolute destination path in the session's namespace. - * @param fence - Whether containment is enforced (the settings-page - * `workspaceFence` switch). Resolution/canonicalization is identical either way. - * @returns A canonical path for an existing target or its nearest existing ancestor. + * The single resolution primitive: session-relative targets resolve under the + * session cwd, absolute targets stay absolute, and the result is `resolve()`d + * (lexical `..` collapse). Throws fs-error for a non-absolute result. */ -export async function ensureWorkspaceWritePath(cwd: string, target: string, fence = true): Promise { - const absolute = requireAbsolute(resolveSessionPath(cwd, target)) - const realCwd = await resolveRealPath(cwd, 'workspace') - let existingPath = absolute - const missingSegments: string[] = [] - - for (;;) { - try { - const realTarget = await realpath(existingPath) - if (fence) assertWithinWorkspace(realCwd, realTarget) - return missingSegments.reduce((path, segment) => join(path, segment), realTarget) - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - if (error instanceof SidebarError) throw error - throw new SidebarError('fs-error', `cannot resolve target "${existingPath}": ${error instanceof Error ? error.message : String(error)}`, 400) - } - const parent = dirname(existingPath) - if (parent === existingPath) { - throw new SidebarError('fs-error', `cannot resolve target "${absolute}"`, 400) - } - missingSegments.unshift(basename(existingPath)) - existingPath = parent - } - } +export function resolveTarget(cwd: string, target: string): string { + return requireAbsolute(resolveSessionPath(cwd, target)) } diff --git a/src/prefs-shared.ts b/src/prefs-shared.ts index 96cfc8f..0409ee9 100644 --- a/src/prefs-shared.ts +++ b/src/prefs-shared.ts @@ -61,20 +61,6 @@ export interface SidebarPrefs { * Side card settings; off restores the pre-merge editor exactly. */ editorExplorer: boolean - /** - * Whether the sidebar's filesystem routes enforce the workspace fence: - * every client-supplied path must resolve (through symlinks) inside the - * session workspace, else the route answers 403 "outside workspace". On - * by default; turning it OFF lets the file tree / editor read+write / - * media / HTML preview / upload routes reach ANY host path (e.g. the - * global ~/.dsh/AGENTS.md or a linked worktree outside the session cwd) - * — the trade-off being that any same-origin script (including - * third-party consumer plugins) can read/write outside the workspace - * through those routes while it is off. The switch lives under the files - * tab's gear in the Side card settings; the fence error surfaces offer a - * one-click global off + retry. - */ - workspaceFence: boolean /** * Title-bar / shell compatibility scheme (the "位置兼容模式" setting): * - `auto` (default): CONSERVATIVE — only the standard Window Controls @@ -180,7 +166,6 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = { mobileDefaultTree: true, agentOpenTools: false, editorExplorer: false, - workspaceFence: true, titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', diff --git a/tests/builtins.spec.ts b/tests/builtins.spec.ts index 39e1287..8839b5d 100644 --- a/tests/builtins.spec.ts +++ b/tests/builtins.spec.ts @@ -143,7 +143,8 @@ describe('built-in tab registrations', () => { 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', 'workspaceFence']) + // The workspace-fence switch is gone with the fence itself. + 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. @@ -151,9 +152,6 @@ describe('built-in tab registrations', () => { 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) - // The workspace fence switch rides the same card as a plain boolean row. - expect(toggles[1]?.title).toBeDefined() - expect(toggles[1]?.desc).toBeDefined() // The open-with configuration (SSH host + custom editors) is the custom // panel rendered below the declarative rows. expect(service.getTab('editor')?.settings?.render).toBeDefined() diff --git a/tests/editor-host.spec.tsx b/tests/editor-host.spec.tsx index 96ead4b..6bc417a 100644 --- a/tests/editor-host.spec.tsx +++ b/tests/editor-host.spec.tsx @@ -22,13 +22,16 @@ import { allLeaves, createSidebarStore, type SidebarTab } from '../src/client/st // answer without a host (the sibling FileTree specs mock it the same way). vi.mock('../src/client/api.ts', () => ({ api: { - fsTree: async () => ({ - path: '/tmp', - entries: [ - { name: 'sub', path: '/tmp/sub', isDir: true }, - { name: 'a.ts', path: '/tmp/a.ts', isDir: false }, - ], - truncated: false, + // The tree lists its visible set in ONE batched call. + fsTrees: async (_scope: unknown, paths: readonly string[]) => ({ + levels: paths.map(path => ({ + path, + entries: [ + { name: 'sub', path: '/tmp/sub', isDir: true }, + { name: 'a.ts', path: '/tmp/a.ts', isDir: false }, + ], + truncated: false, + })), }), // The shared git-status store polls this; a non-repo answer keeps rows plain. gitStatus: async () => ({ isRepo: false, entries: [] }), diff --git a/tests/file-tree-archive.spec.tsx b/tests/file-tree-archive.spec.tsx index d3f8049..b8aad4e 100644 --- a/tests/file-tree-archive.spec.tsx +++ b/tests/file-tree-archive.spec.tsx @@ -20,7 +20,6 @@ import { createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { act } from 'react-dom/test-utils' import { FileTree } from '../src/client/FileTree.tsx' -import { createSidebarStore } from '../src/client/state.ts' import { setupReactAct } from './test-utils.ts' setupReactAct() @@ -38,14 +37,12 @@ const { archiveBuild, archiveStatus, fetchMock } = vi.hoisted(() => ({ vi.mock('../src/client/api.ts', () => ({ api: { - fsTree: async () => ({ - path: '/tmp', - entries: [ + fsTrees: async (_scope: unknown, paths: readonly string[]) => ({ + levels: paths.map(path => ({ path, entries: [ { name: 'sub', path: '/tmp/sub', isDir: true }, { name: 'a.ts', path: '/tmp/a.ts', isDir: false }, { name: 'b.ts', path: '/tmp/b.ts', isDir: false }, - ], - truncated: false, + ], truncated: false })), }), // The tree reads the shared git-status store; a non-repo answer keeps // every row plain (this spec is about the archive row). @@ -95,7 +92,6 @@ async function mountTree(): Promise { root.render(createElement(FileTree, { sessionId: 's1', cwd: '/tmp', - store: createSidebarStore(), expanded: [], revealed: [], onToggle: () => {}, diff --git a/tests/file-tree-batch-load.spec.tsx b/tests/file-tree-batch-load.spec.tsx new file mode 100644 index 0000000..b912263 --- /dev/null +++ b/tests/file-tree-batch-load.spec.tsx @@ -0,0 +1,243 @@ +/** + * Level loading is BATCHED: the visible set (the workspace root plus every + * expanded directory) is listed with ONE `fs.trees` request on mount, on every + * refresh tick and when a directory expands — instead of one `fs.tree` POST + * per level (the N+1 the user's "频繁刷新" report pointed at). + * + * This spec is the probe AND the guard. It counts both routes, so running it + * before the batch change shows the N+1 shape and after it the single request: + * - 8 expanded directories: 9 × `fs.tree` → 1 × `fs.trees` (9 paths); + * - expanding one more directory lists ONLY that level (batch of 1); + * - a refresh tick re-lists the whole visible set in ONE request; + * - a watch notice still re-lists ONLY the stale level; + * - one level's `error` stays on that level; a whole-request failure keeps + * the cached levels and marks only the ones that had nothing. + */ +// @vitest-environment jsdom +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { act } from 'react-dom/test-utils' +import { FileTree } from '../src/client/FileTree.tsx' + +import { setupReactAct } from './test-utils.ts' +setupReactAct() + +// vitest 4.1.11+ follows the OS locale; pin en-US so the error copy is English. +beforeAll(() => { + Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true }) +}) + +const fsTree = vi.hoisted(() => vi.fn()) +const fsTrees = vi.hoisted(() => vi.fn()) + +vi.mock('../src/client/api.ts', () => ({ + api: { + fsTree: (...args: unknown[]) => fsTree(...args), + fsTrees: (...args: unknown[]) => fsTrees(...args), + // The tree reads the shared git-status store; a non-repo answer keeps + // every row plain. + gitStatus: async () => ({ isRepo: false, entries: [] }), + }, + downloadUrl: () => '/sidebar/file', + isOutsideWorkspaceMessage: () => false, +})) + +const DIRS = ['/tmp/d1', '/tmp/d2', '/tmp/d3', '/tmp/d4', '/tmp/d5', '/tmp/d6', '/tmp/d7', '/tmp/d8'] + +/** One level's listing: the root lists the eight directories (so the expanded + * levels actually render) and every directory lists one file named after it. */ +function levelOf(path: string): { + path: string; entries: { name: string; path: string; isDir: boolean }[]; truncated: boolean +} { + if (path === '/tmp') { + return { + path, + entries: DIRS.map(dir => ({ name: dir.slice('/tmp/'.length), path: dir, isDir: true })), + truncated: false, + } + } + const name = path.slice(path.lastIndexOf('/') + 1) + return { path, entries: [{ name: `${name}-file.ts`, path: `${path}/${name}-file.ts`, isDir: false }], truncated: false } +} + +/** The smallest WebSocket the directory watcher talks to (see its spec). */ +class FakeSocket { + static instances: FakeSocket[] = [] + static readonly OPEN = 1 + static readonly CONNECTING = 0 + readyState = FakeSocket.CONNECTING + readonly url: string + onopen: (() => void) | null = null + onmessage: ((event: { data: string }) => void) | null = null + onclose: (() => void) | null = null + constructor(url: string) { + this.url = url + FakeSocket.instances.push(this) + } + send(): void {} + close(): void { this.readyState = 3 } + open(): void { this.readyState = FakeSocket.OPEN; this.onopen?.() } + frame(payload: unknown): void { this.onmessage?.({ data: JSON.stringify(payload) }) } +} + +interface Harness { + container: HTMLDivElement + /** Re-render with a new expansion set; `refreshTick` only when bumping it. */ + rerender: (expanded: string[], refreshTick?: number) => void + unmount: () => void +} + +function mountTree(expanded: string[] = DIRS, refreshTick = 0): Harness { + const container = document.createElement('div') + document.body.append(container) + const root: Root = createRoot(container) + const render = (dirs: string[], tick: number): void => { + root.render(createElement(FileTree, { + sessionId: 's1', + cwd: '/tmp', + expanded: dirs, + revealed: [], + onToggle: () => {}, + onOpenFile: () => {}, + onReferenceFile: () => {}, + refreshTick: tick, + onUploadRequest: () => {}, + busy: false, + })) + } + let tick = refreshTick + act(() => { render(expanded, tick) }) + return { + container, + rerender: (dirs: string[], nextTick?: number) => { + if (nextTick !== undefined) tick = nextTick + act(() => { render(dirs, tick) }) + }, + unmount: () => { act(() => { root.unmount() }); container.remove() }, + } +} + +/** Flush the batch promise chain. */ +async function flush(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + }) +} + +/** The paths a batch call was asked for. */ +function batchPaths(index: number): string[] { + const call = fsTrees.mock.calls[index] + if (call === undefined) throw new Error(`no fs.trees call #${index}`) + return (call[1] as string[]).slice().sort() +} + +let harness: Harness +beforeEach(() => { + FakeSocket.instances = [] + vi.stubGlobal('WebSocket', FakeSocket) + fsTree.mockReset() + fsTree.mockImplementation(async (_scope: unknown, path: string) => levelOf(path)) + fsTrees.mockReset() + fsTrees.mockImplementation(async (_scope: unknown, paths: readonly string[]) => ({ + levels: paths.map(path => levelOf(path)), + })) +}) + +afterEach(() => { + harness.unmount() + document.body.innerHTML = '' + vi.unstubAllGlobals() +}) + +describe('FileTree level loading is batched', () => { + it('lists the root plus 8 expanded directories with ONE fs.trees request', async () => { + harness = mountTree() + await flush() + console.log('BATCH-PERF', JSON.stringify({ fsTree: fsTree.mock.calls.length, fsTrees: fsTrees.mock.calls.length })) + + // The N+1 shape is gone: one batch for the whole visible set. + expect(fsTree).not.toHaveBeenCalled() + expect(fsTrees).toHaveBeenCalledTimes(1) + expect(batchPaths(0)).toEqual(['/tmp', ...DIRS].sort()) + // Every level rendered (root + the 8 directories' children). + expect(harness.container.textContent).toContain('d1-file.ts') + expect(harness.container.textContent).toContain('d8-file.ts') + }) + + it('lists only the newly expanded directory (batch of 1)', async () => { + harness = mountTree(DIRS) + await flush() + fsTrees.mockClear() + harness.rerender([...DIRS, '/tmp/d9']) + await flush() + expect(fsTrees).toHaveBeenCalledTimes(1) + expect(batchPaths(0)).toEqual(['/tmp/d9']) + }) + + it('re-lists the whole visible set in ONE request on a refresh tick', async () => { + harness = mountTree(DIRS) + await flush() + fsTrees.mockClear() + harness.rerender(DIRS, 1) + await flush() + expect(fsTrees).toHaveBeenCalledTimes(1) + expect(batchPaths(0)).toEqual(['/tmp', ...DIRS].sort()) + }) + + it('re-lists ONLY the stale level when the directory watcher reports one', async () => { + harness = mountTree(DIRS) + await flush() + const socket = FakeSocket.instances[0] + if (socket === undefined) throw new Error('no watch socket opened') + act(() => { socket.open() }) + fsTrees.mockClear() + act(() => { socket.frame({ dir: '/tmp/d3' }) }) + await flush() + // A stale notice drops exactly that level and re-lists it — never the + // whole visible set (the batch path is for mount/expand/refresh only). + expect(fsTrees).toHaveBeenCalledTimes(1) + expect(batchPaths(0)).toEqual(['/tmp/d3']) + }) + + it('keeps one level error on that level only', async () => { + fsTrees.mockImplementation(async (_scope: unknown, paths: readonly string[]) => ({ + levels: paths.map(path => path === '/tmp/d3' + ? { path, entries: [], truncated: false, error: 'cannot read d3' } + : levelOf(path)), + })) + harness = mountTree(DIRS) + await flush() + // The failing level shows its raw message; the others still render. + expect(harness.container.textContent).toContain('cannot read d3') + expect(harness.container.textContent).toContain('d1-file.ts') + expect(harness.container.textContent).toContain('d8-file.ts') + }) + + it('keeps the cached levels (plus a hint) when the whole batch request fails', async () => { + harness = mountTree(DIRS) + await flush() + // Everything is cached now: a failed refresh must not blank the tree — it + // keeps the previous listing and explains itself in a hint line. + fsTrees.mockRejectedValue(new Error('listing offline')) + harness.rerender(DIRS, 1) + await flush() + expect(harness.container.textContent).toContain('d1-file.ts') + expect(harness.container.textContent).toContain('d8-file.ts') + const hint = harness.container.querySelector('[data-kind="warn"]') + expect(hint?.textContent).toBe('listing offline') + // The stale rows are NOT replaced by per-level error rows. + expect(harness.container.querySelectorAll('[class*="explorerError"]')).toHaveLength(0) + }) + + it('shows the failure for levels that had nothing cached', async () => { + fsTrees.mockRejectedValue(new Error('listing offline')) + harness = mountTree(DIRS) + await flush() + // First load, nothing cached: the one request failed, so the root and the + // expanded levels carry the message. + expect(harness.container.textContent).toContain('listing offline') + }) +}) diff --git a/tests/file-tree-drop.spec.tsx b/tests/file-tree-drop.spec.tsx index 85ce1d8..7cae077 100644 --- a/tests/file-tree-drop.spec.tsx +++ b/tests/file-tree-drop.spec.tsx @@ -14,7 +14,6 @@ import { createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { act } from 'react-dom/test-utils' import { FileTree } from '../src/client/FileTree.tsx' -import { createSidebarStore } from '../src/client/state.ts' import { TAB_DRAG_TYPE } from '../src/client/TabBar.tsx' import type { UploadItem } from '../src/client/upload.ts' @@ -30,11 +29,11 @@ beforeAll(() => { vi.mock('../src/client/api.ts', () => ({ api: { - fsTree: async () => ({ - entries: [ + fsTrees: async (_scope: unknown, paths: readonly string[]) => ({ + levels: paths.map(path => ({ path, entries: [ { name: 'src', path: '/tmp/src', isDir: true }, { name: 'a.ts', path: '/tmp/a.ts', isDir: false }, - ], + ], truncated: false })), }), // The tree reads the shared git-status store; a non-repo answer keeps // every row plain (this spec is about the drop surface). @@ -60,7 +59,6 @@ async function mountTree(busy = false): Promise { root.render(createElement(FileTree, { sessionId: 's1', cwd: '/tmp', - store: createSidebarStore(), expanded: [], revealed: [], onToggle: () => {}, diff --git a/tests/file-tree-git-status.spec.tsx b/tests/file-tree-git-status.spec.tsx index f044cf2..cba5d3f 100644 --- a/tests/file-tree-git-status.spec.tsx +++ b/tests/file-tree-git-status.spec.tsx @@ -12,7 +12,6 @@ import { createRoot, type Root } from 'react-dom/client' import { act } from 'react-dom/test-utils' import type { GitStatusResult } from '../src/client/api.ts' import { FileTree } from '../src/client/FileTree.tsx' -import { createSidebarStore } from '../src/client/state.ts' import { setupReactAct } from './test-utils.ts' setupReactAct() @@ -26,14 +25,12 @@ const { gitStatus } = vi.hoisted(() => ({ gitStatus: vi.fn() })) vi.mock('../src/client/api.ts', () => ({ api: { - fsTree: async () => ({ - path: '/tmp', - entries: [ + fsTrees: async (_scope: unknown, paths: readonly string[]) => ({ + levels: paths.map(path => ({ path, entries: [ { name: 'sub', path: '/tmp/sub', isDir: true }, { name: 'a.ts', path: '/tmp/a.ts', isDir: false }, { name: 'b.ts', path: '/tmp/b.ts', isDir: false }, - ], - truncated: false, + ], truncated: false })), }), gitStatus, }, @@ -65,7 +62,6 @@ async function mountTree(): Promise { root.render(createElement(FileTree, { sessionId: 'git-spec', cwd: '/tmp', - store: createSidebarStore(), expanded: [], revealed: [], onToggle: () => {}, diff --git a/tests/file-tree-menu-perf.spec.tsx b/tests/file-tree-menu-perf.spec.tsx index 33cc1f7..6d3510c 100644 --- a/tests/file-tree-menu-perf.spec.tsx +++ b/tests/file-tree-menu-perf.spec.tsx @@ -22,7 +22,6 @@ import { createRoot, type Root } from 'react-dom/client' import { act } from 'react-dom/test-utils' import { FileTree } from '../src/client/FileTree.tsx' import type { BetterSidebarService } from '../src/client/service.ts' -import { createSidebarStore } from '../src/client/state.ts' import { setupReactAct } from './test-utils.ts' setupReactAct() @@ -32,8 +31,9 @@ beforeAll(() => { Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true }) }) -const { fsTree, gitStatus, fileApps, directoryApps, archiveBuild, archiveStatus } = vi.hoisted(() => ({ +const { fsTree, fsTrees, gitStatus, fileApps, directoryApps, archiveBuild, archiveStatus } = vi.hoisted(() => ({ fsTree: vi.fn(), + fsTrees: vi.fn(), gitStatus: vi.fn(), fileApps: vi.fn(), directoryApps: vi.fn(), @@ -42,7 +42,7 @@ const { fsTree, gitStatus, fileApps, directoryApps, archiveBuild, archiveStatus })) vi.mock('../src/client/api.ts', () => ({ - api: { fsTree, gitStatus, fileApps, directoryApps, archiveBuild, archiveStatus }, + api: { fsTree, fsTrees, gitStatus, fileApps, directoryApps, archiveBuild, archiveStatus }, downloadUrl: () => '/sidebar/file', archiveDownloadUrl: (id: string) => `/sidebar/archive/${id}`, isOutsideWorkspaceMessage: () => false, @@ -75,7 +75,6 @@ async function mountTree(): Promise { root.render(createElement(FileTree, { sessionId: 'menu-perf', cwd: '/tmp', - store: createSidebarStore(), expanded: [], revealed: [], onToggle: () => {}, @@ -167,16 +166,21 @@ function mark(): void { beforeEach(() => { vi.useFakeTimers() + // The single-level route must never be used again (the batch one replaced + // it); it stays mocked so the probe can assert the count is 0. fsTree.mockReset() - fsTree.mockImplementation(async (_scope: unknown, path: string) => ({ - path, - entries: [ - { name: 'sub', path: '/tmp/sub', isDir: true }, - { name: 'a.ts', path: '/tmp/a.ts', isDir: false }, - { name: 'b.ts', path: '/tmp/b.ts', isDir: false }, - { name: 'c.ts', path: '/tmp/c.ts', isDir: false }, - ], - truncated: false, + fsTrees.mockReset() + fsTrees.mockImplementation(async (_scope: unknown, paths: readonly string[]) => ({ + levels: paths.map(path => ({ + path, + entries: [ + { name: 'sub', path: '/tmp/sub', isDir: true }, + { name: 'a.ts', path: '/tmp/a.ts', isDir: false }, + { name: 'b.ts', path: '/tmp/b.ts', isDir: false }, + { name: 'c.ts', path: '/tmp/c.ts', isDir: false }, + ], + truncated: false, + })), })) gitStatus.mockReset() gitStatus.mockResolvedValue({ isRepo: false, entries: [] }) diff --git a/tests/file-tree-multiselect.spec.tsx b/tests/file-tree-multiselect.spec.tsx index 7c00aad..59ec16f 100644 --- a/tests/file-tree-multiselect.spec.tsx +++ b/tests/file-tree-multiselect.spec.tsx @@ -17,7 +17,6 @@ import { createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { act } from 'react-dom/test-utils' import { FileTree } from '../src/client/FileTree.tsx' -import { createSidebarStore } from '../src/client/state.ts' import { setupReactAct } from './test-utils.ts' setupReactAct() @@ -27,22 +26,25 @@ beforeAll(() => { Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true }) }) -const { fsRemove, fsTree } = vi.hoisted(() => ({ +const { fsRemove, fsTrees } = vi.hoisted(() => ({ fsRemove: vi.fn(async (_scope: unknown, path: string) => ({ path })), - fsTree: vi.fn(async () => ({ - path: '/tmp', - entries: [ - { name: 'sub', path: '/tmp/sub', isDir: true }, - { name: 'a.ts', path: '/tmp/a.ts', isDir: false }, - { name: 'b.ts', path: '/tmp/b.ts', isDir: false }, - ], - truncated: false, + // One BATCHED list call answers every requested level with the same shape. + fsTrees: vi.fn(async (_scope: unknown, paths: readonly string[]) => ({ + levels: paths.map(path => ({ + path, + entries: [ + { name: 'sub', path: '/tmp/sub', isDir: true }, + { name: 'a.ts', path: '/tmp/a.ts', isDir: false }, + { name: 'b.ts', path: '/tmp/b.ts', isDir: false }, + ], + truncated: false, + })), })), })) vi.mock('../src/client/api.ts', () => ({ api: { - fsTree, + fsTrees, fsRemove, // The tree reads the shared git-status store; a non-repo answer keeps // every row plain (this spec is about the selection). @@ -69,7 +71,6 @@ async function mountTree(expanded: string[] = []): Promise { root.render(createElement(FileTree, { sessionId: 's1', cwd: '/tmp', - store: createSidebarStore(), expanded, revealed: [], onToggle: (path: string) => { toggled.push(path) }, @@ -135,7 +136,7 @@ afterEach(() => { document.body.innerHTML = '' fsRemove.mockReset() fsRemove.mockImplementation(async (_scope: unknown, path: string) => ({ path })) - fsTree.mockClear() + fsTrees.mockClear() }) describe('FileTree multi-selection', () => { diff --git a/tests/file-tree-new-folder.spec.tsx b/tests/file-tree-new-folder.spec.tsx index bb4f79b..a5a4e8f 100644 --- a/tests/file-tree-new-folder.spec.tsx +++ b/tests/file-tree-new-folder.spec.tsx @@ -13,7 +13,6 @@ import { createElement, useState, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { act } from 'react-dom/test-utils' import { FileTree } from '../src/client/FileTree.tsx' -import { createSidebarStore } from '../src/client/state.ts' import { setupReactAct } from './test-utils.ts' setupReactAct() @@ -23,16 +22,16 @@ beforeAll(() => { Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true }) }) -const { fsMkdir, fsTree } = vi.hoisted(() => ({ +const { fsMkdir, fsTrees } = vi.hoisted(() => ({ fsMkdir: vi.fn(async (_scope: unknown, _path: string, name: string) => ({ path: `/tmp/${name}` })), // `listingFor` is a hoisted function declaration, so the factory may // reference it; it only runs when the mock is called (after module init). - fsTree: vi.fn(async (_scope: unknown, path: string) => listingFor(path)), + fsTrees: vi.fn(async (_scope: unknown, paths: readonly string[]) => ({ levels: paths.map(path => listingFor(path)) })), })) vi.mock('../src/client/api.ts', () => ({ api: { - fsTree, + fsTrees, fsMkdir, // The tree reads the shared git-status store; a non-repo answer keeps // every row plain (this spec is about folder creation). @@ -79,7 +78,6 @@ function mountTree(initialExpanded: string[] = []): Harness { return createElement(FileTree, { sessionId: 's1', cwd: '/tmp', - store: createSidebarStore(), expanded, revealed: [], onToggle: (path: string) => { @@ -154,8 +152,8 @@ afterEach(() => { document.body.innerHTML = '' fsMkdir.mockReset() fsMkdir.mockImplementation(async (_scope: unknown, _path: string, name: string) => ({ path: `/tmp/${name}` })) - fsTree.mockReset() - fsTree.mockImplementation(async (_scope: unknown, path: string) => listingFor(path)) + fsTrees.mockReset() + fsTrees.mockImplementation(async (_scope: unknown, paths: readonly string[]) => ({ levels: paths.map(path => listingFor(path)) })) }) describe('FileTree new folder', () => { @@ -181,12 +179,12 @@ describe('FileTree new folder', () => { // level's first row. expect(editorIndent(harness.container)).toBe('28px') setNativeValue(input, 'newdir') - const listings = fsTree.mock.calls.length + const listings = fsTrees.mock.calls.length await act(async () => { pressKey(input, 'Enter') }) expect(fsMkdir).toHaveBeenCalledWith({ sessionId: 's1', cwd: '/tmp' }, '/tmp', 'newdir') expect(harness.container.querySelector('input[class*="explorerRenameInput"]')).toBeNull() // The level was dropped and re-listed, so the new folder shows up. - expect(fsTree.mock.calls.length).toBeGreaterThan(listings) + expect(fsTrees.mock.calls.length).toBeGreaterThan(listings) }) it('expands a collapsed directory, edits at ITS level, and creates there', async () => { diff --git a/tests/file-tree-open-in-app.spec.tsx b/tests/file-tree-open-in-app.spec.tsx index b276ccc..330c6c4 100644 --- a/tests/file-tree-open-in-app.spec.tsx +++ b/tests/file-tree-open-in-app.spec.tsx @@ -21,7 +21,6 @@ import { act } from 'react-dom/test-utils' import { FileTree } from '../src/client/FileTree.tsx' import { createOpenInApp, type OpenInApp, type OpenInAppEntry } from '../src/client/open-in-app.ts' import type { OpenWithTarget } from '../src/client/open-with.ts' -import { createSidebarStore } from '../src/client/state.ts' import { setupReactAct } from './test-utils.ts' setupReactAct() @@ -33,13 +32,11 @@ beforeAll(() => { vi.mock('../src/client/api.ts', () => ({ api: { - fsTree: async () => ({ - path: '/tmp', - entries: [ + fsTrees: async (_scope: unknown, paths: readonly string[]) => ({ + levels: paths.map(path => ({ path, entries: [ { name: 'sub', path: '/tmp/sub', isDir: true }, { name: 'a.ts', path: '/tmp/a.ts', isDir: false }, - ], - truncated: false, + ], truncated: false })), }), // The tree reads the shared git-status store; a non-repo answer keeps // every row plain (this spec is about the open-in-app rows). @@ -114,7 +111,6 @@ async function mountTree(handle?: Handle, plugin: { root.render(createElement(FileTree, { sessionId: 's1', cwd: '/tmp', - store: createSidebarStore(), expanded: [], revealed: [], onToggle: () => {}, diff --git a/tests/file-tree-open-with.spec.tsx b/tests/file-tree-open-with.spec.tsx index 2140f52..60e22ce 100644 --- a/tests/file-tree-open-with.spec.tsx +++ b/tests/file-tree-open-with.spec.tsx @@ -15,7 +15,6 @@ import { createRoot, type Root } from 'react-dom/client' import { act } from 'react-dom/test-utils' import { FileTree } from '../src/client/FileTree.tsx' import type { OpenWithTarget } from '../src/client/open-with.ts' -import { createSidebarStore } from '../src/client/state.ts' import { setupReactAct } from './test-utils.ts' setupReactAct() @@ -27,13 +26,11 @@ beforeAll(() => { vi.mock('../src/client/api.ts', () => ({ api: { - fsTree: async () => ({ - path: '/tmp', - entries: [ + fsTrees: async (_scope: unknown, paths: readonly string[]) => ({ + levels: paths.map(path => ({ path, entries: [ { name: 'sub', path: '/tmp/sub', isDir: true }, { name: 'a.ts', path: '/tmp/a.ts', isDir: false }, - ], - truncated: false, + ], truncated: false })), }), // The tree reads the shared git-status store; a non-repo answer keeps // every row plain. @@ -77,7 +74,6 @@ async function mountTree(overrides: { root.render(createElement(FileTree, { sessionId: 's1', cwd: '/tmp', - store: createSidebarStore(), expanded: [], revealed: [], onToggle: () => {}, diff --git a/tests/file-tree-rename-delete.spec.tsx b/tests/file-tree-rename-delete.spec.tsx index 57df5b6..3798b93 100644 --- a/tests/file-tree-rename-delete.spec.tsx +++ b/tests/file-tree-rename-delete.spec.tsx @@ -10,7 +10,6 @@ import { createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { act } from 'react-dom/test-utils' import { FileTree } from '../src/client/FileTree.tsx' -import { createSidebarStore } from '../src/client/state.ts' // The act() environment flag (React 18.2 reads it before flushing effects). import { setupReactAct } from './test-utils.ts' @@ -30,11 +29,11 @@ const { fsRename, fsRemove } = vi.hoisted(() => ({ vi.mock('../src/client/api.ts', () => ({ api: { - fsTree: async () => ({ - entries: [ + fsTrees: async (_scope: unknown, paths: readonly string[]) => ({ + levels: paths.map(path => ({ path, entries: [ { name: 'a.ts', path: '/tmp/a.ts', isDir: false }, { name: 'sub', path: '/tmp/sub', isDir: true }, - ], + ], truncated: false })), }), fsRename, fsRemove, @@ -63,7 +62,6 @@ async function mountTree(): Promise { root.render(createElement(FileTree, { sessionId: 's1', cwd: '/tmp', - store: createSidebarStore(), expanded: [], revealed: [], onToggle: () => {}, diff --git a/tests/file-tree-reveal-scroll.spec.tsx b/tests/file-tree-reveal-scroll.spec.tsx index a4eed7e..a7dbf99 100644 --- a/tests/file-tree-reveal-scroll.spec.tsx +++ b/tests/file-tree-reveal-scroll.spec.tsx @@ -14,7 +14,6 @@ import { createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { act } from 'react-dom/test-utils' import { FileTree } from '../src/client/FileTree.tsx' -import { createSidebarStore } from '../src/client/state.ts' import { setupReactAct } from './test-utils.ts' setupReactAct() @@ -28,11 +27,11 @@ afterEach(() => { scrollIntoView.mockClear() }) vi.mock('../src/client/api.ts', () => ({ api: { - fsTree: async () => ({ - entries: [ + fsTrees: async (_scope: unknown, paths: readonly string[]) => ({ + levels: paths.map(path => ({ path, entries: [ { name: 'src', path: '/tmp/src', isDir: true }, { name: 'a.ts', path: '/tmp/a.ts', isDir: false }, - ], + ], truncated: false })), }), // The tree reads the shared git-status store; a non-repo answer keeps // every row plain (this spec is about the reveal scroll). @@ -59,7 +58,6 @@ async function mountTree(bodyScroll: { top: number; client: number; height: numb root.render(createElement(FileTree, { sessionId: 's1', cwd: '/tmp', - store: createSidebarStore(), expanded: [], revealed, onToggle: () => {}, diff --git a/tests/file-tree-row-memo.spec.tsx b/tests/file-tree-row-memo.spec.tsx index 658138c..f14d00a 100644 --- a/tests/file-tree-row-memo.spec.tsx +++ b/tests/file-tree-row-memo.spec.tsx @@ -12,7 +12,6 @@ import { createRoot, type Root } from 'react-dom/client' import { act } from 'react-dom/test-utils' import { FileTree } from '../src/client/FileTree.tsx' import type { BetterSidebarService } from '../src/client/service.ts' -import { createSidebarStore } from '../src/client/state.ts' import { setupReactAct } from './test-utils.ts' setupReactAct() @@ -24,15 +23,13 @@ beforeAll(() => { vi.mock('../src/client/api.ts', () => ({ api: { - fsTree: async () => ({ - path: '/tmp', - entries: [ + fsTrees: async (_scope: unknown, paths: readonly string[]) => ({ + levels: paths.map(path => ({ path, entries: [ { name: 'sub', path: '/tmp/sub', isDir: true }, { name: 'a.ts', path: '/tmp/a.ts', isDir: false }, { name: 'b.ts', path: '/tmp/b.ts', isDir: false }, { name: 'c.ts', path: '/tmp/c.ts', isDir: false }, - ], - truncated: false, + ], truncated: false })), }), // The tree reads the shared git-status store; a non-repo answer keeps // every row plain (its arrival must not defeat the memo bail-out either). @@ -66,7 +63,6 @@ async function mountTree(): Promise { root.render(createElement(FileTree, { sessionId: 'memo-spec', cwd: '/tmp', - store: createSidebarStore(), expanded: [], revealed: [], onToggle: () => {}, diff --git a/tests/file-tree-search-persist.spec.tsx b/tests/file-tree-search-persist.spec.tsx index e89a990..126b67f 100644 --- a/tests/file-tree-search-persist.spec.tsx +++ b/tests/file-tree-search-persist.spec.tsx @@ -15,7 +15,6 @@ import { beforeAll, afterEach, describe, expect, it, vi } from 'vitest' import { createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { act } from 'react-dom/test-utils' -import { createSidebarStore } from '../src/client/state.ts' import { TreePanel } from '../src/client/TreePanel.tsx' import { setupReactAct } from './test-utils.ts' @@ -37,14 +36,14 @@ function defaultListing(path: string): Listing { return { path, entries: [{ name: 'a.ts', path: '/tmp/a.ts', isDir: false }], truncated: false } } -const { fsTree, fsSearch } = vi.hoisted(() => ({ - fsTree: vi.fn(async (_scope: unknown, path: string) => defaultListing(path)), +const { fsTrees, fsSearch } = vi.hoisted(() => ({ + fsTrees: vi.fn(async (_scope: unknown, paths: readonly string[]) => ({ levels: paths.map(path => defaultListing(path)) })), fsSearch: vi.fn(async () => ({ matches: ['a.ts'], truncated: false })), })) vi.mock('../src/client/api.ts', () => ({ api: { - fsTree, + fsTrees, fsSearch, // The tree reads the shared git-status store; a non-repo answer keeps // every row plain. @@ -67,7 +66,6 @@ function mountPanel(): Harness { root.render(createElement(TreePanel, { sessionId: 's1', cwd: '/tmp', - store: createSidebarStore(), expanded: [], revealed: [], onToggle: () => {}, @@ -116,8 +114,8 @@ let harness: Harness afterEach(() => { harness.unmount() document.body.innerHTML = '' - fsTree.mockReset() - fsTree.mockImplementation(async (_scope: unknown, path: string) => defaultListing(path)) + fsTrees.mockReset() + fsTrees.mockImplementation(async (_scope: unknown, paths: readonly string[]) => ({ levels: paths.map(path => defaultListing(path)) })) fsSearch.mockClear() }) @@ -125,7 +123,7 @@ describe('TreePanel search keeps the tree mounted', () => { it('parks the tree instead of unmounting it, so clearing the query keeps the level cache', async () => { harness = mountPanel() await act(async () => {}) - expect(fsTree).toHaveBeenCalledTimes(1) + expect(fsTrees).toHaveBeenCalledTimes(1) expect(treeBody(harness.container).hasAttribute('hidden')).toBe(false) expect(harness.container.textContent).toContain('a.ts') @@ -140,7 +138,7 @@ describe('TreePanel search keeps the tree mounted', () => { expect(treeBody(harness.container).hasAttribute('hidden')).toBe(false) expect(resultsBody(harness.container).hasAttribute('hidden')).toBe(true) // No second listing: the cache (and the expansion state) survived. - expect(fsTree).toHaveBeenCalledTimes(1) + expect(fsTrees).toHaveBeenCalledTimes(1) }) }) @@ -148,8 +146,8 @@ describe('TreePanel refresh and truncated levels', () => { it('drops a level response that lands after the cache was wiped', async () => { // The FIRST listing never settles until the test resolves it; the refresh // click starts the second one, which answers immediately. - let resolveStale: (listing: Listing) => void = () => {} - fsTree.mockImplementationOnce(async () => await new Promise(resolve => { resolveStale = resolve })) + let resolveStale: (result: { levels: Listing[] }) => void = () => {} + fsTrees.mockImplementationOnce(async () => await new Promise<{ levels: Listing[] }>(resolve => { resolveStale = resolve })) harness = mountPanel() await act(async () => {}) // The pending level shows nothing yet (its rows are not known). @@ -162,12 +160,12 @@ describe('TreePanel refresh and truncated levels', () => { await Promise.resolve() await Promise.resolve() }) - expect(fsTree).toHaveBeenCalledTimes(2) + expect(fsTrees).toHaveBeenCalledTimes(2) expect(harness.container.textContent).toContain('a.ts') // The stale first answer arrives LAST and must be discarded. await act(async () => { - resolveStale({ path: '/tmp', entries: [{ name: 'stale.ts', path: '/tmp/stale.ts', isDir: false }], truncated: false }) + resolveStale({ levels: [{ path: '/tmp', entries: [{ name: 'stale.ts', path: '/tmp/stale.ts', isDir: false }], truncated: false }] }) await Promise.resolve() await Promise.resolve() }) @@ -176,10 +174,8 @@ describe('TreePanel refresh and truncated levels', () => { }) it('surfaces a truncated level instead of hiding it', async () => { - fsTree.mockImplementation(async (_scope: unknown, path: string) => ({ - path, - entries: [{ name: 'a.ts', path: '/tmp/a.ts', isDir: false }], - truncated: true, + fsTrees.mockImplementation(async (_scope: unknown, paths: readonly string[]) => ({ + levels: paths.map(path => ({ path, entries: [{ name: 'a.ts', path: '/tmp/a.ts', isDir: false }], truncated: true })), })) harness = mountPanel() await act(async () => {}) diff --git a/tests/fs-mkdir.spec.ts b/tests/fs-mkdir.spec.ts index 429e9dd..a0bbb29 100644 --- a/tests/fs-mkdir.spec.ts +++ b/tests/fs-mkdir.spec.ts @@ -1,21 +1,22 @@ /** * The host's "new folder" mutation (`fs.mkdir` route → mkdirWorkspaceEntry): - * shape rules, existence refusal, containment under the fence, the root row - * as a legal PARENT, and the happy path against a real temporary filesystem. + * shape rules, existence refusal, the root row as a legal PARENT, and the + * happy path against a real temporary filesystem. + * + * The workspace fence is GONE: these cases assert the LEXICAL path is returned + * as composed (no realpath) and that a parent outside the session workspace is + * accepted like any other directory the host user can write. */ -import { mkdtemp, mkdir, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, readdir, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { mkdirWorkspaceEntry } from '../src/fs-operations.ts' let root: string -/** The workspace root as the host will report it (macOS /tmp is a symlink). */ -let realRoot: string beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'dsh-mkdir-')) - realRoot = await realpath(root) }) afterEach(async () => { @@ -36,13 +37,15 @@ describe('mkdirWorkspaceEntry', () => { it('creates one directory inside the named row and returns its absolute path', async () => { await mkdir(join(root, 'sub')) const result = await mkdirWorkspaceEntry({ cwd: root, path: join(root, 'sub'), name: 'fresh' }) - expect(result.path).toBe(join(realRoot, 'sub', 'fresh')) + // Lexical: the path comes back exactly as composed (no realpath — on macOS + // /var would otherwise be reported as /private/var). + expect(result.path).toBe(join(root, 'sub', 'fresh')) expect(await readdir(join(root, 'sub'))).toEqual(['fresh']) }) it('accepts the workspace root itself as the parent', async () => { const result = await mkdirWorkspaceEntry({ cwd: root, path: root, name: 'top' }) - expect(result.path).toBe(join(realRoot, 'top')) + expect(result.path).toBe(join(root, 'top')) expect(await readdir(root)).toEqual(['top']) }) @@ -57,24 +60,27 @@ describe('mkdirWorkspaceEntry', () => { expect(await codeOf(() => mkdirWorkspaceEntry({ cwd: root, path: root, name: 'taken' }))).toBe('fs-error') }) - it('refuses a parent outside the workspace while the fence is armed, and allows it when disarmed', async () => { + it('creates a directory under a parent outside the workspace (fence removed)', async () => { const outside = await mkdtemp(join(tmpdir(), 'dsh-outside-')) try { await writeFile(join(outside, 'marker.txt'), 'x') - expect(await codeOf(() => mkdirWorkspaceEntry({ cwd: root, path: outside, name: 'nope' }))).toBe('forbidden') - // The fence is the guard, not the path shape: disarmed, the same call lands. - const result = await mkdirWorkspaceEntry({ cwd: root, path: outside, name: 'allowed', fence: false }) - expect(result.path).toBe(join(await realpath(outside), 'allowed')) + // ⚠️ PERMISSION CHANGE: no containment → any writable parent is legal. + const result = await mkdirWorkspaceEntry({ cwd: root, path: outside, name: 'allowed' }) + expect(result.path).toBe(join(outside, 'allowed')) + expect(await readdir(outside)).toEqual(['allowed', 'marker.txt']) } finally { await rm(outside, { recursive: true, force: true }) } }) - it('refuses to create INSIDE a symlink that escapes the workspace', async () => { + it('creates INSIDE a symlink that points outside the workspace', async () => { const outside = await mkdtemp(join(tmpdir(), 'dsh-link-')) try { await symlink(outside, join(root, 'escape')) - expect(await codeOf(() => mkdirWorkspaceEntry({ cwd: root, path: join(root, 'escape'), name: 'nope' }))).toBe('forbidden') + // The link is followed like any directory (no realpath guard). + const result = await mkdirWorkspaceEntry({ cwd: root, path: join(root, 'escape'), name: 'inside-link' }) + expect(result.path).toBe(join(root, 'escape', 'inside-link')) + expect(await readdir(outside)).toEqual(['inside-link']) } finally { await rm(outside, { recursive: true, force: true }) } diff --git a/tests/fs-operations.spec.ts b/tests/fs-operations.spec.ts index d90ebbc..e4fcdba 100644 --- a/tests/fs-operations.spec.ts +++ b/tests/fs-operations.spec.ts @@ -7,6 +7,19 @@ import { renameWorkspaceEntry, removeWorkspaceEntry, writeWorkspaceUpload } from /** The test workspace root (each suite gets its own temp tree). */ const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-upload-')) +/** Symlink creation needs privileges on Windows; the link cases skip there. */ +const canSymlink = (() => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-upload-probe-')) + try { + symlinkSync(dir, join(dir, 'probe-link')) + return true + } catch { + return false + } finally { + rmSync(dir, { recursive: true, force: true }) + } +})() + afterAll(() => { rmSync(root, { recursive: true, force: true }) }) @@ -92,28 +105,38 @@ describe('writeWorkspaceUpload', () => { })).rejects.toMatchObject({ code: 'bad-request' }) }) - it('refuses an upload directory outside the workspace', async () => { + it('uploads into a directory outside the workspace (containment was removed)', async () => { const outside = mkdtempSync(join(tmpdir(), 'dsh-sidebar-upload-outside-')) try { - await expect(writeWorkspaceUpload({ + // ⚠️ PERMISSION CHANGE: the workspace fence is gone, so an absolute + // directory the host user can write is a valid upload target. + const result = await writeWorkspaceUpload({ cwd: root, dir: outside, relativePath: 'x.txt', chunks: chunksOf('x'), limit: 1024, - })).rejects.toMatchObject({ code: 'forbidden' }) + }) + expect(result).toEqual({ path: join(outside, 'x.txt'), size: 1 }) + expect(readFileSync(join(outside, 'x.txt'), 'utf8')).toBe('x') } finally { rmSync(outside, { recursive: true, force: true }) } }) - it('refuses upload directories and targets that resolve outside the workspace', async () => { + it('follows an upload directory symlink outside the workspace (no realpath guard)', async () => { + if (!canSymlink) return const outside = mkdtempSync(join(tmpdir(), 'dsh-sidebar-upload-symlink-outside-')) const link = join(root, 'upload-link') try { symlinkSync(outside, link) - await expect(writeWorkspaceUpload({ - cwd: root, dir: link, relativePath: 'x.txt', chunks: chunksOf('x'), limit: 1024, - })).rejects.toMatchObject({ code: 'forbidden' }) - await expect(writeWorkspaceUpload({ - cwd: root, dir: root, relativePath: 'upload-link/x.txt', chunks: chunksOf('x'), limit: 1024, - })).rejects.toMatchObject({ code: 'forbidden' }) + const viaLink = await writeWorkspaceUpload({ + cwd: root, dir: link, relativePath: 'a.txt', chunks: chunksOf('a'), limit: 1024, + }) + expect(viaLink.path).toBe(join(link, 'a.txt')) + // …and a relative path that walks through the link lands there too. + const viaRelative = await writeWorkspaceUpload({ + cwd: root, dir: root, relativePath: 'upload-link/b.txt', chunks: chunksOf('b'), limit: 1024, + }) + expect(viaRelative.path).toBe(join(root, 'upload-link', 'b.txt')) + expect(readFileSync(join(outside, 'a.txt'), 'utf8')).toBe('a') + expect(readFileSync(join(outside, 'b.txt'), 'utf8')).toBe('b') } finally { rmSync(link, { force: true }) rmSync(outside, { recursive: true, force: true }) diff --git a/tests/fs-tree.spec.ts b/tests/fs-tree.spec.ts index 2175e72..7f3ead8 100644 --- a/tests/fs-tree.spec.ts +++ b/tests/fs-tree.spec.ts @@ -1,6 +1,8 @@ -import { describe, expect, it } from 'vitest' -import { resolve } from 'node:path' -import { compareEntries, isWithin, parentOf, requireAbsolute, rootLabel } from '../src/fs-tree.ts' +import { afterEach, describe, expect, it } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve, sep } from 'node:path' +import { compareEntries, isWithin, invalidateDirectoryCache, listDirectory, parentOf, requireAbsolute, rootLabel } from '../src/fs-tree.ts' import { isWin32 } from './platform.ts' describe('fs-tree', () => { @@ -96,3 +98,102 @@ describe('fs-tree', () => { expect(isWithin('\\\\server\\share\\proj', '\\\\other\\share\\a.ts', 'win32')).toBe(false) }) }) + +describe('listDirectory (capped rows + cache)', () => { + const roots: string[] = [] + const tempDir = (): string => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-list-')) + roots.push(dir) + return dir + } + afterEach(() => { + invalidateDirectoryCache() + while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true }) + }) + + it('caps the rows and flags truncated', async () => { + const dir = tempDir() + for (let index = 0; index < 25; index += 1) { + writeFileSync(join(dir, `file-${String(index).padStart(2, '0')}.txt`), 'x') + } + mkdirSync(join(dir, 'zz-dir')) + const listing = await listDirectory(dir, 10) + expect(listing.path).toBe(dir) + // The cap is the row budget of one level; `truncated` is true whenever the + // level held MORE rows than the budget (regardless of which ones survived). + expect(listing.entries).toHaveLength(10) + expect(listing.truncated).toBe(true) + for (const entry of listing.entries) { + expect(entry.path).toBe(`${dir}${sep}${entry.name}`) + expect(entry.hidden).toBe(entry.name.startsWith('.')) + } + }) + + it('sorts directories first within the returned rows', async () => { + const dir = tempDir() + mkdirSync(join(dir, 'Dir-A')) + mkdirSync(join(dir, 'dir-b')) + writeFileSync(join(dir, 'a.txt'), 'a') + writeFileSync(join(dir, 'B.txt'), 'b') + const listing = await listDirectory(dir, 10) + // Directories first, then case-insensitive names (the comparator's contract + // applied to real rows). + expect(listing.entries.map(entry => entry.name)).toEqual(['Dir-A', 'dir-b', 'a.txt', 'B.txt']) + expect(listing.truncated).toBe(false) + }) + + it('does not flag truncated when the level fits', async () => { + const dir = tempDir() + writeFileSync(join(dir, 'a.txt'), 'a') + const listing = await listDirectory(dir, 10) + expect(listing.entries.map(entry => entry.name)).toEqual(['a.txt']) + expect(listing.truncated).toBe(false) + }) + + it('composes row paths with the platform separator', async () => { + const dir = tempDir() + writeFileSync(join(dir, 'a.txt'), 'a') + const listing = await listDirectory(dir, 10) + expect(listing.entries[0]!.path).toBe(`${dir}${sep}a.txt`) + }) + + it('serves a repeat listing from the TTL cache and re-reads after invalidation', async () => { + const dir = tempDir() + writeFileSync(join(dir, 'a.txt'), 'a') + const first = await listDirectory(dir, 10) + // A file created behind the plugin's back is invisible until the cache + // expires or an invalidation arrives (the writers call it explicitly). + writeFileSync(join(dir, 'b.txt'), 'b') + const cached = await listDirectory(dir, 10) + expect(cached).toBe(first) + expect(cached.entries.map(entry => entry.name)).toEqual(['a.txt']) + invalidateDirectoryCache(dir) + const fresh = await listDirectory(dir, 10) + expect(fresh).not.toBe(first) + expect(fresh.entries.map(entry => entry.name)).toEqual(['a.txt', 'b.txt']) + }) + + it('keys the cache by cap as well as by directory', async () => { + const dir = tempDir() + for (let index = 0; index < 5; index += 1) writeFileSync(join(dir, `f${index}.txt`), 'x') + const capped = await listDirectory(dir, 2) + const full = await listDirectory(dir, 10) + expect(capped.entries).toHaveLength(2) + expect(full.entries).toHaveLength(5) + }) + + it('invalidateDirectoryCache() with no argument clears every level', async () => { + const dir = tempDir() + writeFileSync(join(dir, 'a.txt'), 'a') + const first = await listDirectory(dir, 10) + invalidateDirectoryCache() + const second = await listDirectory(dir, 10) + expect(second).not.toBe(first) + expect(second).toEqual(first) + }) + + it('throws fs-error for a missing directory', async () => { + const dir = tempDir() + await expect(listDirectory(join(dir, 'nope'), 10)).rejects.toMatchObject({ code: 'fs-error' }) + }) +}) diff --git a/tests/fs-watch.spec.ts b/tests/fs-watch.spec.ts index 55bf5ec..953997c 100644 --- a/tests/fs-watch.spec.ts +++ b/tests/fs-watch.spec.ts @@ -16,6 +16,7 @@ import { describe, expect, it, vi } from 'vitest' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { listDirectory } from '../src/fs-tree.ts' import { createDirectoryWatchers, type DirectoryWatchEvent } from '../src/fs-watch.ts' /** Generous delivery budget: the module debounces at 150 ms, the OS adds its own latency. */ @@ -35,6 +36,28 @@ const withScratch = (run: (dir: string) => Promise | void): Promise const settle = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)) describe('directory watchers', () => { + it('drops the level cache for a changed directory before it pushes', async () => { + await withScratch(async (dir) => { + const events: DirectoryWatchEvent[] = [] + // Warm the listing cache with the pre-change level… + const before = await listDirectory(dir, 100) + expect(before.entries).toEqual([]) + const watchers = createDirectoryWatchers(event => events.push(event), () => {}) + try { + expect(watchers.add(dir)).toBe(true) + writeFileSync(join(dir, 'new.txt'), 'x') + await vi.waitFor(() => { expect(events.length).toBeGreaterThan(0) }, { timeout: DELIVERY_TIMEOUT_MS }) + // …then the re-list the client is about to send must NOT be answered + // from that warm entry (the watcher invalidates before pushing). + const after = await listDirectory(dir, 100) + expect(after).not.toBe(before) + expect(after.entries.map(entry => entry.name)).toEqual(['new.txt']) + } finally { + watchers.close() + } + }) + }) + it('folds a burst of changes into a single notice', async () => { await withScratch(async (dir) => { const events: DirectoryWatchEvent[] = [] diff --git a/tests/native-surface.spec.ts b/tests/native-surface.spec.ts index 88ca574..41356c1 100644 --- a/tests/native-surface.spec.ts +++ b/tests/native-surface.spec.ts @@ -11,7 +11,7 @@ import { act } from 'react-dom/test-utils' import { createNativeTabRecords, NativeTabBody, NativeTabTitle } from '../src/client/native/tab-adapter.tsx' import { registerNativeSurface } from '../src/client/native/index.ts' import { createBetterSidebarService, type SidebarSurface } from '../src/client/service.ts' -import { createSidebarStore, type SidebarTab } from '../src/client/state.ts' +import { createSidebarStore, toggleExpanded, type SidebarTab } from '../src/client/state.ts' const scope = { sessionId: 's1', cwd: '/work' } @@ -45,17 +45,89 @@ describe('createNativeTabRecords', () => { expect(view.tab).toMatchObject({ id: 'tab-3', path: '/work/b.ts', title: 'renamed.ts' }) }) - it('tracks expansion per record and bumps its version', () => { + it('tracks expansion in the SESSION state and bumps the record version', () => { + const store = createSidebarStore() + store.setSession('s1') const records = createNativeTabRecords() + records.attachStore(store) records.ensure({ id: 'tab-4', kind: 'editor', title: 'Files', params: undefined, scope }) const before = records.versionOf('tab-4') records.toggleExpanded('tab-4', '/work/src') expect(records.get('tab-4')?.expanded).toEqual(['/work/src']) + expect(store.getSessionStates().get('s1')?.expanded).toEqual(['/work/src']) expect(records.versionOf('tab-4')).toBeGreaterThan(before) records.toggleExpanded('tab-4', '/work/src') expect(records.get('tab-4')?.expanded).toEqual([]) }) + it('keeps the expansion after a tab record is DROPPED and rebuilt (the reported bug)', () => { + // The user's flow: open a file preview (the files tab unmounts and its + // native record is dropped), close it, return to the files page — a NEW + // record for the SAME session must show the set the user had. + const store = createSidebarStore() + store.setSession('s1') + const records = createNativeTabRecords() + records.attachStore(store) + records.ensure({ id: 'files-1', kind: 'files', title: 'Files', params: undefined, scope }) + records.toggleExpanded('files-1', '/work/src') + records.toggleExpanded('files-1', '/work/src/components') + expect(records.get('files-1')?.expanded).toEqual(['/work/src', '/work/src/components']) + + records.drop('files-1') + const rebuilt = records.ensure({ id: 'files-2', kind: 'files', title: 'Files', params: undefined, scope }) + expect(rebuilt.expanded).toEqual(['/work/src', '/work/src/components']) + }) + + it('shares one expansion set between two native tabs of the same session', () => { + const store = createSidebarStore() + store.setSession('s1') + const records = createNativeTabRecords() + records.attachStore(store) + records.ensure({ id: 'files-a', kind: 'files', title: 'Files', params: undefined, scope }) + records.ensure({ id: 'files-b', kind: 'files', title: 'Files', params: undefined, scope }) + + records.toggleExpanded('files-a', '/work/src') + expect(records.get('files-b')?.expanded).toEqual(['/work/src']) + + // The WORKBENCH's own toggle (the per-session reducer) is the same state. + store.reduce(state => toggleExpanded(state, '/work/lib')) + expect(records.get('files-a')?.expanded).toEqual(['/work/src', '/work/lib']) + expect(records.get('files-b')?.expanded).toEqual(['/work/src', '/work/lib']) + + // …and a toggle from the native side is visible to the workbench reducer's + // state (one authority, two surfaces). + records.toggleExpanded('files-b', '/work/src') + expect(store.getSessionStates().get('s1')?.expanded).toEqual(['/work/lib']) + }) + + it('keeps two sessions apart (a native tab in each)', () => { + const store = createSidebarStore() + store.setSession('s1') + const records = createNativeTabRecords() + records.attachStore(store) + records.ensure({ id: 's1-files', kind: 'files', title: 'Files', params: undefined, scope }) + records.ensure({ id: 's2-files', kind: 'files', title: 'Files', params: undefined, scope: { sessionId: 's2', cwd: '/other' } }) + + // The ACTIVE session goes through `reduce`; the background one through + // `reduceFor` — both must land in their own session only. + records.toggleExpanded('s1-files', '/work/src') + records.toggleExpanded('s2-files', '/other/lib') + expect(records.get('s1-files')?.expanded).toEqual(['/work/src']) + expect(records.get('s2-files')?.expanded).toEqual(['/other/lib']) + expect(store.getSessionStates().get('s1')?.expanded).toEqual(['/work/src']) + expect(store.getSessionStates().get('s2')?.expanded).toEqual(['/other/lib']) + }) + + it('reflects a store change made while no view is subscribed yet', () => { + const store = createSidebarStore() + store.setSession('s1') + store.reduce(state => toggleExpanded(state, '/work/src')) + const records = createNativeTabRecords() + records.attachStore(store) + const view = records.ensure({ id: 'late', kind: 'files', title: 'Files', params: undefined, scope }) + expect(view.expanded).toEqual(['/work/src']) + }) + it('notifies subscribers and forgets a dropped record', () => { const records = createNativeTabRecords() records.ensure({ id: 'tab-5', kind: 'terminal', title: 'Terminal', params: undefined, scope }) diff --git a/tests/plugin-shape.spec.ts b/tests/plugin-shape.spec.ts index 721162e..0e881a7 100644 --- a/tests/plugin-shape.spec.ts +++ b/tests/plugin-shape.spec.ts @@ -70,9 +70,14 @@ describe('dsh-better-sidebar plugin export shape', () => { // The separate file-window mode is the default (each file opens its own // tab; the merged editor-explorer is opt-in). expect(resolved.editorExplorer).toBe(false) - // The workspace fence (containment over the sidebar fs routes) defaults - // ON — the safe default never depends on the stored document. - expect(resolved.workspaceFence).toBe(true) + // The workspace fence is GONE: `workspaceFence` is no longer a declared + // schema field, so a resolved document does not carry it. A legacy stored + // value passes through the OPEN object schema untouched (and is inert). + expect(resolved.workspaceFence).toBeUndefined() + const legacy = (PrefsSchema as unknown as { + (input: Record | undefined): Record + })({ workspaceFence: true }) + expect(legacy.workspaceFence).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. diff --git a/tests/prefs.spec.ts b/tests/prefs.spec.ts index 085746b..f38dc58 100644 --- a/tests/prefs.spec.ts +++ b/tests/prefs.spec.ts @@ -39,7 +39,6 @@ describe('side card preferences', () => { mobileDefaultTree: true, agentOpenTools: true, editorExplorer: false, - workspaceFence: true, titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', @@ -63,7 +62,6 @@ describe('side card preferences', () => { mobileDefaultTree: true, agentOpenTools: false, editorExplorer: false, - workspaceFence: true, titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', @@ -87,7 +85,6 @@ describe('side card preferences', () => { mobileDefaultTree: true, agentOpenTools: false, editorExplorer: false, - workspaceFence: true, titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', @@ -138,13 +135,13 @@ describe('side card preferences', () => { expect((await loadPrefs(wire({ editorExplorer: true }))).editorExplorer).toBe(true) }) - it('defaults workspaceFence to true; only an explicit false disarms the containment guard', async () => { - // Absent or malformed → on (the fs routes keep refusing outside paths). - expect((await loadPrefs(wire({}))).workspaceFence).toBe(true) - expect((await loadPrefs(wire({ workspaceFence: 'no' }))).workspaceFence).toBe(true) - expect((await loadPrefs(wire({ workspaceFence: 0 }))).workspaceFence).toBe(true) - // An explicit false survives (the one-click off in the fence error notice). - expect((await loadPrefs(wire({ workspaceFence: false }))).workspaceFence).toBe(false) + it('no longer exposes a workspaceFence pref (the containment guard is gone)', async () => { + // The field was retired together with the fence: whatever a stored + // document (or a legacy profile) still carries, the typed prefs the client + // consumes do not have it — there is nothing left to arm or disarm. + expect('workspaceFence' in await loadPrefs(wire({}))).toBe(false) + expect('workspaceFence' in await loadPrefs(wire({ workspaceFence: true }))).toBe(false) + expect('workspaceFence' in await loadPrefs(wire({ workspaceFence: false }))).toBe(false) }) it('defaults the title-bar scheme to the conservative auto with no preset or custom CSS', async () => { diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 2827ea4..247974b 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -9,7 +9,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSyn import { tmpdir } from 'node:os' import { join, resolve as resolvePath } from 'node:path' import { SettingsConflictError, type SettingsNamespace } from '@deepseek-ai/dsh-settings' -import { apply, mediaTypeForPath } from '../src/index.ts' +import { apply, FS_TREES_MAX_PATHS, mediaTypeForPath } from '../src/index.ts' import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts' import { encodeHtmlUrl } from '../src/html-route.ts' import * as git from '../src/git.ts' @@ -475,17 +475,20 @@ describe('session cwd resolution over the API route', () => { expect(value.value?.content).toContain('runGit') }) - it('rejects repo-root-relative fs.read paths outside a nested session workspace', async () => { + it('reads repo-root-relative paths outside a nested session workspace (fence removed)', async () => { const route = mount({ sessions: { get: () => ({ header: { cwd: join(process.cwd(), 'src') } }), }, }) + // ⚠️ PERMISSION CHANGE: `package.json` sits ABOVE the nested session cwd. + // The containment guard is gone, so this now reads like any other path. const result = await invoke(route, 'fs.read', { sessionId: 's-sub', path: 'package.json' }) - expect(result).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } }) + expect(result.ok).toBe(true) + expect((result.value as { content?: string } | undefined)?.content).toContain('"name"') }) - it('rejects fs.tree paths outside the session workspace', async () => { + it('lists a directory outside the session workspace (fence removed)', async () => { const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-security-')) const workspace = join(root, 'workspace') const outside = join(root, 'outside') @@ -496,13 +499,16 @@ describe('session cwd resolution over the API route', () => { try { const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } }) const tree = await invoke(route, 'fs.tree', { sessionId: 'security', path: outside }) - expect(tree).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } }) + expect(tree.ok).toBe(true) + const value = tree.value as unknown as { path: string; entries: Array<{ name: string }> } + expect(value.path).toBe(outside) + expect(value.entries.map(entry => entry.name)).toEqual(['secret.txt']) } finally { rmSync(root, { recursive: true, force: true }) } }) - it('rejects fs.read paths outside the session workspace', async () => { + it('reads a file outside the session workspace (fence removed)', async () => { const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-security-')) const workspace = join(root, 'workspace') const outside = join(root, 'outside') @@ -513,52 +519,56 @@ describe('session cwd resolution over the API route', () => { try { const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } }) const read = await invoke(route, 'fs.read', { sessionId: 'security', path: outsideFile }) - expect(read).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } }) + expect(read.ok).toBe(true) + expect((read.value as unknown as { kind: string; content: string }).content).toBe('secret') } finally { rmSync(root, { recursive: true, force: true }) } }) - it('rejects fs.write paths outside the session workspace', async () => { + it('writes a file outside the session workspace (fence removed)', async () => { const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-security-')) const workspace = join(root, 'workspace') const outside = join(root, 'outside') mkdirSync(workspace) mkdirSync(outside) + const written = join(outside, 'written.txt') try { const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } }) - const write = await invoke(route, 'fs.write', { sessionId: 'security', path: join(outside, 'written.txt'), content: 'hack' }) - expect(write).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } }) + const write = await invoke(route, 'fs.write', { sessionId: 'security', path: written, content: 'hack' }) + expect(write.ok).toBe(true) + expect(readFileSync(written, 'utf8')).toBe('hack') } finally { rmSync(root, { recursive: true, force: true }) } }) - it('rejects media and HTML reads through a workspace symlink', async () => { + it('serves media and HTML through a workspace symlink (fence removed)', async () => { if (!canCreateSymlink) return const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-route-symlink-security-')) const workspace = join(root, 'workspace') const outside = join(root, 'outside') mkdirSync(workspace) mkdirSync(outside) - const mediaPath = join(outside, 'secret.png') - const htmlPath = join(outside, 'secret.html') - writeFileSync(mediaPath, 'not an image') - writeFileSync(htmlPath, '

secret

') + const mediaPath = join(outside, 'pic.png') + const htmlPath = join(outside, 'page.html') + // A real 1x1 PNG: the media route sniffs the magic bytes, a text file + // would be refused as "not a file or too large" for the wrong reason. + writeFileSync(mediaPath, Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==', 'base64')) + writeFileSync(htmlPath, '

outside

') try { symlinkSync(outside, join(workspace, 'link')) const routes = mountAll({ sessions: { get: () => ({ header: { cwd: workspace } }) } }) const media = routes.find(route => route.path === '/sidebar/file')! const html = routes.find(route => route.path === '/sidebar/html')! - const mediaResult = await invokeGet(media, `/sidebar/file?sessionId=security&path=${encodeURIComponent(join(workspace, 'link', 'secret.png'))}`) + const mediaResult = await invokeGet(media, `/sidebar/file?sessionId=security&path=${encodeURIComponent(join(workspace, 'link', 'pic.png'))}`) // Use the production encoder so the URL is well-formed on every // platform (a Windows drive path needs the leading slash separator // that a naive join-without-separator drops). - const htmlResult = await invokeGet(html, encodeHtmlUrl('security', join(workspace, 'link', 'secret.html'))) - expect(mediaResult).toMatchObject({ status: 403 }) - expect(JSON.parse(mediaResult.body)).toMatchObject({ ok: false, error: { code: 'forbidden' } }) - expect(htmlResult).toMatchObject({ status: 403 }) - expect(JSON.parse(htmlResult.body)).toMatchObject({ ok: false, error: { code: 'forbidden' } }) + const htmlResult = await invokeGet(html, encodeHtmlUrl('security', join(workspace, 'link', 'page.html'))) + expect(mediaResult).toMatchObject({ status: 200 }) + expect(htmlResult).toMatchObject({ status: 200 }) + expect(htmlResult.body).toContain('outside') } finally { rmSync(root, { recursive: true, force: true }) } @@ -577,7 +587,76 @@ describe('session cwd resolution over the API route', () => { } }) - it.skipIf(!canCreateSymlink)('rejects workspace symlinks that resolve outside the workspace', async () => { + it('fs.trees returns every requested level in one batch, errors in place', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-trees-')) + const workspace = join(root, 'workspace') + mkdirSync(join(workspace, 'src', 'deep'), { recursive: true }) + writeFileSync(join(workspace, 'src', 'a.ts'), 'a') + writeFileSync(join(workspace, 'src', 'deep', 'b.ts'), 'b') + try { + const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } }) + const result = await invoke(route, 'fs.trees', { + sessionId: 's-tree', + // A relative path, an absolute path and one that does not exist: the + // batch answers all three, the failure only on its own level. + paths: ['src', join(workspace, 'src', 'deep'), join(workspace, 'missing')], + }) + expect(result.ok).toBe(true) + const levels = (result.value as unknown as { levels: Array<{ path: string; entries: Array<{ name: string }>; truncated: boolean; error?: string }> }).levels + expect(levels).toHaveLength(3) + expect(levels[0]).toMatchObject({ path: join(workspace, 'src'), truncated: false }) + expect(levels[0]!.entries.map(entry => entry.name)).toEqual(['deep', 'a.ts']) + expect(levels[1]!.entries.map(entry => entry.name)).toEqual(['b.ts']) + expect(levels[1]!.error).toBeUndefined() + expect(levels[2]!.path).toBe(join(workspace, 'missing')) + expect(levels[2]!.entries).toEqual([]) + expect(levels[2]!.truncated).toBe(false) + expect(levels[2]!.error).toMatch(/missing/) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('fs.trees warms the same cache fs.tree reads (and vice versa)', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-trees-cache-')) + const workspace = join(root, 'workspace') + mkdirSync(workspace) + writeFileSync(join(workspace, 'a.txt'), 'a') + try { + const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } }) + const batch = await invoke(route, 'fs.trees', { sessionId: 's-tree', paths: [workspace] }) + expect(batch.ok).toBe(true) + const level = (batch.value as unknown as { levels: Array<{ entries: Array<{ name: string }> }> }).levels[0]! + // A file created after the batch is invisible to the single-path route + // too — proof the two routes share one cache entry. + writeFileSync(join(workspace, 'b.txt'), 'b') + const single = await invoke(route, 'fs.tree', { sessionId: 's-tree', path: workspace }) + expect((single.value as unknown as { entries: Array<{ name: string }> }).entries.map(entry => entry.name)) + .toEqual(level.entries.map(entry => entry.name)) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('fs.trees rejects an empty list and an oversized batch', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-trees-guard-')) + const workspace = join(root, 'workspace') + mkdirSync(workspace) + try { + const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } }) + const empty = await invoke(route, 'fs.trees', { sessionId: 's-tree', paths: [] }) + expect(empty).toMatchObject({ ok: false, status: 400, error: { code: 'bad-request' } }) + const tooMany = await invoke(route, 'fs.trees', { + sessionId: 's-tree', + paths: Array.from({ length: FS_TREES_MAX_PATHS + 1 }, () => workspace), + }) + expect(tooMany).toMatchObject({ ok: false, status: 400, error: { code: 'bad-request' } }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it.skipIf(!canCreateSymlink)('lists a workspace symlink that resolves outside the workspace', async () => { const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-symlink-security-')) const workspace = join(root, 'workspace') const outside = join(root, 'outside') @@ -587,14 +666,16 @@ describe('session cwd resolution over the API route', () => { try { symlinkSync(outside, join(workspace, 'link')) const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } }) + // No realpath: the link is followed like any other directory. const tree = await invoke(route, 'fs.tree', { sessionId: 'security', path: join(workspace, 'link') }) - expect(tree).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } }) + expect(tree.ok).toBe(true) + expect((tree.value as unknown as { entries: Array<{ name: string }> }).entries.map(entry => entry.name)).toEqual(['secret.txt']) } finally { rmSync(root, { recursive: true, force: true }) } }) - it.skipIf(!canCreateSymlink)('rejects fs.read through a workspace symlink', async () => { + it.skipIf(!canCreateSymlink)('reads through a workspace symlink', async () => { const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-symlink-security-')) const workspace = join(root, 'workspace') const outside = join(root, 'outside') @@ -605,13 +686,14 @@ describe('session cwd resolution over the API route', () => { symlinkSync(outside, join(workspace, 'link')) const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } }) const read = await invoke(route, 'fs.read', { sessionId: 'security', path: join(workspace, 'link', 'secret.txt') }) - expect(read).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } }) + expect(read.ok).toBe(true) + expect((read.value as unknown as { content: string }).content).toBe('secret') } finally { rmSync(root, { recursive: true, force: true }) } }) - it.skipIf(!canCreateSymlink)('rejects fs.write through a workspace symlink', async () => { + it.skipIf(!canCreateSymlink)('writes through a workspace symlink', async () => { const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-symlink-security-')) const workspace = join(root, 'workspace') const outside = join(root, 'outside') @@ -621,7 +703,9 @@ describe('session cwd resolution over the API route', () => { symlinkSync(outside, join(workspace, 'link')) const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } }) const write = await invoke(route, 'fs.write', { sessionId: 'security', path: join(workspace, 'link', 'new.txt'), content: 'hack' }) - expect(write).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } }) + expect(write.ok).toBe(true) + // The write landed on the link's TARGET (the link is not replaced). + expect(readFileSync(join(outside, 'new.txt'), 'utf8')).toBe('hack') } finally { rmSync(root, { recursive: true, force: true }) } @@ -794,7 +878,6 @@ describe('side card settings routes', () => { autoOpenJobs: true, agentOpenTools: false, editorExplorer: false, - workspaceFence: true, // These title-bar fields are declared without a schema default on // purpose, so a document predating them migrates rather than flips. titleBarCompat: false, @@ -866,7 +949,7 @@ describe('side card settings routes', () => { } }) - it('disarms the workspace fence for the fs routes when the pref is off', async () => { + it('reaches outside the workspace with no pref set (the fence is gone)', async () => { const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fence-off-')) const workspace = join(root, 'workspace') const outside = join(root, 'outside') @@ -874,14 +957,10 @@ describe('side card settings routes', () => { mkdirSync(outside) writeFileSync(join(outside, 'secret.txt'), 'global instructions') try { + // ⚠️ PERMISSION CHANGE: the default is now "no containment", and there + // is no switch to arm it — the routes read/write wherever the host user + // can. (`workspaceFence` in an old profile is simply an unknown key.) const route = mountWithSettings(createFakeSettings()) - // Default (fence on): the outside read is refused as usual… - const refused = await invoke(route, 'fs.read', { sessionId: 'fence', cwd: workspace, path: join(outside, 'secret.txt') }) - expect(refused).toMatchObject({ ok: false, error: { code: 'forbidden' } }) - // …then the settings-page switch (or the fence notice's one-click off) - // disarms every fs route for paths outside the workspace. - const off = await invoke(route, 'settings.update', { patch: { workspaceFence: false } }) - expect(off.ok).toBe(true) const read = await invoke(route, 'fs.read', { sessionId: 'fence', cwd: workspace, path: join(outside, 'secret.txt') }) expect(read).toMatchObject({ ok: true, value: { kind: 'text', content: 'global instructions' } }) const tree = await invoke(route, 'fs.tree', { sessionId: 'fence', cwd: workspace, path: outside }) @@ -889,6 +968,13 @@ describe('side card settings routes', () => { const write = await invoke(route, 'fs.write', { sessionId: 'fence', cwd: workspace, path: join(outside, 'written.txt'), content: 'ok' }) expect(write).toMatchObject({ ok: true }) expect(readFileSync(join(outside, 'written.txt'), 'utf8')).toBe('ok') + // A legacy `workspaceFence: true` patch is just an unknown prefs key now + // (the schema no longer declares it): it must not fail the write and must + // not re-arm anything. + const legacy = await invoke(route, 'settings.update', { patch: { workspaceFence: true } }) + expect(legacy.ok).toBe(true) + const stillOpen = await invoke(route, 'fs.read', { sessionId: 'fence', cwd: workspace, path: join(outside, 'secret.txt') }) + expect(stillOpen).toMatchObject({ ok: true }) } finally { rmSync(root, { recursive: true, force: true }) } diff --git a/tests/zip.spec.ts b/tests/zip.spec.ts index b74c9c2..1552226 100644 --- a/tests/zip.spec.ts +++ b/tests/zip.spec.ts @@ -826,7 +826,27 @@ describe('archive task flow (build → status → download)', () => { expect(byName.get('b/index.ts')!.data.toString('utf8')).toBe('from b\n') }) - it('never follows a symlink out of the workspace', async () => { + it('zips a path outside the session workspace (fence removed)', async () => { + const root = tempRoot() + const workspace = join(root, 'workspace') + const outside = join(root, 'outside') + mkdirSync(workspace) + mkdirSync(outside) + writeFileSync(join(outside, 'secret.txt'), 'secret') + const { download } = await archiveFlow({ + sessionId: 's-zip', + cwd: workspace, + paths: [join(outside, 'secret.txt')], + }) + // ⚠️ PERMISSION CHANGE: an absolute path outside the workspace is now a + // normal selection (no 403 branch exists any more). + expect(download.status).toBe(200) + const entries = readZip(download.body) + expect(entries.map(entry => entry.name)).toEqual(['secret.txt']) + expect(entries[0]!.data.toString('utf8')).toBe('secret') + }) + + it('skips a symlink row while walking a directory (no escape by following)', async () => { const root = tempRoot() const workspace = join(root, 'workspace') const outside = join(root, 'outside') @@ -835,46 +855,20 @@ describe('archive task flow (build → status → download)', () => { writeFileSync(join(outside, 'secret.txt'), 'secret') try { symlinkSync(outside, join(workspace, 'link')) - // A file INSIDE the workspace, so the archive itself is readable. writeFileSync(join(workspace, 'keep.txt'), 'keep') } catch { return // symlink creation needs privileges on Windows } - const { api, download } = mountHost() - // Selecting the escaping link refuses at the workspace fence (the build - // canonicalizes before walking), so its target is never read… - const linked = await post(api, 'archive.build', { sessionId: 's-zip', cwd: workspace, paths: [join(workspace, 'link')] }) - expect(linked.ok).toBe(false) - expect(linked.status).toBe(403) - // …and walking the containing directory reports the link as an entry-less - // row instead of descending into it. - const build = await post(api, 'archive.build', { sessionId: 's-zip', cwd: workspace, paths: [workspace] }) - const id = (build.value as { id: string }).id - await waitForReady(api, id, 's-zip') - const walked = await get(download, `/sidebar/archive?sessionId=s-zip&id=${encodeURIComponent(id)}`) - expect(walked.status).toBe(200) - const entries = readZip(walked.body) + // A DIRECTORY walk still never descends through a link (that rule is about + // cycles, not containment): the link row is reported as an entry-less row, + // so the archive holds no bytes from its target. + const { download } = await archiveFlow({ sessionId: 's-zip', cwd: workspace, paths: [workspace] }) + expect(download.status).toBe(200) + const entries = readZip(download.body) expect(entries.map(entry => entry.name)).toEqual(['workspace/', 'workspace/keep.txt']) expect(entries.some(entry => entry.data.toString('utf8').includes('secret'))).toBe(false) }) - it('refuses a path outside the workspace with 403', async () => { - const root = tempRoot() - const workspace = join(root, 'workspace') - const outside = join(root, 'outside') - mkdirSync(workspace) - mkdirSync(outside) - writeFileSync(join(outside, 'secret.txt'), 'secret') - const { api } = mountHost() - const response = await post(api, 'archive.build', { - sessionId: 's-zip', - cwd: workspace, - paths: [join(outside, 'secret.txt')], - }) - expect(response.status).toBe(403) - expect(response.error?.code).toBe('forbidden') - }) - it('rejects an empty path list and a download without sessionId/id (400)', async () => { const root = tempRoot() const workspace = join(root, 'workspace')